Repository: mattermost-community/focalboard Branch: main Commit: a84bbb65e32e Files: 1547 Total size: 11.1 MB Directory structure: gitextract_a_j2svim/ ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── doc_improvement.md │ │ └── enhancement.md │ ├── codeql/ │ │ └── codeql-config.yml │ ├── dependabot.yml │ └── workflows/ │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── dev-release.yml │ ├── lint-server.yml │ ├── prod-release.yml │ └── scorecards-analysis.yml ├── .gitignore ├── .gitlab-ci.yml ├── .gitpod.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile.build ├── LICENSE.txt ├── Makefile ├── NOTICE.txt ├── README.md ├── SECURITY.md ├── app-config.json ├── config.json ├── docker/ │ ├── Dockerfile │ ├── README.md │ ├── config.json │ ├── docker-compose-db-nginx.yml │ ├── docker-compose.yml │ └── server_config.json ├── docker-testing/ │ ├── docker-compose-mariadb.yml │ ├── docker-compose-mysql.yml │ └── docker-compose-postgres.yml ├── docs/ │ ├── README.md │ ├── _config.yml │ ├── code-review.md │ ├── contribution-checklist.md │ ├── contributions-without-ticket.md │ ├── core-committers.md │ ├── dev-tips.md │ ├── focalboard-dev-guide.md │ └── index.md ├── experiments/ │ └── webext/ │ ├── .gitignore │ ├── .parcelrc │ ├── README.md │ ├── manifest.json │ ├── package.json │ ├── src/ │ │ ├── utils/ │ │ │ ├── Board.ts │ │ │ ├── networking.ts │ │ │ └── settings.ts │ │ └── views/ │ │ ├── OptionsApp.scss │ │ ├── OptionsApp.tsx │ │ ├── PopupApp.scss │ │ ├── PopupApp.tsx │ │ ├── options.html │ │ ├── options.tsx │ │ ├── popup.html │ │ └── popup.tsx │ └── tsconfig.json ├── import/ │ ├── README.md │ ├── asana/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── asana.ts │ │ ├── importAsana.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── utils.ts │ ├── jira/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── importJira.ts │ │ ├── jiraImporter.test.ts │ │ ├── jiraImporter.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── utils.ts │ ├── nextcloud-deck/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── deck.ts │ │ ├── importDeck.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── utils.ts │ ├── notion/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── importNotion.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── utils.ts │ ├── todoist/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── importTodoist.ts │ │ ├── package.json │ │ ├── todoist.ts │ │ ├── tsconfig.json │ │ └── utils.ts │ ├── trello/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── importTrello.ts │ │ ├── package.json │ │ ├── trello.ts │ │ ├── tsconfig.json │ │ └── utils.ts │ └── util/ │ └── archive.ts ├── linux/ │ ├── Makefile │ ├── go.mod │ ├── go.sum │ └── main.go ├── mac/ │ ├── Focalboard/ │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── AutoSaveWindowController.swift │ │ ├── Base.lproj/ │ │ │ └── Main.storyboard │ │ ├── CustomWKWebView.swift │ │ ├── DownloadHandler.swift │ │ ├── Focalboard.entitlements │ │ ├── Globals.swift │ │ ├── Info.plist │ │ ├── Inherit.entitlements │ │ ├── PortUtils.swift │ │ ├── ViewController.swift │ │ ├── WhatsNewViewController.swift │ │ └── whatsnew.txt │ ├── Focalboard.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ └── Focalboard.xcscheme │ ├── Focalboard.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ └── IDEWorkspaceChecks.plist │ ├── FocalboardTests/ │ │ ├── FocalboardTests.swift │ │ └── Info.plist │ ├── FocalboardUITests/ │ │ ├── FocalboardUITests.swift │ │ └── Info.plist │ ├── README.md │ └── export.plist ├── modd-servertest.conf ├── modd.conf ├── noticegen/ │ ├── Readme.md │ └── config.yaml ├── pull_request_template.md ├── responsible_disclosure_policy.md ├── server/ │ ├── .golangci.yml │ ├── admin-scripts/ │ │ └── reset-password.sh │ ├── api/ │ │ ├── admin.go │ │ ├── api.go │ │ ├── api_test.go │ │ ├── archive.go │ │ ├── audit.go │ │ ├── auth.go │ │ ├── blocks.go │ │ ├── boards.go │ │ ├── boards_and_blocks.go │ │ ├── cards.go │ │ ├── categories.go │ │ ├── channels.go │ │ ├── compliance.go │ │ ├── config.go │ │ ├── content_blocks.go │ │ ├── context.go │ │ ├── files.go │ │ ├── members.go │ │ ├── onboarding.go │ │ ├── search.go │ │ ├── sharing.go │ │ ├── statistics.go │ │ ├── subscriptions.go │ │ ├── system.go │ │ ├── system_test.go │ │ ├── teams.go │ │ ├── templates.go │ │ └── users.go │ ├── app/ │ │ ├── app.go │ │ ├── app_test.go │ │ ├── auth.go │ │ ├── auth_test.go │ │ ├── blocks.go │ │ ├── blocks_test.go │ │ ├── boards.go │ │ ├── boards_and_blocks.go │ │ ├── boards_test.go │ │ ├── cards.go │ │ ├── cards_test.go │ │ ├── category.go │ │ ├── category_boards.go │ │ ├── category_boards_test.go │ │ ├── category_test.go │ │ ├── clientConfig.go │ │ ├── clientConfig_test.go │ │ ├── compliance.go │ │ ├── content_blocks.go │ │ ├── content_blocks_test.go │ │ ├── export.go │ │ ├── files.go │ │ ├── files_test.go │ │ ├── helper_test.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── initialize.go │ │ ├── onboarding.go │ │ ├── onboarding_test.go │ │ ├── permissions.go │ │ ├── server_metadata.go │ │ ├── server_metadata_test.go │ │ ├── sharing.go │ │ ├── sharing_test.go │ │ ├── statistics.go │ │ ├── subscriptions.go │ │ ├── teams.go │ │ ├── teams_test.go │ │ ├── templates.go │ │ ├── templates_test.go │ │ ├── user.go │ │ └── user_test.go │ ├── assets/ │ │ ├── assets.go │ │ ├── build-template-archive/ │ │ │ └── main.go │ │ └── templates-boardarchive/ │ │ ├── b7wnw9awd4pnefryhq51apbzb4c/ │ │ │ └── board.jsonl │ │ ├── bbkpwdj8x17bdpdqd176n8ctoua/ │ │ │ └── board.jsonl │ │ ├── bbn1888mprfrm5fjw9f1je9x3xo/ │ │ │ └── board.jsonl │ │ ├── bc41mwxg9ybb69pn9j5zna6d36c/ │ │ │ └── board.jsonl │ │ ├── bcm39o11e4ib8tye8mt6iyuec9o/ │ │ │ └── board.jsonl │ │ ├── bd65qbzuqupfztpg31dgwgwm5ga/ │ │ │ └── board.jsonl │ │ ├── bgi1yqiis8t8xdqxgnet8ebutky/ │ │ │ └── board.jsonl │ │ ├── bh4pkixqsjift58e1qy6htrgeay/ │ │ │ └── board.jsonl │ │ ├── bkqk6hpfx7pbsucue7jan5n1o1o/ │ │ │ └── board.jsonl │ │ ├── brs9cdimfw7fodyi7erqt747rhc/ │ │ │ └── board.jsonl │ │ ├── bsjd59qtpbf888mqez3ge77domw/ │ │ │ └── board.jsonl │ │ ├── bui5izho7dtn77xg3thkiqprc9r/ │ │ │ └── board.jsonl │ │ ├── buixxjic3xjfkieees4iafdrznc/ │ │ │ └── board.jsonl │ │ └── version.json │ ├── auth/ │ │ ├── auth.go │ │ ├── auth_test.go │ │ └── mocks/ │ │ └── mockauth_interface.go │ ├── client/ │ │ └── client.go │ ├── go.mod │ ├── go.sum │ ├── go.tools.mod │ ├── go.tools.sum │ ├── integrationtests/ │ │ ├── blocks_test.go │ │ ├── board_test.go │ │ ├── boards_and_blocks_test.go │ │ ├── cards_test.go │ │ ├── clienttestlib.go │ │ ├── compliance_test.go │ │ ├── content_blocks_test.go │ │ ├── export_test.go │ │ ├── file_test.go │ │ ├── permissions_test.go │ │ ├── pluginteststore.go │ │ ├── sharing_test.go │ │ ├── sidebar_test.go │ │ ├── subscriptions_test.go │ │ ├── teststore.go │ │ ├── user_test.go │ │ └── work_template_test.go │ ├── main/ │ │ ├── doc.go │ │ └── main.go │ ├── model/ │ │ ├── auth.go │ │ ├── block.go │ │ ├── block_test.go │ │ ├── blockid.go │ │ ├── blocktype.go │ │ ├── board.go │ │ ├── board_statistics.go │ │ ├── boards_and_blocks.go │ │ ├── boards_and_blocks_test.go │ │ ├── card.go │ │ ├── card_test.go │ │ ├── category.go │ │ ├── category_boards.go │ │ ├── clientConfig.go │ │ ├── cloud.go │ │ ├── compliance.go │ │ ├── database.go │ │ ├── error.go │ │ ├── errorResponse.go │ │ ├── file.go │ │ ├── import_export.go │ │ ├── log_level.go │ │ ├── mocks/ │ │ │ ├── mockservicesapi.go │ │ │ └── propValueResolverMock.go │ │ ├── notification.go │ │ ├── permission.go │ │ ├── properties.go │ │ ├── properties_test.go │ │ ├── services_api.go │ │ ├── sharing.go │ │ ├── subscription.go │ │ ├── team.go │ │ ├── user.go │ │ ├── util.go │ │ └── version.go │ ├── server/ │ │ ├── initHandlers.go │ │ ├── params.go │ │ └── server.go │ ├── services/ │ │ ├── audit/ │ │ │ ├── audit.go │ │ │ ├── record.go │ │ │ └── record_test.go │ │ ├── auth/ │ │ │ ├── email.go │ │ │ ├── password.go │ │ │ ├── password_test.go │ │ │ ├── request_parser.go │ │ │ └── request_parser_test.go │ │ ├── config/ │ │ │ └── config.go │ │ ├── metrics/ │ │ │ ├── metrics.go │ │ │ └── service.go │ │ ├── notify/ │ │ │ ├── notifylogger/ │ │ │ │ └── logger_backend.go │ │ │ ├── notifymentions/ │ │ │ │ ├── app_api.go │ │ │ │ ├── delivery.go │ │ │ │ ├── extract.go │ │ │ │ ├── extract_test.go │ │ │ │ ├── mentions.go │ │ │ │ ├── mentions_backend.go │ │ │ │ └── mentions_test.go │ │ │ ├── notifysubscriptions/ │ │ │ │ ├── app_api.go │ │ │ │ ├── delivery.go │ │ │ │ ├── diff.go │ │ │ │ ├── diff2markdown.go │ │ │ │ ├── diff2markdown_test.go │ │ │ │ ├── diff2slackattachments.go │ │ │ │ ├── notifier.go │ │ │ │ ├── subscriptions_backend.go │ │ │ │ └── util.go │ │ │ ├── plugindelivery/ │ │ │ │ ├── mention_deliver.go │ │ │ │ ├── message.go │ │ │ │ ├── plugin_delivery.go │ │ │ │ ├── subscription_deliver.go │ │ │ │ ├── user.go │ │ │ │ └── user_test.go │ │ │ └── service.go │ │ ├── permissions/ │ │ │ ├── localpermissions/ │ │ │ │ ├── helpers_test.go │ │ │ │ ├── localpermissions.go │ │ │ │ └── localpermissions_test.go │ │ │ ├── mmpermissions/ │ │ │ │ ├── helpers_test.go │ │ │ │ ├── mmpermissions.go │ │ │ │ ├── mmpermissions_test.go │ │ │ │ └── mocks/ │ │ │ │ └── mockpluginapi.go │ │ │ ├── mocks/ │ │ │ │ └── mockstore.go │ │ │ └── permissions.go │ │ ├── scheduler/ │ │ │ ├── scheduler.go │ │ │ └── scheduler_test.go │ │ ├── store/ │ │ │ ├── generators/ │ │ │ │ ├── main.go │ │ │ │ └── transactional_store.go.tmpl │ │ │ ├── mattermostauthlayer/ │ │ │ │ ├── mattermostauthlayer.go │ │ │ │ └── mattermostauthlayer_test.go │ │ │ ├── mockstore/ │ │ │ │ └── mockstore.go │ │ │ ├── sqlstore/ │ │ │ │ ├── blocks.go │ │ │ │ ├── board.go │ │ │ │ ├── boards_and_blocks.go │ │ │ │ ├── category.go │ │ │ │ ├── category_boards.go │ │ │ │ ├── cloud.go │ │ │ │ ├── compliance.go │ │ │ │ ├── data_migrations.go │ │ │ │ ├── data_migrations_test.go │ │ │ │ ├── data_retention.go │ │ │ │ ├── file.go │ │ │ │ ├── helpers_test.go │ │ │ │ ├── legacy_blocks.go │ │ │ │ ├── migrate.go │ │ │ │ ├── migrations/ │ │ │ │ │ ├── 000001_init.down.sql │ │ │ │ │ ├── 000001_init.up.sql │ │ │ │ │ ├── 000002_system_settings_table.down.sql │ │ │ │ │ ├── 000002_system_settings_table.up.sql │ │ │ │ │ ├── 000003_blocks_rootid.down.sql │ │ │ │ │ ├── 000003_blocks_rootid.up.sql │ │ │ │ │ ├── 000004_auth_table.down.sql │ │ │ │ │ ├── 000004_auth_table.up.sql │ │ │ │ │ ├── 000005_blocks_modifiedby.down.sql │ │ │ │ │ ├── 000005_blocks_modifiedby.up.sql │ │ │ │ │ ├── 000006_sharing_table.down.sql │ │ │ │ │ ├── 000006_sharing_table.up.sql │ │ │ │ │ ├── 000007_workspaces_table.down.sql │ │ │ │ │ ├── 000007_workspaces_table.up.sql │ │ │ │ │ ├── 000008_teams.down.sql │ │ │ │ │ ├── 000008_teams.up.sql │ │ │ │ │ ├── 000009_blocks_history.down.sql │ │ │ │ │ ├── 000009_blocks_history.up.sql │ │ │ │ │ ├── 000010_blocks_created_by.down.sql │ │ │ │ │ ├── 000010_blocks_created_by.up.sql │ │ │ │ │ ├── 000011_match_collation.down.sql │ │ │ │ │ ├── 000011_match_collation.up.sql │ │ │ │ │ ├── 000012_match_column_collation.down.sql │ │ │ │ │ ├── 000012_match_column_collation.up.sql │ │ │ │ │ ├── 000013_millisecond_timestamps.down.sql │ │ │ │ │ ├── 000013_millisecond_timestamps.up.sql │ │ │ │ │ ├── 000014_add_not_null_constraint.down.sql │ │ │ │ │ ├── 000014_add_not_null_constraint.up.sql │ │ │ │ │ ├── 000015_blocks_history_no_nulls.down.sql │ │ │ │ │ ├── 000015_blocks_history_no_nulls.up.sql │ │ │ │ │ ├── 000016_subscriptions_table.down.sql │ │ │ │ │ ├── 000016_subscriptions_table.up.sql │ │ │ │ │ ├── 000017_add_file_info.down.sql │ │ │ │ │ ├── 000017_add_file_info.up.sql │ │ │ │ │ ├── 000018_add_teams_and_boards.down.sql │ │ │ │ │ ├── 000018_add_teams_and_boards.up.sql │ │ │ │ │ ├── 000019_populate_categories.down.sql │ │ │ │ │ ├── 000019_populate_categories.up.sql │ │ │ │ │ ├── 000020_populate_category_blocks.down.sql │ │ │ │ │ ├── 000020_populate_category_blocks.up.sql │ │ │ │ │ ├── 000021_create_boards_members_history.down.sql │ │ │ │ │ ├── 000021_create_boards_members_history.up.sql │ │ │ │ │ ├── 000022_create_default_board_role.down.sql │ │ │ │ │ ├── 000022_create_default_board_role.up.sql │ │ │ │ │ ├── 000023_persist_category_collapsed_state.down.sql │ │ │ │ │ ├── 000023_persist_category_collapsed_state.up.sql │ │ │ │ │ ├── 000024_mark_existsing_categories_collapsed.down.sql │ │ │ │ │ ├── 000024_mark_existsing_categories_collapsed.up.sql │ │ │ │ │ ├── 000025_indexes_update.down.sql │ │ │ │ │ ├── 000025_indexes_update.up.sql │ │ │ │ │ ├── 000026_create_preferences_table.down.sql │ │ │ │ │ ├── 000026_create_preferences_table.up.sql │ │ │ │ │ ├── 000027_migrate_user_props_to_preferences.down.sql │ │ │ │ │ ├── 000027_migrate_user_props_to_preferences.up.sql │ │ │ │ │ ├── 000028_remove_template_channel_link.down.sql │ │ │ │ │ ├── 000028_remove_template_channel_link.up.sql │ │ │ │ │ ├── 000029_add_category_type_field.down.sql │ │ │ │ │ ├── 000029_add_category_type_field.up.sql │ │ │ │ │ ├── 000030_add_category_sort_order.down.sql │ │ │ │ │ ├── 000030_add_category_sort_order.up.sql │ │ │ │ │ ├── 000031_add_category_boards_sort_order.down.sql │ │ │ │ │ ├── 000031_add_category_boards_sort_order.up.sql │ │ │ │ │ ├── 000032_move_boards_category_to_end.down.sql │ │ │ │ │ ├── 000032_move_boards_category_to_end.up.sql │ │ │ │ │ ├── 000033_remove_deleted_category_boards.down.sql │ │ │ │ │ ├── 000033_remove_deleted_category_boards.up.sql │ │ │ │ │ ├── 000034_category_boards_remove_unused_delete_at_column.down.sql │ │ │ │ │ ├── 000034_category_boards_remove_unused_delete_at_column.up.sql │ │ │ │ │ ├── 000035_add_hidden_board_column.down.sql │ │ │ │ │ ├── 000035_add_hidden_board_column.up.sql │ │ │ │ │ ├── 000036_category_board_add_unique_constraint.down.sql │ │ │ │ │ ├── 000036_category_board_add_unique_constraint.up.sql │ │ │ │ │ ├── 000037_hidden_boards_from_preferences.down.sql │ │ │ │ │ ├── 000037_hidden_boards_from_preferences.up.sql │ │ │ │ │ ├── 000038_delete_hiddenBoardIDs_from_preferences.down.sql │ │ │ │ │ ├── 000038_delete_hiddenBoardIDs_from_preferences.up.sql │ │ │ │ │ ├── 000039_add_path_to_file_info.down.sql │ │ │ │ │ ├── 000039_add_path_to_file_info.up.sql │ │ │ │ │ ├── 000040_fix_fileinfo_soft_deletes.down.sql │ │ │ │ │ ├── 000040_fix_fileinfo_soft_deletes.up.sql │ │ │ │ │ └── README.md │ │ │ │ ├── migrationstests/ │ │ │ │ │ ├── boards_migrator_test.go │ │ │ │ │ ├── de_duplicate_category_boards_migration_test.go │ │ │ │ │ ├── fixtures/ │ │ │ │ │ │ ├── deletedMembershipBoardsMigrationFixtures.sql │ │ │ │ │ │ ├── test18AddTeamsAndBoardsSQLMigrationFixtures.sql │ │ │ │ │ │ ├── test27MigrateUserPropsToPreferences.sql │ │ │ │ │ │ ├── test28RemoveTemplateChannelLink.sql │ │ │ │ │ │ ├── test33_with_deleted_data.sql │ │ │ │ │ │ ├── test33_with_no_deleted_data.sql │ │ │ │ │ │ ├── test34_drop_delete_at_column.sql │ │ │ │ │ │ ├── test35_add_hidden_column.sql │ │ │ │ │ │ ├── test36_add_unique_constraint.sql │ │ │ │ │ │ ├── test37_valid_data.sql │ │ │ │ │ │ ├── test37_valid_data_no_hidden_boards.sql │ │ │ │ │ │ ├── test37_valid_data_preference_but_no_hidden_board.sql │ │ │ │ │ │ ├── test37_valid_data_sqlite.sql │ │ │ │ │ │ ├── test37_valid_data_sqlite_preference_but_no_hidden_board.sql │ │ │ │ │ │ ├── test38_add_plugin_preferences.sql │ │ │ │ │ │ ├── test38_add_standalone_preferences.sql │ │ │ │ │ │ ├── test40FixFileinfoSoftDeletes.sql │ │ │ │ │ │ └── testDeDuplicateCategoryBoardsMigration.sql │ │ │ │ │ ├── helpers_test.go │ │ │ │ │ ├── migrate_34_test.go │ │ │ │ │ ├── migration35_test.go │ │ │ │ │ ├── migration36_test.go │ │ │ │ │ ├── migration37_test.go │ │ │ │ │ ├── migration38_test.go │ │ │ │ │ ├── migration_27_test.go │ │ │ │ │ ├── migration_28_test.go │ │ │ │ │ └── migration_33_test.go │ │ │ │ ├── notificationhints.go │ │ │ │ ├── params.go │ │ │ │ ├── public_methods.go │ │ │ │ ├── schema_table_migration.go │ │ │ │ ├── schema_table_migration_test.go │ │ │ │ ├── session.go │ │ │ │ ├── sharing.go │ │ │ │ ├── sqlite.go │ │ │ │ ├── sqlstore.go │ │ │ │ ├── sqlstore_test.go │ │ │ │ ├── subscriptions.go │ │ │ │ ├── system.go │ │ │ │ ├── team.go │ │ │ │ ├── templates.go │ │ │ │ ├── user.go │ │ │ │ └── util.go │ │ │ ├── store.go │ │ │ └── storetests/ │ │ │ ├── blocks.go │ │ │ ├── boards.go │ │ │ ├── boards_and_blocks.go │ │ │ ├── category.go │ │ │ ├── categoryBoards.go │ │ │ ├── cloud.go │ │ │ ├── compliance.go │ │ │ ├── data_retention.go │ │ │ ├── files.go │ │ │ ├── helpers.go │ │ │ ├── notificationhints.go │ │ │ ├── session.go │ │ │ ├── sharing.go │ │ │ ├── subscriptions.go │ │ │ ├── system.go │ │ │ ├── teams.go │ │ │ ├── users.go │ │ │ └── util.go │ │ ├── telemetry/ │ │ │ ├── mocks/ │ │ │ │ └── ServerIface.go │ │ │ ├── telemetry.go │ │ │ └── telemetry_test.go │ │ └── webhook/ │ │ ├── webhook.go │ │ └── webhook_test.go │ ├── swagger/ │ │ ├── README.md │ │ ├── docs/ │ │ │ └── html/ │ │ │ ├── .openapi-generator/ │ │ │ │ └── VERSION │ │ │ ├── .openapi-generator-ignore │ │ │ └── index.html │ │ └── swagger.yml │ ├── utils/ │ │ ├── callbackqueue.go │ │ ├── callbackqueue_test.go │ │ ├── debug.go │ │ ├── links.go │ │ ├── testUtils.go │ │ └── utils.go │ ├── web/ │ │ ├── webserver.go │ │ └── webserver_test.go │ └── ws/ │ ├── adapter.go │ ├── common.go │ ├── helpers_test.go │ ├── mocks/ │ │ ├── mockpluginapi.go │ │ └── mockstore.go │ ├── plugin_adapter.go │ ├── plugin_adapter_client.go │ ├── plugin_adapter_cluster.go │ ├── plugin_adapter_test.go │ ├── server.go │ └── server_test.go ├── server-config.json ├── webapp/ │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .nvmrc │ ├── .prettierignore │ ├── .prettierrc.json │ ├── .stylelintrc.json │ ├── NOTICE.txt │ ├── __mocks__/ │ │ ├── fileMock.js │ │ └── styleMock.js │ ├── cypress/ │ │ ├── config.json │ │ ├── global.d.ts │ │ ├── integration/ │ │ │ ├── cardBadges.ts │ │ │ ├── cardURLProperty.ts │ │ │ ├── createBoard.ts │ │ │ ├── groupByProperty.ts │ │ │ ├── loginActions.ts │ │ │ └── manageGroups.ts │ │ ├── plugins/ │ │ │ └── index.js │ │ ├── support/ │ │ │ ├── api_commands.ts │ │ │ ├── index.ts │ │ │ └── ui_commands.ts │ │ └── tsconfig.json │ ├── cypress.json │ ├── html-templates/ │ │ ├── deveditor.ejs │ │ └── page.ejs │ ├── i18n/ │ │ ├── ar.json │ │ ├── ars.json │ │ ├── ca.json │ │ ├── de.json │ │ ├── el.json │ │ ├── en.json │ │ ├── en_AU.json │ │ ├── es.json │ │ ├── et.json │ │ ├── fa.json │ │ ├── fr.json │ │ ├── he.json │ │ ├── hr.json │ │ ├── hu.json │ │ ├── id.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ka.json │ │ ├── kab.json │ │ ├── kk.json │ │ ├── ko.json │ │ ├── lt.json │ │ ├── ml.json │ │ ├── nb_NO.json │ │ ├── nl.json │ │ ├── oc.json │ │ ├── pl.json │ │ ├── pt.json │ │ ├── pt_BR.json │ │ ├── ru.json │ │ ├── sk.json │ │ ├── sl.json │ │ ├── sv.json │ │ ├── tr.json │ │ ├── uk.json │ │ ├── vi.json │ │ ├── zh_Hans.json │ │ └── zh_Hant.json │ ├── package.json │ ├── src/ │ │ ├── app.tsx │ │ ├── archiver.ts │ │ ├── blockIcons.ts │ │ ├── blocks/ │ │ │ ├── __snapshots__/ │ │ │ │ ├── block.test.ts.snap │ │ │ │ └── board.test.ts.snap │ │ │ ├── attachmentBlock.tsx │ │ │ ├── block.test.ts │ │ │ ├── block.ts │ │ │ ├── board.test.ts │ │ │ ├── board.ts │ │ │ ├── boardView.test.ts │ │ │ ├── boardView.ts │ │ │ ├── card.ts │ │ │ ├── checkboxBlock.ts │ │ │ ├── commentBlock.ts │ │ │ ├── contentBlock.ts │ │ │ ├── dividerBlock.ts │ │ │ ├── filterClause.test.ts │ │ │ ├── filterClause.ts │ │ │ ├── filterGroup.ts │ │ │ ├── h1Block.tsx │ │ │ ├── h2Block.tsx │ │ │ ├── h3Block.tsx │ │ │ ├── imageBlock.ts │ │ │ ├── sharing.ts │ │ │ ├── team.ts │ │ │ ├── textBlock.ts │ │ │ └── workspace.ts │ │ ├── boardCloudLimits/ │ │ │ └── index.ts │ │ ├── boardUtils.ts │ │ ├── boardsCloudLimits/ │ │ │ └── index.ts │ │ ├── cardFilter.test.ts │ │ ├── cardFilter.ts │ │ ├── components/ │ │ │ ├── __snapshots__/ │ │ │ │ ├── addContentMenuItem.test.tsx.snap │ │ │ │ ├── blockIconSelector.test.tsx.snap │ │ │ │ ├── cardBadges.test.tsx.snap │ │ │ │ ├── cardDialog.test.tsx.snap │ │ │ │ ├── centerPanel.test.tsx.snap │ │ │ │ ├── confirmAddUserForNotifications.test.tsx.snap │ │ │ │ ├── confirmationDialogBox.test.tsx.snap │ │ │ │ ├── contentBlock.test.tsx.snap │ │ │ │ ├── dialog.test.tsx.snap │ │ │ │ ├── flashMessages.test.tsx.snap │ │ │ │ ├── markdownEditor.test.tsx.snap │ │ │ │ ├── modal.test.tsx.snap │ │ │ │ ├── personSelector.test.tsx.snap │ │ │ │ ├── propertyValueElement.test.tsx.snap │ │ │ │ ├── rootPortal.test.tsx.snap │ │ │ │ ├── topBar.test.tsx.snap │ │ │ │ ├── viewMenu.test.tsx.snap │ │ │ │ ├── viewTitle.test.tsx.snap │ │ │ │ └── workspace.test.tsx.snap │ │ │ ├── addContentMenuItem.test.tsx │ │ │ ├── addContentMenuItem.tsx │ │ │ ├── blockIconSelector.test.tsx │ │ │ ├── blockIconSelector.tsx │ │ │ ├── blocksEditor/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── blockContent.test.tsx.snap │ │ │ │ │ ├── blocksEditor.test.tsx.snap │ │ │ │ │ ├── editor.test.tsx.snap │ │ │ │ │ └── rootInput.test.tsx.snap │ │ │ │ ├── blockContent.scss │ │ │ │ ├── blockContent.test.tsx │ │ │ │ ├── blockContent.tsx │ │ │ │ ├── blocks/ │ │ │ │ │ ├── attachment/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── attachment.test.tsx.snap │ │ │ │ │ │ ├── attachment.scss │ │ │ │ │ │ ├── attachment.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── checkbox/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── checkbox.test.tsx.snap │ │ │ │ │ │ ├── checkbox.scss │ │ │ │ │ │ ├── checkbox.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── divider/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── divider.test.tsx.snap │ │ │ │ │ │ ├── divider.scss │ │ │ │ │ │ ├── divider.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── h1/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── h1.test.tsx.snap │ │ │ │ │ │ ├── h1.scss │ │ │ │ │ │ ├── h1.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── h2/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── h2.test.tsx.snap │ │ │ │ │ │ ├── h2.scss │ │ │ │ │ │ ├── h2.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── h3/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── h3.test.tsx.snap │ │ │ │ │ │ ├── h3.scss │ │ │ │ │ │ ├── h3.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── image.test.tsx.snap │ │ │ │ │ │ ├── image.scss │ │ │ │ │ │ ├── image.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── list-item/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── list-item.test.tsx.snap │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── list-item.scss │ │ │ │ │ │ └── list-item.test.tsx │ │ │ │ │ ├── quote/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── quote.test.tsx.snap │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── quote.scss │ │ │ │ │ │ └── quote.test.tsx │ │ │ │ │ ├── text/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── text.test.tsx.snap │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── text.scss │ │ │ │ │ │ └── text.test.tsx │ │ │ │ │ ├── text-dev/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── text.scss │ │ │ │ │ ├── types.tsx │ │ │ │ │ └── video/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── video.test.tsx.snap │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── video.scss │ │ │ │ │ └── video.test.tsx │ │ │ │ ├── blocksEditor.test.tsx │ │ │ │ ├── blocksEditor.tsx │ │ │ │ ├── devmain.scss │ │ │ │ ├── devmain.tsx │ │ │ │ ├── editor.scss │ │ │ │ ├── editor.test.tsx │ │ │ │ ├── editor.tsx │ │ │ │ ├── rootInput.test.tsx │ │ │ │ └── rootInput.tsx │ │ │ ├── boardIconSelector.tsx │ │ │ ├── boardTemplateSelector/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── boardTemplateSelector.test.tsx.snap │ │ │ │ │ ├── boardTemplateSelectorItem.test.tsx.snap │ │ │ │ │ └── boardTemplateSelectorPreview.test.tsx.snap │ │ │ │ ├── boardTemplateSelector.scss │ │ │ │ ├── boardTemplateSelector.test.tsx │ │ │ │ ├── boardTemplateSelector.tsx │ │ │ │ ├── boardTemplateSelectorItem.scss │ │ │ │ ├── boardTemplateSelectorItem.test.tsx │ │ │ │ ├── boardTemplateSelectorItem.tsx │ │ │ │ ├── boardTemplateSelectorPreview.scss │ │ │ │ ├── boardTemplateSelectorPreview.test.tsx │ │ │ │ └── boardTemplateSelectorPreview.tsx │ │ │ ├── boardsSwitcher/ │ │ │ │ ├── boardsSwitcher.scss │ │ │ │ └── boardsSwitcher.tsx │ │ │ ├── boardsSwitcherDialog/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── boardSwitcherDialog.test.tsx.snap │ │ │ │ ├── boardSwitcherDialog.scss │ │ │ │ ├── boardSwitcherDialog.test.tsx │ │ │ │ └── boardSwitcherDialog.tsx │ │ │ ├── calculations/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── calculation.test.tsx.snap │ │ │ │ │ └── options.test.tsx.snap │ │ │ │ ├── calculation.scss │ │ │ │ ├── calculation.test.tsx │ │ │ │ ├── calculation.tsx │ │ │ │ ├── calculations.test.tsx │ │ │ │ ├── calculations.ts │ │ │ │ ├── options.test.tsx │ │ │ │ └── options.tsx │ │ │ ├── calendar/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── fullCalendar.test.tsx.snap │ │ │ │ ├── fullCalendar.test.tsx │ │ │ │ ├── fullCalendar.tsx │ │ │ │ └── fullcalendar.scss │ │ │ ├── cardActionsMenu/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── cardActionsMenu.test.tsx.snap │ │ │ │ ├── cardActionsMenu.test.tsx │ │ │ │ ├── cardActionsMenu.tsx │ │ │ │ ├── cardActionsMenuIcon.scss │ │ │ │ └── cardActionsMenuIcon.tsx │ │ │ ├── cardBadges.scss │ │ │ ├── cardBadges.test.tsx │ │ │ ├── cardBadges.tsx │ │ │ ├── cardDetail/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── cardDetail.test.tsx.snap │ │ │ │ │ ├── cardDetailContents.test.tsx.snap │ │ │ │ │ ├── cardDetailContentsMenu.test.tsx.snap │ │ │ │ │ ├── cardDetailProperties.test.tsx.snap │ │ │ │ │ ├── comment.test.tsx.snap │ │ │ │ │ └── commentsList.test.tsx.snap │ │ │ │ ├── attachment.scss │ │ │ │ ├── attachment.tsx │ │ │ │ ├── cardDetail.scss │ │ │ │ ├── cardDetail.test.tsx │ │ │ │ ├── cardDetail.tsx │ │ │ │ ├── cardDetailContents.test.tsx │ │ │ │ ├── cardDetailContents.tsx │ │ │ │ ├── cardDetailContentsMenu.test.tsx │ │ │ │ ├── cardDetailContentsMenu.tsx │ │ │ │ ├── cardDetailContentsUtility.test.ts │ │ │ │ ├── cardDetailContentsUtility.ts │ │ │ │ ├── cardDetailContext.tsx │ │ │ │ ├── cardDetailProperties.test.tsx │ │ │ │ ├── cardDetailProperties.tsx │ │ │ │ ├── comment.scss │ │ │ │ ├── comment.test.tsx │ │ │ │ ├── comment.tsx │ │ │ │ ├── commentsList.scss │ │ │ │ ├── commentsList.test.tsx │ │ │ │ ├── commentsList.tsx │ │ │ │ └── imagePaste.tsx │ │ │ ├── cardDialog.scss │ │ │ ├── cardDialog.test.tsx │ │ │ ├── cardDialog.tsx │ │ │ ├── cardLimitNotification.scss │ │ │ ├── cardLimitNotification.tsx │ │ │ ├── centerPanel.scss │ │ │ ├── centerPanel.test.tsx │ │ │ ├── centerPanel.tsx │ │ │ ├── confirmAddUserForNotifications.scss │ │ │ ├── confirmAddUserForNotifications.test.tsx │ │ │ ├── confirmAddUserForNotifications.tsx │ │ │ ├── confirmationDialogBox.scss │ │ │ ├── confirmationDialogBox.test.tsx │ │ │ ├── confirmationDialogBox.tsx │ │ │ ├── content/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── attachmentElement.test.tsx.snap │ │ │ │ │ ├── checkboxElement.test.tsx.snap │ │ │ │ │ ├── contentElement.test.tsx.snap │ │ │ │ │ ├── dividerElement.test.tsx.snap │ │ │ │ │ ├── imageElement.test.tsx.snap │ │ │ │ │ └── textElement.test.tsx.snap │ │ │ │ ├── archivedFile/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── archivedFile.test.tsx.snap │ │ │ │ │ ├── archivedFile.scss │ │ │ │ │ ├── archivedFile.test.tsx │ │ │ │ │ └── archivedFile.tsx │ │ │ │ ├── attachmentElement.scss │ │ │ │ ├── attachmentElement.test.tsx │ │ │ │ ├── attachmentElement.tsx │ │ │ │ ├── checkboxElement.scss │ │ │ │ ├── checkboxElement.test.tsx │ │ │ │ ├── checkboxElement.tsx │ │ │ │ ├── contentElement.test.tsx │ │ │ │ ├── contentElement.tsx │ │ │ │ ├── contentRegistry.test.tsx │ │ │ │ ├── contentRegistry.tsx │ │ │ │ ├── dividerElement.scss │ │ │ │ ├── dividerElement.test.tsx │ │ │ │ ├── dividerElement.tsx │ │ │ │ ├── imageElement.test.tsx │ │ │ │ ├── imageElement.tsx │ │ │ │ ├── textElement.scss │ │ │ │ ├── textElement.test.tsx │ │ │ │ └── textElement.tsx │ │ │ ├── contentBlock.scss │ │ │ ├── contentBlock.test.tsx │ │ │ ├── contentBlock.tsx │ │ │ ├── createCategory/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── createCategory.test.tsx.snap │ │ │ │ ├── createCategory.scss │ │ │ │ ├── createCategory.test.tsx │ │ │ │ └── createCategory.tsx │ │ │ ├── dialog.scss │ │ │ ├── dialog.test.tsx │ │ │ ├── dialog.tsx │ │ │ ├── flashMessages.scss │ │ │ ├── flashMessages.test.tsx │ │ │ ├── flashMessages.tsx │ │ │ ├── gallery/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── gallery.test.tsx.snap │ │ │ │ │ └── galleryCard.test.tsx.snap │ │ │ │ ├── gallery.scss │ │ │ │ ├── gallery.test.tsx │ │ │ │ ├── gallery.tsx │ │ │ │ ├── galleryCard.scss │ │ │ │ ├── galleryCard.test.tsx │ │ │ │ └── galleryCard.tsx │ │ │ ├── globalHeader/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── globalHeader.test.tsx.snap │ │ │ │ │ └── globalHeaderSettingsMenu.test.tsx.snap │ │ │ │ ├── globalHeader.scss │ │ │ │ ├── globalHeader.test.tsx │ │ │ │ ├── globalHeader.tsx │ │ │ │ ├── globalHeaderSettingsMenu.scss │ │ │ │ ├── globalHeaderSettingsMenu.test.tsx │ │ │ │ └── globalHeaderSettingsMenu.tsx │ │ │ ├── guestNoBoards.scss │ │ │ ├── guestNoBoards.tsx │ │ │ ├── hiddenCardCount/ │ │ │ │ ├── hiddenCardCount.scss │ │ │ │ └── hiddenCardCount.tsx │ │ │ ├── iconSelector.scss │ │ │ ├── iconSelector.tsx │ │ │ ├── kanban/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── kanban.test.tsx.snap │ │ │ │ │ ├── kanbanCard.test.tsx.snap │ │ │ │ │ ├── kanbanColumn.test.tsx.snap │ │ │ │ │ ├── kanbanColumnHeader.test.tsx.snap │ │ │ │ │ └── kanbanHiddenColumnItem.test.tsx.snap │ │ │ │ ├── calculation/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ ├── calculation.test.tsx.snap │ │ │ │ │ │ ├── calculationOptions.test.tsx.snap │ │ │ │ │ │ └── kanbanOption.test.tsx.snap │ │ │ │ │ ├── calculation.scss │ │ │ │ │ ├── calculation.test.tsx │ │ │ │ │ ├── calculation.tsx │ │ │ │ │ ├── calculationOption.scss │ │ │ │ │ ├── calculationOptions.test.tsx │ │ │ │ │ ├── calculationOptions.tsx │ │ │ │ │ ├── kanbanOption.test.tsx │ │ │ │ │ └── kanbanOption.tsx │ │ │ │ ├── kanban.scss │ │ │ │ ├── kanban.test.tsx │ │ │ │ ├── kanban.tsx │ │ │ │ ├── kanbanCard.scss │ │ │ │ ├── kanbanCard.test.tsx │ │ │ │ ├── kanbanCard.tsx │ │ │ │ ├── kanbanColumn.scss │ │ │ │ ├── kanbanColumn.test.tsx │ │ │ │ ├── kanbanColumn.tsx │ │ │ │ ├── kanbanColumnHeader.test.tsx │ │ │ │ ├── kanbanColumnHeader.tsx │ │ │ │ ├── kanbanHiddenColumnItem.test.tsx │ │ │ │ └── kanbanHiddenColumnItem.tsx │ │ │ ├── live-markdown-plugin/ │ │ │ │ ├── block-types/ │ │ │ │ │ ├── codeBlockStrategy.ts │ │ │ │ │ └── headingBlockStrategy.ts │ │ │ │ ├── inline-styles/ │ │ │ │ │ ├── boldStyleStrategy.ts │ │ │ │ │ ├── headingDelimiterStyleStrategy.ts │ │ │ │ │ ├── inlineCodeStyleStrategy.ts │ │ │ │ │ ├── italicStyleStrategy.ts │ │ │ │ │ ├── olDelimiterStyleStrategy.ts │ │ │ │ │ ├── quoteStyleStrategy.ts │ │ │ │ │ ├── strikethroughStyleStrategy.ts │ │ │ │ │ └── ulDelimiterStyleStrategy.ts │ │ │ │ ├── liveMarkdownPlugin.ts │ │ │ │ ├── pluginStrategy.ts │ │ │ │ └── utils/ │ │ │ │ └── findRangesWithRegex.ts │ │ │ ├── markdownEditor.scss │ │ │ ├── markdownEditor.test.tsx │ │ │ ├── markdownEditor.tsx │ │ │ ├── markdownEditorInput/ │ │ │ │ ├── entryComponent/ │ │ │ │ │ ├── entryComponent.scss │ │ │ │ │ └── entryComponent.tsx │ │ │ │ ├── markdownEditorInput.scss │ │ │ │ └── markdownEditorInput.tsx │ │ │ ├── messages/ │ │ │ │ ├── versionMessage.scss │ │ │ │ ├── versionMessage.test.tsx │ │ │ │ └── versionMessage.tsx │ │ │ ├── modal.scss │ │ │ ├── modal.test.tsx │ │ │ ├── modal.tsx │ │ │ ├── modalWrapper.scss │ │ │ ├── modalWrapper.tsx │ │ │ ├── newVersionBanner.scss │ │ │ ├── newVersionBanner.tsx │ │ │ ├── onboardingTour/ │ │ │ │ ├── addComments/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── addComments.test.tsx.snap │ │ │ │ │ ├── addComments.test.tsx │ │ │ │ │ ├── addComments.tsx │ │ │ │ │ └── add_comments.scss │ │ │ │ ├── addDescription/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── addDescription.test.tsx.snap │ │ │ │ │ ├── addDescription.test.tsx │ │ │ │ │ ├── add_description.scss │ │ │ │ │ └── add_description.tsx │ │ │ │ ├── addProperties/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── addProperties.test.tsx.snap │ │ │ │ │ ├── addProperties.test.tsx │ │ │ │ │ ├── add_properties.scss │ │ │ │ │ └── add_properties.tsx │ │ │ │ ├── addView/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── addView.test.tsx.snap │ │ │ │ │ ├── addView.test.tsx │ │ │ │ │ ├── add_view.scss │ │ │ │ │ └── add_view.tsx │ │ │ │ ├── copyLink/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── copyLink.test.tsx.snap │ │ │ │ │ ├── copyLink.test.tsx │ │ │ │ │ ├── copy_link.scss │ │ │ │ │ └── copy_link.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── manageCategories/ │ │ │ │ │ ├── manageCategories.scss │ │ │ │ │ └── manageCategories.tsx │ │ │ │ ├── openCard/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── openCard.test.tsx.snap │ │ │ │ │ ├── openCard.test.tsx │ │ │ │ │ ├── open_card.scss │ │ │ │ │ └── open_card.tsx │ │ │ │ ├── searchForBoards/ │ │ │ │ │ ├── searchForBoards.scss │ │ │ │ │ └── searchForBoards.tsx │ │ │ │ ├── shareBoard/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── shareBoard.test.tsx.snap │ │ │ │ │ ├── shareBoard.scss │ │ │ │ │ ├── shareBoard.test.tsx │ │ │ │ │ └── shareBoard.tsx │ │ │ │ ├── sidebarCategories/ │ │ │ │ │ ├── sidebarCategories.scss │ │ │ │ │ └── sidebarCategories.tsx │ │ │ │ └── tourTipRenderer/ │ │ │ │ └── tourTipRenderer.tsx │ │ │ ├── permissions/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── boardPermissionGate.test.tsx.snap │ │ │ │ ├── boardPermissionGate.test.tsx │ │ │ │ └── boardPermissionGate.tsx │ │ │ ├── personSelector.scss │ │ │ ├── personSelector.test.tsx │ │ │ ├── personSelector.tsx │ │ │ ├── propertyValueElement.test.tsx │ │ │ ├── propertyValueElement.tsx │ │ │ ├── pulsating_dot/ │ │ │ │ ├── index.tsx │ │ │ │ └── pulsating_dot.scss │ │ │ ├── rootPortal.test.tsx │ │ │ ├── rootPortal.tsx │ │ │ ├── searchDialog/ │ │ │ │ ├── searchDialog.scss │ │ │ │ └── searchDialog.tsx │ │ │ ├── shareBoard/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── shareBoard.test.tsx.snap │ │ │ │ │ ├── shareBoardButton.test.tsx.snap │ │ │ │ │ ├── shareBoardLoginButton.test.tsx.snap │ │ │ │ │ ├── teamPermissionsRow.test.tsx.snap │ │ │ │ │ └── userPermissionsRow.test.tsx.snap │ │ │ │ ├── shareBoard.scss │ │ │ │ ├── shareBoard.test.tsx │ │ │ │ ├── shareBoard.tsx │ │ │ │ ├── shareBoardButton.scss │ │ │ │ ├── shareBoardButton.test.tsx │ │ │ │ ├── shareBoardButton.tsx │ │ │ │ ├── shareBoardLoginButton.scss │ │ │ │ ├── shareBoardLoginButton.test.tsx │ │ │ │ ├── shareBoardLoginButton.tsx │ │ │ │ ├── teamPermissionsRow.test.tsx │ │ │ │ ├── teamPermissionsRow.tsx │ │ │ │ ├── userPermissionsRow.test.tsx │ │ │ │ └── userPermissionsRow.tsx │ │ │ ├── sidebar/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── deleteBoardDialog.test.tsx.snap │ │ │ │ │ ├── registrationLink.test.tsx.snap │ │ │ │ │ ├── sidebar.test.tsx.snap │ │ │ │ │ ├── sidebarBoardItem.test.tsx.snap │ │ │ │ │ ├── sidebarCategory.test.tsx.snap │ │ │ │ │ └── sidebarSettingsMenu.test.tsx.snap │ │ │ │ ├── deleteBoardDialog.scss │ │ │ │ ├── deleteBoardDialog.test.tsx │ │ │ │ ├── deleteBoardDialog.tsx │ │ │ │ ├── registrationLink.scss │ │ │ │ ├── registrationLink.test.tsx │ │ │ │ ├── registrationLink.tsx │ │ │ │ ├── sidebar.scss │ │ │ │ ├── sidebar.test.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── sidebarBoardItem.scss │ │ │ │ ├── sidebarBoardItem.test.tsx │ │ │ │ ├── sidebarBoardItem.tsx │ │ │ │ ├── sidebarCategory.scss │ │ │ │ ├── sidebarCategory.test.tsx │ │ │ │ ├── sidebarCategory.tsx │ │ │ │ ├── sidebarSettingsMenu.scss │ │ │ │ ├── sidebarSettingsMenu.test.tsx │ │ │ │ ├── sidebarSettingsMenu.tsx │ │ │ │ ├── sidebarUserMenu.scss │ │ │ │ └── sidebarUserMenu.tsx │ │ │ ├── table/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── table.test.tsx.snap │ │ │ │ │ ├── tableGroupHeaderRow.test.tsx.snap │ │ │ │ │ ├── tableHeader.test.tsx.snap │ │ │ │ │ ├── tableHeaderMenu.test.tsx.snap │ │ │ │ │ ├── tableHeaders.test.tsx.snap │ │ │ │ │ ├── tableRow.test.tsx.snap │ │ │ │ │ └── tableRows.test.tsx.snap │ │ │ │ ├── calculation/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── calculationRow.test.tsx.snap │ │ │ │ │ ├── calculationRow.scss │ │ │ │ │ ├── calculationRow.test.tsx │ │ │ │ │ ├── calculationRow.tsx │ │ │ │ │ └── tableCalculationOptions.tsx │ │ │ │ ├── horizontalGrip.scss │ │ │ │ ├── horizontalGrip.tsx │ │ │ │ ├── table.scss │ │ │ │ ├── table.test.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tableColumnResizeContext.tsx │ │ │ │ ├── tableGroup.tsx │ │ │ │ ├── tableGroupHeaderRow.test.tsx │ │ │ │ ├── tableGroupHeaderRow.tsx │ │ │ │ ├── tableHeader.test.tsx │ │ │ │ ├── tableHeader.tsx │ │ │ │ ├── tableHeaderMenu.test.tsx │ │ │ │ ├── tableHeaderMenu.tsx │ │ │ │ ├── tableHeaders.test.tsx │ │ │ │ ├── tableHeaders.tsx │ │ │ │ ├── tableRow.scss │ │ │ │ ├── tableRow.test.tsx │ │ │ │ ├── tableRow.tsx │ │ │ │ ├── tableRows.test.tsx │ │ │ │ └── tableRows.tsx │ │ │ ├── topBar.scss │ │ │ ├── topBar.test.tsx │ │ │ ├── topBar.tsx │ │ │ ├── tutorial_tour_tip/ │ │ │ │ ├── hooks.ts │ │ │ │ ├── tutorial_tour_tip.scss │ │ │ │ ├── tutorial_tour_tip.tsx │ │ │ │ ├── tutorial_tour_tip_backdrop.tsx │ │ │ │ ├── tutorial_tour_tip_manager.tsx │ │ │ │ └── useElementAvailable.ts │ │ │ ├── viewHeader/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── dateFilter.test.tsx.snap │ │ │ │ │ ├── emptyCardButton.test.tsx.snap │ │ │ │ │ ├── filterComponent.test.tsx.snap │ │ │ │ │ ├── filterEntry.test.tsx.snap │ │ │ │ │ ├── filterValue.test.tsx.snap │ │ │ │ │ ├── newCardButton.test.tsx.snap │ │ │ │ │ ├── newCardButtonTemplateItem.test.tsx.snap │ │ │ │ │ ├── viewHeader.test.tsx.snap │ │ │ │ │ ├── viewHeaderActionsMenu.test.tsx.snap │ │ │ │ │ ├── viewHeaderGroupByMenu.test.tsx.snap │ │ │ │ │ ├── viewHeaderPropertiesMenu.test.tsx.snap │ │ │ │ │ ├── viewHeaderSearch.test.tsx.snap │ │ │ │ │ └── viewHeaderSortMenu.test.tsx.snap │ │ │ │ ├── dateFilter.scss │ │ │ │ ├── dateFilter.test.tsx │ │ │ │ ├── dateFilter.tsx │ │ │ │ ├── emptyCardButton.test.tsx │ │ │ │ ├── emptyCardButton.tsx │ │ │ │ ├── filterComponent.scss │ │ │ │ ├── filterComponent.test.tsx │ │ │ │ ├── filterComponent.tsx │ │ │ │ ├── filterEntry.scss │ │ │ │ ├── filterEntry.test.tsx │ │ │ │ ├── filterEntry.tsx │ │ │ │ ├── filterValue.scss │ │ │ │ ├── filterValue.test.tsx │ │ │ │ ├── filterValue.tsx │ │ │ │ ├── multiperson.scss │ │ │ │ ├── multipersonFilterValue.tsx │ │ │ │ ├── newCardButton.test.tsx │ │ │ │ ├── newCardButton.tsx │ │ │ │ ├── newCardButtonTemplateItem.test.tsx │ │ │ │ ├── newCardButtonTemplateItem.tsx │ │ │ │ ├── viewHeader.scss │ │ │ │ ├── viewHeader.test.tsx │ │ │ │ ├── viewHeader.tsx │ │ │ │ ├── viewHeaderActionsMenu.test.tsx │ │ │ │ ├── viewHeaderActionsMenu.tsx │ │ │ │ ├── viewHeaderDisplayByMenu.tsx │ │ │ │ ├── viewHeaderGroupByMenu.test.tsx │ │ │ │ ├── viewHeaderGroupByMenu.tsx │ │ │ │ ├── viewHeaderPropertiesMenu.test.tsx │ │ │ │ ├── viewHeaderPropertiesMenu.tsx │ │ │ │ ├── viewHeaderSearch.test.tsx │ │ │ │ ├── viewHeaderSearch.tsx │ │ │ │ ├── viewHeaderSortMenu.test.tsx │ │ │ │ └── viewHeaderSortMenu.tsx │ │ │ ├── viewMenu.scss │ │ │ ├── viewMenu.test.tsx │ │ │ ├── viewMenu.tsx │ │ │ ├── viewTitle.scss │ │ │ ├── viewTitle.test.tsx │ │ │ ├── viewTitle.tsx │ │ │ ├── withWebSockets.tsx │ │ │ ├── workspace.scss │ │ │ ├── workspace.test.tsx │ │ │ └── workspace.tsx │ │ ├── config/ │ │ │ └── clientConfig.ts │ │ ├── constants.ts │ │ ├── csvExporter.ts │ │ ├── emojiList.ts │ │ ├── errors.ts │ │ ├── file.ts │ │ ├── fileIcons.ts │ │ ├── hooks/ │ │ │ ├── permissions.tsx │ │ │ ├── sortable.tsx │ │ │ ├── useGetAllTemplates.ts │ │ │ └── websockets.tsx │ │ ├── i18n.tsx │ │ ├── insights/ │ │ │ └── index.ts │ │ ├── main.tsx │ │ ├── mutator.test.ts │ │ ├── mutator.ts │ │ ├── nativeApp.ts │ │ ├── octoClient.test.ts │ │ ├── octoClient.ts │ │ ├── octoUtils.test.ts │ │ ├── octoUtils.tsx │ │ ├── onboardingTour/ │ │ │ └── index.ts │ │ ├── pages/ │ │ │ ├── boardPage/ │ │ │ │ ├── backwardCompatibilityQueryParamsRedirect.tsx │ │ │ │ ├── boardPage.scss │ │ │ │ ├── boardPage.tsx │ │ │ │ ├── setWindowTitleAndIcon.tsx │ │ │ │ ├── teamToBoardAndViewRedirect.tsx │ │ │ │ ├── undoRedoHotKeys.tsx │ │ │ │ └── websocketConnection.tsx │ │ │ ├── changePasswordPage.scss │ │ │ ├── changePasswordPage.tsx │ │ │ ├── errorPage.scss │ │ │ ├── errorPage.tsx │ │ │ ├── loginPage.scss │ │ │ ├── loginPage.tsx │ │ │ ├── registerPage.scss │ │ │ ├── registerPage.tsx │ │ │ └── welcome/ │ │ │ ├── __snapshots__/ │ │ │ │ └── welcomePage.test.tsx.snap │ │ │ ├── welcomePage.scss │ │ │ ├── welcomePage.test.tsx │ │ │ └── welcomePage.tsx │ │ ├── properties/ │ │ │ ├── baseTextEditor.tsx │ │ │ ├── checkbox/ │ │ │ │ ├── checkbox.tsx │ │ │ │ └── property.tsx │ │ │ ├── createdBy/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── createdBy.test.tsx.snap │ │ │ │ ├── createdBy.test.tsx │ │ │ │ ├── createdBy.tsx │ │ │ │ └── property.tsx │ │ │ ├── createdTime/ │ │ │ │ ├── createdTime.scss │ │ │ │ ├── createdTime.tsx │ │ │ │ └── property.tsx │ │ │ ├── date/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── date.test.tsx.snap │ │ │ │ ├── date.scss │ │ │ │ ├── date.test.tsx │ │ │ │ ├── date.tsx │ │ │ │ └── property.tsx │ │ │ ├── email/ │ │ │ │ ├── email.tsx │ │ │ │ └── property.tsx │ │ │ ├── index.tsx │ │ │ ├── multiperson/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── multiperson.test.tsx.snap │ │ │ │ ├── multiperson.test.tsx │ │ │ │ ├── multiperson.tsx │ │ │ │ └── property.tsx │ │ │ ├── multiselect/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── multiselect.test.tsx.snap │ │ │ │ ├── multiselect.test.tsx │ │ │ │ ├── multiselect.tsx │ │ │ │ └── property.tsx │ │ │ ├── number/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── number.test.tsx.snap │ │ │ │ ├── number.test.tsx │ │ │ │ ├── number.tsx │ │ │ │ └── property.tsx │ │ │ ├── person/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── confirmPerson.test.tsx.snap │ │ │ │ │ └── person.test.tsx.snap │ │ │ │ ├── confirmPerson.test.tsx │ │ │ │ ├── confirmPerson.tsx │ │ │ │ ├── person.test.tsx │ │ │ │ ├── person.tsx │ │ │ │ └── property.tsx │ │ │ ├── phone/ │ │ │ │ ├── phone.tsx │ │ │ │ └── property.tsx │ │ │ ├── select/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── select.test.tsx.snap │ │ │ │ ├── property.tsx │ │ │ │ ├── select.test.tsx │ │ │ │ └── select.tsx │ │ │ ├── text/ │ │ │ │ ├── property.tsx │ │ │ │ └── text.tsx │ │ │ ├── types.tsx │ │ │ ├── unknown/ │ │ │ │ └── property.tsx │ │ │ ├── updatedBy/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── updatedBy.test.tsx.snap │ │ │ │ ├── property.tsx │ │ │ │ ├── updatedBy.scss │ │ │ │ ├── updatedBy.test.tsx │ │ │ │ └── updatedBy.tsx │ │ │ ├── updatedTime/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── updatedTime.test.tsx.snap │ │ │ │ ├── property.tsx │ │ │ │ ├── updatedTime.scss │ │ │ │ ├── updatedTime.test.tsx │ │ │ │ └── updatedTime.tsx │ │ │ └── url/ │ │ │ ├── __snapshots__/ │ │ │ │ └── url.test.tsx.snap │ │ │ ├── property.tsx │ │ │ ├── url.scss │ │ │ ├── url.test.tsx │ │ │ └── url.tsx │ │ ├── route.tsx │ │ ├── router.tsx │ │ ├── statistics/ │ │ │ └── index.ts │ │ ├── store/ │ │ │ ├── attachments.ts │ │ │ ├── boards.ts │ │ │ ├── cards.ts │ │ │ ├── channels.ts │ │ │ ├── clientConfig.ts │ │ │ ├── comments.ts │ │ │ ├── contents.ts │ │ │ ├── globalError.ts │ │ │ ├── globalTemplates.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── initialLoad.ts │ │ │ ├── language.ts │ │ │ ├── limits.ts │ │ │ ├── searchText.ts │ │ │ ├── sidebar.ts │ │ │ ├── teams.ts │ │ │ ├── users.ts │ │ │ └── views.ts │ │ ├── styles/ │ │ │ ├── _markdown.scss │ │ │ ├── _modifiers.scss │ │ │ ├── _typography.scss │ │ │ ├── _z-index.scss │ │ │ ├── focalboard-variables.scss │ │ │ ├── labels.scss │ │ │ ├── main.scss │ │ │ ├── shared-variables.scss │ │ │ └── variables.scss │ │ ├── svg/ │ │ │ ├── card-skeleton.tsx │ │ │ ├── error-illustration.tsx │ │ │ └── search-illustration.tsx │ │ ├── telemetry/ │ │ │ ├── telemetry.ts │ │ │ ├── telemetryClient.test.ts │ │ │ └── telemetryClient.ts │ │ ├── test/ │ │ │ ├── fetchMock.ts │ │ │ └── testBlockFactory.ts │ │ ├── testUtils.tsx │ │ ├── theme.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── images.d.ts │ │ │ └── index.d.ts │ │ ├── undoManager.test.ts │ │ ├── undomanager.ts │ │ ├── user.tsx │ │ ├── userSettings.ts │ │ ├── utils.test.ts │ │ ├── utils.ts │ │ ├── widgets/ │ │ │ ├── __snapshots__/ │ │ │ │ ├── guestBadge.test.tsx.snap │ │ │ │ └── propertyMenu.test.tsx.snap │ │ │ ├── adminBadge/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── adminBadge.test.tsx.snap │ │ │ │ ├── adminBadge.scss │ │ │ │ ├── adminBadge.test.tsx │ │ │ │ └── adminBadge.tsx │ │ │ ├── buttons/ │ │ │ │ ├── button.scss │ │ │ │ ├── button.tsx │ │ │ │ ├── buttonWithMenu.scss │ │ │ │ ├── buttonWithMenu.tsx │ │ │ │ ├── iconButton.scss │ │ │ │ └── iconButton.tsx │ │ │ ├── editable.scss │ │ │ ├── editable.tsx │ │ │ ├── editableArea.scss │ │ │ ├── editableArea.tsx │ │ │ ├── editableDayPicker.scss │ │ │ ├── editableDayPicker.tsx │ │ │ ├── emojiPicker.scss │ │ │ ├── emojiPicker.tsx │ │ │ ├── guestBadge.scss │ │ │ ├── guestBadge.test.tsx │ │ │ ├── guestBadge.tsx │ │ │ ├── icons/ │ │ │ │ ├── HandRight.tsx │ │ │ │ ├── Link.tsx │ │ │ │ ├── add.scss │ │ │ │ ├── add.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── apps.tsx │ │ │ │ ├── board.scss │ │ │ │ ├── board.tsx │ │ │ │ ├── brokenFile.tsx │ │ │ │ ├── calendar.scss │ │ │ │ ├── calendar.tsx │ │ │ │ ├── card.scss │ │ │ │ ├── card.tsx │ │ │ │ ├── check.scss │ │ │ │ ├── check.tsx │ │ │ │ ├── checkIcon.tsx │ │ │ │ ├── chevronDown.tsx │ │ │ │ ├── chevronRight.tsx │ │ │ │ ├── chevronUp.tsx │ │ │ │ ├── close.tsx │ │ │ │ ├── closeCircle.tsx │ │ │ │ ├── compassIcon.tsx │ │ │ │ ├── delete.scss │ │ │ │ ├── delete.tsx │ │ │ │ ├── disclosureTriangle.scss │ │ │ │ ├── disclosureTriangle.tsx │ │ │ │ ├── divider.scss │ │ │ │ ├── divider.tsx │ │ │ │ ├── dot.scss │ │ │ │ ├── dot.tsx │ │ │ │ ├── dropdown.scss │ │ │ │ ├── dropdown.tsx │ │ │ │ ├── duplicate.scss │ │ │ │ ├── duplicate.tsx │ │ │ │ ├── edit.tsx │ │ │ │ ├── emoji.scss │ │ │ │ ├── emoji.tsx │ │ │ │ ├── focalboard_logo.scss │ │ │ │ ├── focalboard_logo.tsx │ │ │ │ ├── folder.tsx │ │ │ │ ├── gallery.scss │ │ │ │ ├── gallery.tsx │ │ │ │ ├── globe.tsx │ │ │ │ ├── grip.scss │ │ │ │ ├── grip.tsx │ │ │ │ ├── hamburger.scss │ │ │ │ ├── hamburger.tsx │ │ │ │ ├── help.scss │ │ │ │ ├── help.tsx │ │ │ │ ├── hide.scss │ │ │ │ ├── hide.tsx │ │ │ │ ├── hideSidebar.scss │ │ │ │ ├── hideSidebar.tsx │ │ │ │ ├── image.scss │ │ │ │ ├── image.tsx │ │ │ │ ├── link.scss │ │ │ │ ├── lockOutline.tsx │ │ │ │ ├── logo.scss │ │ │ │ ├── logo.tsx │ │ │ │ ├── logoWithName.scss │ │ │ │ ├── logoWithName.tsx │ │ │ │ ├── logoWithNameWhite.scss │ │ │ │ ├── logoWithNameWhite.tsx │ │ │ │ ├── message.tsx │ │ │ │ ├── newFolder.tsx │ │ │ │ ├── options.scss │ │ │ │ ├── options.tsx │ │ │ │ ├── random.tsx │ │ │ │ ├── search.tsx │ │ │ │ ├── settings.scss │ │ │ │ ├── settings.tsx │ │ │ │ ├── show.scss │ │ │ │ ├── show.tsx │ │ │ │ ├── showSidebar.scss │ │ │ │ ├── showSidebar.tsx │ │ │ │ ├── sortDown.scss │ │ │ │ ├── sortDown.tsx │ │ │ │ ├── sortUp.scss │ │ │ │ ├── sortUp.tsx │ │ │ │ ├── submenuTriangle.scss │ │ │ │ ├── submenuTriangle.tsx │ │ │ │ ├── table.scss │ │ │ │ ├── table.tsx │ │ │ │ ├── text.scss │ │ │ │ ├── text.tsx │ │ │ │ └── update.tsx │ │ │ ├── label.scss │ │ │ ├── label.tsx │ │ │ ├── menu/ │ │ │ │ ├── colorOption.scss │ │ │ │ ├── colorOption.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── labelOption.scss │ │ │ │ ├── labelOption.tsx │ │ │ │ ├── menu.scss │ │ │ │ ├── menu.tsx │ │ │ │ ├── menuItem.tsx │ │ │ │ ├── menuUtil.ts │ │ │ │ ├── separatorOption.scss │ │ │ │ ├── separatorOption.tsx │ │ │ │ ├── subMenuOption.scss │ │ │ │ ├── subMenuOption.tsx │ │ │ │ ├── switchOption.tsx │ │ │ │ ├── textInputOption.tsx │ │ │ │ └── textOption.tsx │ │ │ ├── menuWrapper.scss │ │ │ ├── menuWrapper.tsx │ │ │ ├── notificationBox/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── notificationBox.test.tsx.snap │ │ │ │ ├── notificationBox.scss │ │ │ │ ├── notificationBox.test.tsx │ │ │ │ └── notificationBox.tsx │ │ │ ├── propertyMenu.scss │ │ │ ├── propertyMenu.test.tsx │ │ │ ├── propertyMenu.tsx │ │ │ ├── switch.scss │ │ │ ├── switch.tsx │ │ │ ├── tooltip.scss │ │ │ ├── tooltip.tsx │ │ │ ├── valueSelector.scss │ │ │ └── valueSelector.tsx │ │ └── wsclient.ts │ ├── tsconfig.json │ ├── tslint.json │ ├── webpack.common.js │ ├── webpack.dev.js │ ├── webpack.editor.js │ └── webpack.prod.js ├── website/ │ ├── .editorconfig │ ├── .gitignore │ ├── Makefile │ ├── README.md │ └── site/ │ ├── archetypes/ │ │ └── default.md │ ├── config.toml │ ├── content/ │ │ ├── blog/ │ │ │ ├── 2021-1-7-hello.md │ │ │ ├── 2021-4-21-Focalboard v0.6.5 release.md │ │ │ ├── 2021-4-27-Mattermost-Focalboard-early-preview.md │ │ │ ├── 2021-5-07-meeting-agenda-template.md │ │ │ ├── 2021-5-13-Focalboard-the-road-to-v1.md │ │ │ └── 2021-6-18-Mattermost-Focalboard-release.md │ │ ├── docs/ │ │ │ └── personal-edition/ │ │ │ ├── _index.md │ │ │ ├── desktop.md │ │ │ ├── docker.md │ │ │ ├── ubuntu-upgrade.md │ │ │ └── ubuntu.md │ │ ├── download/ │ │ │ └── index.html │ │ ├── feedback/ │ │ │ └── _index.md │ │ ├── fwlink/ │ │ │ ├── doc-boards.html │ │ │ ├── feedback-boards.html │ │ │ ├── feedback-focalboard.html │ │ │ ├── plugin-setup.html │ │ │ ├── setup-536.html │ │ │ ├── v1-focalboard.html │ │ │ └── websocket-connect-error.html │ │ └── guide/ │ │ ├── admin/ │ │ │ └── _index.md │ │ ├── server-setup/ │ │ │ └── _index.md │ │ ├── user/ │ │ │ └── _index.md │ │ └── websocket-errors/ │ │ └── _index.md │ ├── layouts/ │ │ ├── 404.html │ │ ├── _default/ │ │ │ ├── _markup/ │ │ │ │ └── render-link.html │ │ │ ├── list.html │ │ │ ├── page.html │ │ │ ├── single.html │ │ │ └── taxonomy.html │ │ ├── blog/ │ │ │ ├── li.html │ │ │ ├── list.html │ │ │ ├── single.html │ │ │ └── summary.html │ │ ├── index.html │ │ ├── indexes/ │ │ │ └── tag.html │ │ ├── partials/ │ │ │ ├── blogauthor.html │ │ │ ├── footer.html │ │ │ ├── hanchor.html │ │ │ ├── head.html │ │ │ ├── hero.html │ │ │ ├── intro.html │ │ │ ├── mailinglist.html │ │ │ ├── nav.html │ │ │ ├── notification.html │ │ │ ├── page-edit.html │ │ │ ├── series_link.html │ │ │ └── sidebar.html │ │ └── shortcodes/ │ │ ├── baseurl.html │ │ ├── bignumber.html │ │ ├── blogurl.html │ │ ├── content.html │ │ ├── md.html │ │ └── note.html │ ├── static/ │ │ ├── css/ │ │ │ ├── bar.css │ │ │ ├── code.css │ │ │ ├── markdown.css │ │ │ ├── note.css │ │ │ ├── partials/ │ │ │ │ ├── banners.css │ │ │ │ ├── base.css │ │ │ │ ├── blog.css │ │ │ │ ├── buttons.css │ │ │ │ ├── fontawesome.css │ │ │ │ ├── footer.css │ │ │ │ ├── header.css │ │ │ │ ├── homepage.css │ │ │ │ ├── root.css │ │ │ │ ├── sidebar.css │ │ │ │ └── template-picker.css │ │ │ ├── styles.css │ │ │ └── tabs.css │ │ ├── fonts/ │ │ │ └── FontAwesome.otf │ │ ├── js/ │ │ │ ├── main.js │ │ │ └── tabs.js │ │ ├── lottie/ │ │ │ └── intro-section.json │ │ └── robots.txt │ └── themes/ │ ├── archetypes/ │ │ └── default.md │ ├── config.toml │ ├── content/ │ │ └── contribute/ │ │ └── _index.md │ └── layouts/ │ ├── 404.html │ ├── _default/ │ │ ├── list.html │ │ └── single.html │ ├── index.html │ └── partials/ │ ├── about.html │ ├── contact.html │ ├── counters.html │ ├── footer.html │ ├── head.html │ ├── hero.html │ ├── intro.html │ ├── js.html │ ├── nav.html │ ├── nav2.html │ ├── services.html │ ├── testimonials.html │ └── work.html └── win-wpf/ ├── .gitignore ├── AppxManifest.xml ├── Focalboard/ │ ├── App.config │ ├── App.xaml │ ├── App.xaml.cs │ ├── Focalboard.csproj │ ├── MainWindow.xaml │ ├── MainWindow.xaml.cs │ ├── Properties/ │ │ ├── AssemblyInfo.cs │ │ ├── Resources.Designer.cs │ │ ├── Resources.resx │ │ ├── Settings.Designer.cs │ │ └── Settings.settings │ ├── Utils.cs │ ├── Webview2Installer.cs │ └── packages.config ├── Focalboard.sln ├── README.md ├── build.bat ├── package-zip.bat └── package.bat ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ CHANGELOG.md README.md node_modules .github/ mac/ win-wpf/ website/ linux/ go.work go.work.sum ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org/ root = true [*] end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true charset = utf-8 [*.go] indent_style = tab [*.{js,jsx,json,html}] indent_style = space indent_size = 4 [{package.json,.eslintrc.json}] indent_size = 2 [i18n/**.json] indent_size = 2 [Makefile] indent_style = tab [*.scss] indent_style = space indent_size = 4 ================================================ FILE: .gitattributes ================================================ website/** linguist-documentation server/swagger/** linguist-generated ================================================ FILE: .github/CODEOWNERS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: 'Bug: ' labels: Bug, Triage assignees: '' --- ## Steps to reproduce the behavior 1. Go to ... 2. Select ... 3. Scroll down to ... 4. See error ## Expected behavior A clear and concise description of what you expected to happen. ## Screenshots (optional) If applicable, add screenshots or a screen recording to elaborate on the problem. ## Edition and Platform - Edition: Personal Desktop / Personal Server / Mattermost Boards (plugin) - Version: [e.g. v0.15.0] - Browser and OS: [e.g. Chrome on Mac, Edge on Windows] ## Additional context (optional) Add any other context about the problem here, and any notes about the severity: * Sev 1: Affects critical functionality without a workaround * Sev 2: Affects major functionality with a difficult or non-obvious workaround * Sev 3: Affects minor, non-critical functionality ================================================ FILE: .github/ISSUE_TEMPLATE/doc_improvement.md ================================================ --- name: Documentation Request about: Request improvement to our documentation title: 'Doc: ' labels: Documentation, Triage assignees: '' --- ## Summary Concisely summarize improvement to documentation requested. ## Link to documentation page If applicable, link to the documentation page and/or section where you feel the improvement could be added. E.g. `https://docs.mattermost.com/boards/accessing-boards.html` ## (Optional) Additional context and/or screenshot Add additional context and/or a screenshot of the product feature you'd like explained in documentation. ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement.md ================================================ --- name: Enhancement/Feature Idea about: Suggest a new capability title: 'Feature Idea: ' labels: Enhancement, Triage assignees: '' --- ## Summary What the new capability is. ## How important this is to me and why Importance: High/Medium/Low Use cases: 1. 2. 3. ## Additional context/similar features Any examples of good implementations of this capability. ================================================ FILE: .github/codeql/codeql-config.yml ================================================ name: "CodeQL config" query-filters: - exclude: problem.severity: - warning - recommendation - exclude: id: go/log-injection paths-ignore: - 'server/swagger/**/*.html' - 'website/**/*.html' - '**/*_test.go' - 'webapp/cypress/**' - '**/*.test.*' ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directories: - "**/*" reviewers: - "mattermost/cloud-sre" open-pull-requests-limit: 5 groups: Github Actions updates: applies-to: version-updates dependency-type: production schedule: # Check for updates to GitHub Actions every week day: "monday" time: "09:00" interval: "weekly" ================================================ FILE: .github/workflows/ci.yml ================================================ name: Check-in tests on: push: branches: - 'main' - 'releases-**' pull_request: workflow_dispatch: env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} EXCLUDE_ENTERPRISE: true jobs: ci-ubuntu-server: runs-on: ubuntu-22.04 strategy: matrix: db: - sqlite - mysql - mariadb - postgres steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: server/go.mod - name: "Test server: ${{matrix['db']}}" run: make server-test-${{matrix['db']}} ci-ubuntu-webapp: runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: npm ci run: cd focalboard/webapp && npm ci && cd - - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: focalboard/server/go.mod - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: focalboard/webapp/.nvmrc - name: Build Linux server run: cd focalboard; make server-linux-package - name: Copy server binary for Cypress run: cp focalboard/bin/linux/focalboard-server focalboard/bin/ - name: Upload server package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-server-linux-amd64.tar.gz path: ${{ github.workspace }}/focalboard/dist/focalboard-server-linux-amd64.tar.gz - name: Lint & test webapp run: cd focalboard; make webapp-ci ci-windows-server: runs-on: windows-2022 strategy: matrix: db: - sqlite steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: focalboard/server/go.mod - name: "Test server (minimum): ${{matrix['db']}}" run: cd focalboard; make server-test-mini-${{matrix['db']}} ci-mac-server: runs-on: macos-15 strategy: matrix: db: - sqlite steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: focalboard/server/go.mod - name: "Test server (minimum): ${{matrix['db']}}" run: cd focalboard; make server-test-mini-${{matrix['db']}} ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ name: "CodeQL" on: push: branches: [ main, release-** ] pull_request: # The branches below must be a subset of the branches above branches: [ main, release-** ] schedule: - cron: '30 4 * * 0' permissions: contents: read jobs: analyze: permissions: security-events: write # for github/codeql-action/autobuild to send a status report name: Analyze runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: language: [ 'go', 'javascript' ] steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e with: languages: ${{ matrix.language }} debug: false config-file: ./.github/codeql/codeql-config.yml # Autobuild attempts to build any compiled languages - name: Autobuild uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # Perform Analysis - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e ================================================ FILE: .github/workflows/dev-release.yml ================================================ name: Dev-Release on: push: branches: [ main, release-** ] pull_request: branches: [ main, release-** ] workflow_dispatch: env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} EXCLUDE_ENTERPRISE: true jobs: ubuntu: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Replace token 1 server run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: Replace token 2 server run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_DEV_KEY }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: npm ci run: cd focalboard/webapp; npm ci --no-optional - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 1.21 - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20.11.0 - name: apt-get update run: sudo apt-get update - name: apt-get install -y libgtk-3-dev run: sudo apt-get install -y libgtk-3-dev - name: apt-get install -y libwebkit2gtk-4.0-dev run: sudo apt-get install -y libwebkit2gtk-4.0-dev - name: Build Linux server and app run: cd focalboard/; make server-linux-package linux-app env: BUILD_NUMBER: ${{ github.run_id }} - name: Upload server package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-server-linux-amd64.tar.gz path: ${{ github.workspace }}/focalboard/dist/focalboard-server-linux-amd64.tar.gz - name: Upload app package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-linux.tar.gz path: ${{ github.workspace }}/focalboard/linux/dist/focalboard-linux.tar.gz macos: runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Replace token 1 server run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: Replace token 2 server run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_DEV_KEY }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: npm ci run: cd focalboard/webapp; npm ci --no-optional - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 1.21 - name: List Xcode versions run: ls -n /Applications/ | grep Xcode* - name: Build macOS run: cd focalboard; make mac-app env: DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer BUILD_NUMBER: ${{ github.run_id }} - name: Upload macOS package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-mac.zip path: ${{ github.workspace }}/focalboard/mac/dist/focalboard-mac.zip windows: runs-on: windows-2022 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Replace token 1 server run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: Add msbuild to PATH uses: microsoft/setup-msbuild@v1.3 - name: npm ci run: cd focalboard/webapp; npm ci --no-optional - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 1.21 - name: Setup NuGet uses: nuget/setup-nuget@323ab0502cd38fdc493335025a96c8fdb0edc71f with: nuget-version: '5.x' - name: NuGet Restore run: nuget restore focalboard\win-wpf\Focalboard.sln - name: Build Windows WPF app run: cd focalboard; make win-wpf-app env: BUILD_NUMBER: ${{ github.run_id }} - name: Upload app msix package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard.msix path: ${{ github.workspace }}/focalboard/win-wpf/focalboard.msix - name: Upload app zip package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-win.zip path: ${{ github.workspace }}/focalboard/win-wpf/dist/focalboard-win.zip ================================================ FILE: .github/workflows/lint-server.yml ================================================ name: golangci-lint on: push: branches: [ main, release-** ] pull_request: branches: [ main, release-** ] workflow_dispatch: env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} EXCLUDE_ENTERPRISE: true jobs: down-migrations: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: assert that down migrations are SELECT 1 scripts run: | cd focalboard echo 'SELECT 1;' > downmigration for file in server/services/store/sqlstore/migrations/*.down.sql; do diff -Bw downmigration $file; done golangci: name: plugin runs-on: ubuntu-22.04 steps: - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 1.21 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: set up golangci-lint run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.59.0 - name: lint run: | cd focalboard make server-lint ================================================ FILE: .github/workflows/prod-release.yml ================================================ name: Production-Release on: workflow_dispatch env: EXCLUDE_ENTERPRISE: true BRANCH_NAME: ${{ github.head_ref || github.ref_name }} jobs: ubuntu: runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Replace token 1 server run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: Replace token 2 server run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_PROD_KEY }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: npm ci run: cd focalboard/webapp; npm ci --no-optional - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 1.21 - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 20.11.0 - name: apt-get update run: sudo apt-get update - name: apt-get install -y libgtk-3-dev run: sudo apt-get install -y libgtk-3-dev - name: apt-get install -y libwebkit2gtk-4.0-dev run: sudo apt-get install -y libwebkit2gtk-4.0-dev - name: Build Linux server and app run: cd focalboard; make server-linux-package linux-app env: BUILD_NUMBER: ${{ github.run_id }} - name: Upload server package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-server-linux-amd64.tar.gz path: ${{ github.workspace }}/focalboard/dist/focalboard-server-linux-amd64.tar.gz - name: Upload app package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-linux.tar.gz path: ${{ github.workspace }}/focalboard/linux/dist/focalboard-linux.tar.gz macos: runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Replace token 1 server run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: Replace token 2 server run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_PROD_KEY }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: npm ci run: cd focalboard/webapp; npm ci --no-optional - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 1.21 - name: List Xcode versions run: ls -n /Applications/ | grep Xcode* - name: Build macOS run: cd focalboard; make mac-app env: DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer BUILD_NUMBER: ${{ github.run_id }} - name: Upload macOS package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-mac.zip path: ${{ github.workspace }}/focalboard/mac/dist/focalboard-mac.zip windows: runs-on: windows-2025 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: path: "focalboard" - name: Replace token 1 server run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: Replace token 2 server run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_PROD_KEY }},g" ${{ github.workspace }}/focalboard/server/services/telemetry/telemetry.go - name: Add msbuild to PATH uses: microsoft/setup-msbuild@v1.3 - name: npm ci run: cd focalboard/webapp; npm ci --no-optional - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 1.21 - name: Setup NuGet uses: nuget/setup-nuget@323ab0502cd38fdc493335025a96c8fdb0edc71f with: nuget-version: '5.x' - name: NuGet Restore run: nuget restore focalboard\win-wpf\Focalboard.sln - name: Build Windows WPF app run: cd focalboard; make win-wpf-app env: BUILD_NUMBER: ${{ github.run_id }} - name: Upload app msix package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard.msix path: ${{ github.workspace }}/focalboard/win-wpf/focalboard.msix - name: Upload app zip package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: focalboard-win.zip path: ${{ github.workspace }}/focalboard/win-wpf/dist/focalboard-win.zip ================================================ FILE: .github/workflows/scorecards-analysis.yml ================================================ name: Scorecards supply-chain security on: # Only the default branch is supported. branch_protection_rule: schedule: - cron: '38 10 * * 2' push: branches: [ main ] # Declare default permissions as read only. permissions: read-all jobs: analysis: name: Scorecards analysis runs-on: ubuntu-22.04 permissions: # Needed to upload the results to code-scanning dashboard. security-events: write actions: read contents: read steps: - name: "Checkout code" uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 with: results_file: results.sarif results_format: sarif # Read-only PAT token. To create it, # follow the steps in https://github.com/ossf/scorecard-action#pat-token-creation. repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} # Publish the results to enable scorecard badges. For more details, see # https://github.com/ossf/scorecard-action#publishing-results. # For private repositories, `publish_results` will automatically be set to `false`, # regardless of the value entered here. publish_results: true # Upload the results as artifacts (optional). - name: "Upload artifact" uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e with: sarif_file: results.sarif ================================================ FILE: .gitignore ================================================ # Created by https://www.gitignore.io/api/node ### Node ### # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed # OS Files .DS_Store # VSCode project files .vscode *.code-workspace # golang go.work go.work.sum # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Environment files .env # Dependency directory # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git node_modules dist pack package bin debug __debug_bin files octo*.db focalboard*.db *.boardarchive .eslintcache .vscode/settings.json # config.json is copied from app-config.json in the Makefile mac/resources/config.json mac/temp mac/dist mac/*.xcodeproj/**/xcuserdata linux/bin linux/dist linux/temp win/temp win/dist webapp/cypress/screenshots webapp/cypress/videos server/swagger/clients server/vendor .idea docker/certs docker/data server/**/*.coverage ================================================ FILE: .gitlab-ci.yml ================================================ stages: - build - s3 variables: BUILD: "yes" IMAGE_BUILDER: $CI_REGISTRY/mattermost/ci/images/builder:go-1.19.5-node-16.15.0-1 include: - project: mattermost/ci/focalboard ref: main file: private.yml ================================================ FILE: .gitpod.yml ================================================ mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config ================================================ FILE: CHANGELOG.md ================================================ # Focalboard Changelog Focalboard is an open source, self-hosted alternative to Trello, Notion, Asana and Jira for project management. We help individuals and teams define, organize, track and manage their work. This changelog summarizes updates to our open source project. You can also find the [latest releases and release notes on GitHub here](https://github.com/mattermost/focalboard/releases). ## v0.15 Release - March, 2022 * Onboarding tour. Thanks @harshilsharma63! @jespino! * Channel intro link to Boards. Thanks @sbishel! * Improved share board UI. Thanks @sbishel! * New error pages. Thanks @wiggin77! @asaadmahmood! * In-app links to import Help Docs. Thanks @justinegeffen! @sbishel! * Toggle to hide empty groups for TableView. Thanks @vish9812! * Removed transactions from sqlite backend to prevent locking issues. Thanks @jespino! * Update readme with accurate Linux standalone app build instructions. Thanks @wiggin77! * Change wrapping in React.memo. Thanks @kamre! * Don't use property value for key construction. Thanks @kamre! * Updated comment box alignment. Thanks @asaadmahmood! * Show "(Deleted User)" instead of UUID when user not found. Thanks @akkivasu! ## v0.14 Release - February, 2022 * Template selector dialog. Thanks @jespino! * New standard templates. Thanks @wiggin77! * Archive file format has changed and now supports images. Thanks @wiggin77! * Card badges. Thanks @kamre! * URL property improvement. Thanks @kamre! * GIF support in card descriptions. Thanks @asaadmahmood! * Add links to linode. Thanks @ChaseKnowlden! * Add `chown` for nobody in the docker run example. Thanks @K3UL! * Fixed Mac M1 chip build. Thanks @jpaldeano! * Removed link to deleted css file. Thanks @kamre! * Fixed typo in config.toml. Thanks @krmh04! ## v0.12 Release - January, 2022 * Change notifications. Thanks @wiggin77! * Person avatars. Thanks @asaadmahmood! * Updated comment sort order. Thanks @asaadmahmood! ## v0.11 Release - December, 2021 * Calendar view. Thanks @sbishel! * `@mention` autocomplete. Thanks @hahmadia! ## v0.10 Release - November, 2021 * @mention notifications. Thanks @wiggin77! * Board calculations. Thanks @harshilsharma63! * Unfurl card previews in posts. Thanks @hahmadia! * Plus many, many contributions from Hacktoberfest and beyond, including from: @jufab, @kamre, @Johennes, @nishantwrp, @tiago154, @DeeJayBro, @CuriousCorrelation, @prakharporwal, @donno2048, @anchepiece, @puerco, @adithyaakrishna, @JenyaFTW, @ivernus, @grsky360, @b4sen, @naresh1205, @JtheBAB, @ssensalo, @berkeka, @yedamao, @Prassud, @NakulChauhan2001, @achie27, @crspeller, @sahil9001, @alauregaillard, @igordsm, @rafaeelaudibert, @kaakaa, @Sayanta66, @Bhavin789, @Shahzayb, @kayazeren, @fcoiuri, @tsabi, @DeviousLab, @leosunmo, @xMicky24GIT, @majidsajadi, @marcvelasco, and @aloks98. Sorry if we missed anyone in this list! ## v0.9 Release - August, 2021 * New date range property type. Thanks @sbishel! * Changed the urls to use routes instead of query parameters. Thanks @jespino! * Add clear button to value selectors. Thanks @jespino! * Fix auto-size columns in FireFox. Thanks @kamre! * Fix comments not appearing in readonly view. Thanks @harshilsharma63! * Multi-line card titles. Thanks @kamre! * Add unit tests for sqlstore. Thanks @yedamao! * Add makefile documentation. Thanks @Szymongib! ## v0.8 Release - July, 2021 * CreatedBy property. Thanks @harshilsharma63! * Fix dragged card order. Thanks @kamre! * Date format user setting. Thanks @darkLord19! * Add property tooltip in board view. Thanks @ditsemto! * Fix plugin links. Thanks @N3rdP1um23! * Add MySQL documentation. Thanks @ctlaltdieliet and @3l0w! * RPC API support. Thanks @agnivade! ## v0.7.0 Release - June, 2021 * Multi-select property type. Thanks @hahmadia! * Checkbox property type. Thanks @mickmister! * Person property type. Thanks @harshilsharma63! * Grouped table view. Thanks @sbishel! * Export individual boards. Thanks @hahmadia! * Focalboard can now be built as a Mattermost plugin! Thanks @mgdelacroix and @jespino! * Improved read-only fields display. Thanks @Johennes! * Improved logging. Thanks @wiggin77! * Prometheus metrics. Thanks @spirosoik! * Mac: Open window by clicking on the dock icon. Thanks @Johennes! * Additional unit tests. Thanks @matheusmosca! * Fixed Linux app caret display. Thanks @fritsstegmann! * Added CodeQL check. Thanks @srkgupta! ## v0.6.7 Release - May, 2021 * Key Updates: * Added Todoist import script. Thanks @harshilsharma63! * Added MySql database support. Thanks @jespino! * Added URL and phone number properties. Thanks @BharatKalluri! * Added documentation for share board. Thanks @haardikdharma10! * Persist Mac app settings. Thanks @Johennes! * Improved board sorting without leading emoji. Thanks @Johennes! * Added Prettier linting for SCSS. Thanks @signalwerk! * Improved table headers. Thanks @sbishel! * Disable unused Mac tab menu. Thanks @@haardikdharma10! * Fixed server lint issues. Thanks @harshilsharma63! * Updated open button. Thanks @arjitc! ## v0.6.5 Release - April 19, 2021 * Key Updates: * Focalboard now available on DockerHub at https://hub.docker.com/r/mattermost/focalboard. [#91](https://github.com/mattermost/focalboard/issues/91) Thanks @jwilander @obbardc! * You can now contribute translations to Focalboard on https://translate.mattermost.com/. Thanks @jespino! * Added German language translation. Thanks @svelle! * Added Japanese language translation. Thanks @kaakaa! * Added French language translation. Thanks @CyrilLD! * Added Occitan language translation. Thanks Quentin PAGÈS! * Added Dutch language translation. Thanks Tom De Moor! * Added Turkish language translation. Thanks Abdullah Musab! * Added Chinese language translation. Thanks Yao Xie and toto6038! * Added Russian language translation. Thanks Edward Smirnov! * Add Dockerfile to run service in a container. [#76](https://github.com/mattermost/focalboard/pull/76) Thanks @proffalken! * Add docker-compose to run the whole service in containers. [#105](https://github.com/mattermost/focalboard/pull/105) Thanks @jbutler992! * Added Gallery view. * Added Checkbox content type. * Added Selected cards duplication with Ctrl+D. * Added Search shortcut (Ctrl+Shift+F). * Requested Contributions * Add more frontend unit test coverage. [#126](https://github.com/mattermost/focalboard/pull/126) Thanks @renjithgr! * [GH-40](https://github.com/mattermost/focalboard/issues/40) - Add property type email [#84](https://github.com/mattermost/focalboard/pull/84). Thanks @renjithgr! ## v0.6.1 Release - March 15, 2021 * Focalboard Personal Desktop is now live in the App Stores: * [Mac App Store](https://apps.apple.com/app/apple-store/id1556908618?pt=2114704&ct=changelog&mt=8) * [Microsoft App Store](https://www.microsoft.com/store/apps/9NLN2T0SX9VF?cid=changelog) * Added [Windows native app (WPF)](https://github.com/mattermost/focalboard/tree/main/win-wpf) support * Added [Swagger / OpenAPI definition and documentation](https://htmlpreview.github.io/?https://github.com/mattermost/focalboard/blob/main/server/swagger/docs/html/index.html) * Added [Import scripts for Trello, Asana, and Notion](https://github.com/mattermost/focalboard/tree/main/import) * Added [Developer Tips and Tricks article](https://www.focalboard.com/contribute/getting-started/dev-tips/). * Added Security improvements: * [Single-user session token](https://github.com/mattermost/focalboard/commit/0fe96ad7ed3b0c3a68c9a5889b34b764782f9266) * [CSRF prevention with X-Requested-With header](https://github.com/mattermost/focalboard/commit/43c656c9a440e12f87b61d66654ed3d9873b1620) ================================================ FILE: CONTRIBUTING.md ================================================ # Disclaimer > [!WARNING] > **Effective September 15th, 2023, Mattermost, Inc. staff are no longer reviewing or merging pull requests for either Focalboard or the Mattermost Boards plugin in this repository (`mattermost/focalboard`). We encourage the community to fork this repository for continued development and contributions.** > > The reason behind these changes is to focus Mattermost developer resources on improving the platform’s performance and core features to ensure Mattermost continues being resilient, stable, and best-in-breed for critical operations. > > ️💡 [Learn more](https://forum.mattermost.com/t/upcoming-product-changes-to-boards-and-various-plugins/16669) ## Past maintainers - **Scott Bishel**: [@sbishel](https://github.com/sbishel) - **Jesús Espino**: [@jespino](https://github.com/jespino) - **Doug Lauder**: [@wiggin77](https://github.com/wiggin77) - **Miguel de la Cruz**: [@mgdelacroix](https://github.com/mgdelacroix) - **Harshil Sharma**: [@harshilsharma63](https://github.com/harshilsharma63) - **Chen Lim**: [@chenilim](https://github.com/chenilim) - **Ogi Marušić**: [@ogi-m](https://github.com/ogi-m) - **Winson Wu**: [@wuwinson](https://github.com/wuwinson) - **Justine Geffen**: [@justinegeffen](https://github.com/justinegeffen) ================================================ FILE: Dockerfile.build ================================================ # This Dockerfile is used to build Focalboard for Linux. It builds all the parts inside the image # and the last stage just holds the package which is then copied back to the host. # # docker buildx build -f Dockerfile.build --no-cache --platform linux/amd64 -t focalboard-build:dirty --output out . # docker buildx build -f Dockerfile.build --no-cache --platform linux/arm64 -t focalboard-build:dirty --output out . # # Afterwards the packages can be found in the ./out folder. # build frontend FROM node:16.3.0@sha256:ca6daf1543242acb0ca59ff425509eab7defb9452f6ae07c156893db06c7a9a4 AS frontend WORKDIR /webapp COPY webapp . ### 'CPPFLAGS="-DPNG_ARM_NEON_OPT=0"' Needed To Avoid Bug Described in: https://github.com/imagemin/optipng-bin/issues/118#issuecomment-1019838562 ### Can be Removed when Ticket will be Closed RUN CPPFLAGS="-DPNG_ARM_NEON_OPT=0" npm install --no-optional && \ npm run pack # build backend and package FROM golang:1.18.3@sha256:b203dc573d81da7b3176264bfa447bd7c10c9347689be40540381838d75eebef AS backend COPY . . COPY --from=frontend /webapp/pack webapp/pack ARG TARGETARCH # RUN apt-get update && apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev -y RUN EXCLUDE_PLUGIN=true EXCLUDE_SERVER=true EXCLUDE_ENTERPRISE=true make server-linux arch=${TARGETARCH} RUN make server-linux-package-docker arch=${TARGETARCH} # Copy package back to host FROM scratch AS dist ARG TARGETARCH COPY --from=backend /go/dist/focalboard-server-linux-${TARGETARCH}.tar.gz . ================================================ FILE: LICENSE.txt ================================================ Mattermost Licensing SOFTWARE LICENSING You are licensed to use compiled versions of Focalboard produced by Mattermost, Inc. under an MIT LICENSE - See MIT-COMPILED-LICENSE.md included in compiled versions for details You may be licensed to use source code to create compiled versions not produced by Mattermost, Inc. in one of two ways: 1. Under the Free Software Foundation’s GNU AGPL v.3.0, subject to the exceptions outlined in this policy; or 2. Under a commercial license available from Mattermost, Inc. by contacting commercial@mattermost.com You are licensed to use the source code in Admin Tools and Configuration Files (webapp/html-templates/, app-config.json, config.json, webapp/i18n/, server/model/, plugin/ and all subdirectories thereof) under the Apache License v2.0. We promise that we will not enforce the copyleft provisions in AGPL v3.0 against you if your application (a) does not link to Focalboard directly, but exclusively uses Focalboard's Admin Tools and Configuration Files, and (b) you have not modified, added to or adapted the source code of Focalboard in a way that results in the creation of a “modified version” or “work based on” Focalboard as these terms are defined in the AGPL v3.0 license. MATTERMOST TRADEMARK GUIDELINES Your use of the mark Mattermost is subject to Mattermost, Inc's prior written approval and our organization’s Trademark Standards of Use at http://www.mattermost.org/trademark-standards-of-use/. For trademark approval or any questions you have about using these trademarks, please email trademark@mattermost.com ------------------------------------------------------------------------------------------------------------------------------ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ------------------------------------------------------------------------------ The software is released under the terms of the GNU Affero General Public License, version 3. GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) 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 . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: Makefile ================================================ .PHONY: prebuild clean cleanall ci server server-mac server-linux server-win server-linux-package generate watch-server webapp mac-app win-app-wpf linux-app modd-precheck templates-archive PACKAGE_FOLDER = focalboard # Build Flags BUILD_NUMBER ?= $(BUILD_NUMBER:) BUILD_DATE = $(shell date -u) BUILD_HASH = $(shell git rev-parse HEAD) # If we don't set the build number it defaults to dev ifeq ($(BUILD_NUMBER),) BUILD_NUMBER := dev BUILD_DATE := n/a endif BUILD_TAGS += json1 sqlite3 LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildNumber=$(BUILD_NUMBER)" LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildDate=$(BUILD_DATE)" LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildHash=$(BUILD_HASH)" RACE = -race ifeq ($(OS),Windows_NT) RACE := '' endif # MAC cpu architecture ifeq ($(shell uname -m),arm64) MAC_GO_ARCH := arm64 else MAC_GO_ARCH := amd64 endif all: webapp server ## Build server and webapp. prebuild: ## Run prebuild actions (install dependencies etc.). cd webapp; npm install ci: webapp-ci server-test ## Simulate CI, locally. templates-archive: ## Build templates archive file cd server/assets/build-template-archive; go run -tags '$(BUILD_TAGS)' main.go --dir="../templates-boardarchive" --out="../templates.boardarchive" server: ## Build server for local environment. $(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=dev") cd server; go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/focalboard-server ./main server-mac: ## Build server for Mac. mkdir -p bin/mac $(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=mac") ifeq ($(FB_PROD),) cd server; env GOOS=darwin GOARCH=$(MAC_GO_ARCH) go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/mac/focalboard-server ./main else # Always build x86 for production, to work on both Apple Silicon and legacy Macs cd server; env GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/mac/focalboard-server ./main endif server-linux: ## Build server for Linux. mkdir -p bin/linux $(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=linux") cd server; env GOOS=linux GOARCH=$(arch) go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/linux/focalboard-server ./main server-docker: ## Build server for Docker Architectures. mkdir -p bin/docker $(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=linux") cd server; env GOOS=$(os) GOARCH=$(arch) go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/docker/focalboard-server ./main server-win: ## Build server for Windows. $(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=win") cd server; env GOOS=windows GOARCH=amd64 go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/win/focalboard-server.exe ./main server-dll: ## Build server as Windows DLL. $(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=win") cd server; env GOOS=windows GOARCH=amd64 go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -buildmode=c-shared -o ../bin/win-dll/focalboard-server.dll ./main server-linux-package: server-linux webapp rm -rf package mkdir -p package/${PACKAGE_FOLDER}/bin cp bin/linux/focalboard-server package/${PACKAGE_FOLDER}/bin cp -R webapp/pack package/${PACKAGE_FOLDER}/pack cp server-config.json package/${PACKAGE_FOLDER}/config.json cp NOTICE.txt package/${PACKAGE_FOLDER} cp webapp/NOTICE.txt package/${PACKAGE_FOLDER}/webapp-NOTICE.txt mkdir -p dist cd package && tar -czvf ../dist/focalboard-server-linux-amd64.tar.gz ${PACKAGE_FOLDER} rm -rf package server-linux-package-docker: rm -rf package mkdir -p package/${PACKAGE_FOLDER}/bin cp bin/linux/focalboard-server package/${PACKAGE_FOLDER}/bin cp -R webapp/pack package/${PACKAGE_FOLDER}/pack cp server-config.json package/${PACKAGE_FOLDER}/config.json cp NOTICE.txt package/${PACKAGE_FOLDER} cp webapp/NOTICE.txt package/${PACKAGE_FOLDER}/webapp-NOTICE.txt mkdir -p dist cd package && tar -czvf ../dist/focalboard-server-linux-$(arch).tar.gz ${PACKAGE_FOLDER} rm -rf package generate: ## Install and run code generators. cd server; go install github.com/golang/mock/mockgen@v1.6.0 cd server; go generate ./... server-lint: ## Run linters on server code. @if ! [ -x "$$(command -v golangci-lint)" ]; then \ echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install-golangci-lint for installation instructions."; \ exit 1; \ fi; cd server; golangci-lint run ./... modd-precheck: @if ! [ -x "$$(command -v modd)" ]; then \ echo "modd is not installed. Please see https://github.com/cortesi/modd#install for installation instructions"; \ exit 1; \ fi; \ watch: modd-precheck ## Run both server and webapp watching for changes env FOCALBOARD_BUILD_TAGS='$(BUILD_TAGS)' modd watch-single-user: modd-precheck ## Run both server and webapp in single user mode watching for changes env FOCALBOARDSERVER_ARGS=--single-user FOCALBOARD_BUILD_TAGS='$(BUILD_TAGS)' modd watch-server-test: modd-precheck ## Run server tests watching for changes env FOCALBOARD_BUILD_TAGS='$(BUILD_TAGS)' modd -f modd-servertest.conf server-test: server-test-sqlite server-test-mysql server-test-mariadb server-test-postgres ## Run server tests server-test-sqlite: export FOCALBOARD_UNIT_TESTING=1 server-test-sqlite: ## Run server tests using sqlite cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-sqlite-profile.coverage -count=1 -timeout=30m ./... cd server; go tool cover -func server-sqlite-profile.coverage server-test-mini-sqlite: export FOCALBOARD_UNIT_TESTING=1 server-test-mini-sqlite: ## Run server tests using sqlite cd server/integrationtests; go test -tags '$(BUILD_TAGS)' $(RACE) -v -count=1 -timeout=30m ./... server-test-mysql: export FOCALBOARD_UNIT_TESTING=1 server-test-mysql: export FOCALBOARD_STORE_TEST_DB_TYPE=mysql server-test-mysql: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44446 server-test-mysql: ## Run server tests using mysql @echo Starting docker container for mysql docker compose -f ./docker-testing/docker-compose-mysql.yml down -v --remove-orphans docker compose -f ./docker-testing/docker-compose-mysql.yml run start_dependencies cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-mysql-profile.coverage -count=1 -timeout=30m ./... cd server; go tool cover -func server-mysql-profile.coverage docker compose -f ./docker-testing/docker-compose-mysql.yml down -v --remove-orphans server-test-mariadb: export FOCALBOARD_UNIT_TESTING=1 server-test-mariadb: export FOCALBOARD_STORE_TEST_DB_TYPE=mariadb server-test-mariadb: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44445 server-test-mariadb: templates-archive ## Run server tests using mysql @echo Starting docker container for mariadb docker compose -f ./docker-testing/docker-compose-mariadb.yml down -v --remove-orphans docker compose -f ./docker-testing/docker-compose-mariadb.yml run start_dependencies cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-mariadb-profile.coverage -count=1 -timeout=30m ./... cd server; go tool cover -func server-mariadb-profile.coverage docker compose -f ./docker-testing/docker-compose-mariadb.yml down -v --remove-orphans server-test-postgres: export FOCALBOARD_UNIT_TESTING=1 server-test-postgres: export FOCALBOARD_STORE_TEST_DB_TYPE=postgres server-test-postgres: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44447 server-test-postgres: ## Run server tests using postgres @echo Starting docker container for postgres docker compose -f ./docker-testing/docker-compose-postgres.yml down -v --remove-orphans docker compose -f ./docker-testing/docker-compose-postgres.yml run start_dependencies cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-postgres-profile.coverage -count=1 -timeout=30m ./... cd server; go tool cover -func server-postgres-profile.coverage docker compose -f ./docker-testing/docker-compose-postgres.yml down -v --remove-orphans webapp: ## Build webapp. cd webapp; npm run pack webapp-ci: ## Webapp CI: linting & testing. cd webapp; npm run check cd webapp; npm run test cd webapp; npm run cypress:ci webapp-test: ## jest tests for webapp cd webapp; npm run test mac-app: server-mac webapp ## Build Mac application. rm -rf mac/temp rm -rf mac/dist rm -rf mac/resources/bin rm -rf mac/resources/pack mkdir -p mac/resources/bin cp bin/mac/focalboard-server mac/resources/bin/focalboard-server cp app-config.json mac/resources/config.json cp -R webapp/pack mac/resources/pack mkdir -p mac/temp xcodebuild archive -workspace mac/Focalboard.xcworkspace -scheme Focalboard -archivePath mac/temp/focalboard.xcarchive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED="NO" CODE_SIGNING_ALLOWED="NO" \ || { echo "xcodebuild failed, did you install the full Xcode and not just the CLI tools?"; exit 1; } mkdir -p mac/dist cp -R mac/temp/focalboard.xcarchive/Products/Applications/Focalboard.app mac/dist/ # xcodebuild -exportArchive -archivePath mac/temp/focalboard.xcarchive -exportPath mac/dist -exportOptionsPlist mac/export.plist cp NOTICE.txt mac/dist cp webapp/NOTICE.txt mac/dist/webapp-NOTICE.txt cd mac/dist; zip -r focalboard-mac.zip Focalboard.app MIT-COMPILED-LICENSE.md NOTICE.txt webapp-NOTICE.txt win-wpf-app: server-dll webapp ## Build Windows WPF application. cd win-wpf && ./build.bat cd win-wpf && ./package.bat cd win-wpf && ./package-zip.bat linux-app: webapp ## Build Linux application. rm -rf linux/temp rm -rf linux/dist mkdir -p linux/dist mkdir -p linux/temp/focalboard-app cp app-config.json linux/temp/focalboard-app/config.json cp NOTICE.txt linux/temp/focalboard-app/ cp webapp/NOTICE.txt linux/temp/focalboard-app/webapp-NOTICE.txt cp -R webapp/pack linux/temp/focalboard-app/pack cd linux; make build cp -R linux/bin/focalboard-app linux/temp/focalboard-app/ cd linux/temp; tar -zcf ../dist/focalboard-linux.tar.gz focalboard-app rm -rf linux/temp swagger: ## Generate swagger API spec and clients based on it. mkdir -p server/swagger/docs mkdir -p server/swagger/clients cd server && swagger generate spec -m -o ./swagger/swagger.yml cd server/swagger && openapi-generator generate -i swagger.yml -g html2 -o docs/html cd server/swagger && openapi-generator generate -i swagger.yml -g go -o clients/go cd server/swagger && openapi-generator generate -i swagger.yml -g javascript -o clients/javascript cd server/swagger && openapi-generator generate -i swagger.yml -g typescript-fetch -o clients/typescript cd server/swagger && openapi-generator generate -i swagger.yml -g swift5 -o clients/swift cd server/swagger && openapi-generator generate -i swagger.yml -g python -o clients/python clean: ## Clean build artifacts. rm -rf bin rm -rf dist rm -rf webapp/pack rm -rf mac/temp rm -rf mac/dist rm -rf linux/dist rm -rf win-wpf/msix rm -f win-wpf/focalboard.msix cleanall: clean ## Clean all build artifacts and dependencies. rm -rf webapp/node_modules ## Help documentatin à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html help: @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' ./Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' ================================================ FILE: NOTICE.txt ================================================ Focalboard © 2015-present Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information. NOTICES: -------- This document includes a list of open source components used in Focalboard, including those that have been modified. ----- ## Go This product uses the Go programming language by the Go authors. * HOMEPAGE: * https://golang.org * LICENSE: BSD-style Copyright (c) 2009 The Go Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## Masterminds/squirrel This product contains 'squirrel' by GitHub user "Masterminds". Fluent SQL generation for golang * HOMEPAGE: * https://github.com/Masterminds/squirrel * LICENSE: MIT Squirrel The Masterminds Copyright (C) 2014-2015, Lann Martin Copyright (C) 2015-2016, Google Copyright (C) 2015, Matt Farina and Matt Butcher Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## golang-migrate/migrate This product contains 'migrate' by GitHub user "golang-migrate". Database migrations. CLI and Golang library. * HOMEPAGE: * https://github.com/golang-migrate/migrate * LICENSE: MIT The MIT License (MIT) Original Work Copyright (c) 2016 Matthias Kadenbach https://github.com/mattes/migrate Modified Work Copyright (c) 2018 Dale Hui https://github.com/golang-migrate/migrate Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## x/tools This product contains 'tools' by The Go Authors. [mirror] Go tools * HOMEPAGE: * https://github.com/golang/tools * LICENSE: BSD-3-Clause Copyright (c) 2009 The Go Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## x/crypto This product contains 'crypto' by The Go Authors. [mirror] Go supplementary cryptography libraries * HOMEPAGE: * https://github.com/golang/crypto * LICENSE: BSD-3-Clause Copyright (c) 2009 The Go Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## gorilla/handlers This product contains 'handlers' by Gorilla web toolkit. A collection of useful handlers for Go's net/http package. * HOMEPAGE: * https://github.com/gorilla/handlers * LICENSE: BSD-2-Clause Copyright (c) 2013 The Gorilla Handlers Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## gorilla/mux This product contains 'mux' by Gorilla web toolkit. A powerful URL router and dispatcher for golang. * HOMEPAGE: * https://github.com/gorilla/mux * LICENSE: BSD-3-Clause Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## gorilla/websocket This product contains 'websocket' by Gorilla web toolkit. A WebSocket implementation for Go. * HOMEPAGE: * https://github.com/gorilla/websocket * LICENSE: BSD-2-Clause Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## zserge/lorca This product contains 'lorca' by GitHub user "zserge". A very small library to build modern HTML5 desktop apps in Go. * HOMEPAGE: * https://github.com/zserge/lorca * LICENSE: MIT MIT License Copyright (c) 2018 Serge Zaitsev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## webview/webview This product contains 'webview' by GitHub user "webview". Tiny cross-platform webview library for C/C++/Golang. * HOMEPAGE: * https://github.com/webview/webview * LICENSE: MIT MIT License Copyright (c) 2017 Serge Zaitsev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## lib/pq This product contains 'pq' by GitHub user "lib". Pure Go Postgres driver for database/sql * HOMEPAGE: * https://github.com/lib/pq * LICENSE: MIT Copyright (c) 2011-2013, 'pq' Contributors Portions Copyright (C) 2011 Blake Mizerany Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- This product contains 'viper' by GitHub user "spf13". Go configuration with fangs * HOMEPAGE: * https://github.com/spf13/viper * LICENSE: MIT Copyright (c) 2014 Steve Francia Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- This product contains 'uuid' by Google Inc. Go package for UUIDs based on RFC 4122 and DCE 1.1: Authentication and Security Services. * HOMEPAGE: * https://github.com/google/uuid * LICENSE: BSD-3-Clause Copyright (c) 2009,2014 Google Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## pkg/errors This product contains 'errors' by GitHub user "pkg". Simple error handling primitives * HOMEPAGE: * https://github.com/pkg/errors * LICENSE: BSD-2-Clause Copyright (c) 2015, Dave Cheney All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## rudderlabs/analytics-go This product contains 'analytics-go' by Segment, Inc. A toolkit with common assertions and mocks that plays nicely with the standard library * HOMEPAGE: * https://github.com/rudderlabs/analytics-go * LICENSE: MIT The MIT License (MIT) Copyright (c) 2016 Segment, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## gonutz/w32 This product contains 'testify' by Stretchr, Inc.. A wrapper of Windows APIs for the Go Programming Language. * HOMEPAGE: * https://github.com/gonutz/w32 * LICENSE: BSD-style Copyright (c) 2010-2012 The w32 Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. The names of the authors may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- ## stretchr/testify This product contains 'testify' by Stretchr, Inc.. A toolkit with common assertions and mocks that plays nicely with the standard library * HOMEPAGE: * https://github.com/stretchr/testify * LICENSE: MIT MIT License Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## mattn/go-sqlite3 This product contains 'go-sqlite3' by GitHub user "mattn". sqlite3 driver for go using database/sql * HOMEPAGE: * https://github.com/mattn/go-sqlite3 * LICENSE: MIT The MIT License (MIT) Copyright (c) 2014 Yasuhiro Matsumoto Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## zap This product contains 'zap' by Uber Technologies, Inc. Blazing fast, structured, leveled logging in Go. * HOMEPAGE: * https://github.com/uber-go/zap * LICENSE: MIT Copyright (c) 2016-2017 Uber Technologies, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## nicksnyder/go-i18n This product contains 'go-i18n' by GitHub user 'nicksnyder'. Translate your Go program into multiple languages. * HOMEPAGE: * https://github.com/nicksnyder/go-i18n * LICENSE: MIT Copyright (c) 2014 Nick Snyder https://github.com/nicksnyder Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## niemeyer/pretty This product contains 'pretty' by GitHub user "niemeyer" Pretty printing for Go values * HOMEPAGE: * https://github.com/niemeyer/pretty * LICENSE: MIT Copyright 2012 Keith Rarick Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- ## wiggin77/merror This product contains 'merror' by GitHub user 'wiggin77'. Multiple Error aggregator for Go. * HOMEPAGE: * https://github.com/wiggin77/merror * LICENSE: MIT MIT License Copyright (c) 2018 wiggin77 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ > [!WARNING] > This repository is currently not maintained. If you're interested in becoming a maintainer please [let us know here](https://github.com/mattermost-community/focalboard/issues/5038). > > This repository only contains standalone Focalboard. If you're looking for the Mattermost plugin please see [mattermost/mattermost-plugin-boards](https://github.com/mattermost/mattermost-plugin-boards). > # Focalboard ![CI Status](https://github.com/mattermost/focalboard/actions/workflows/ci.yml/badge.svg) ![CodeQL](https://github.com/mattermost/focalboard/actions/workflows/codeql-analysis.yml/badge.svg) ![Dev Release](https://github.com/mattermost/focalboard/actions/workflows/dev-release.yml/badge.svg) ![Prod Release](https://github.com/mattermost/focalboard/actions/workflows/prod-release.yml/badge.svg) ![Focalboard](website/site/static/img/hero.jpg) Focalboard is an open source, multilingual, self-hosted project management tool that's an alternative to Trello, Notion, and Asana. It helps define, organize, track and manage work across individuals and teams. Focalboard comes in two editions: * **[Personal Desktop](https://www.focalboard.com/docs/personal-edition/desktop/)**: A standalone, single-user [macOS](https://apps.apple.com/app/apple-store/id1556908618?pt=2114704&ct=website&mt=8), [Windows](https://www.microsoft.com/store/apps/9NLN2T0SX9VF?cid=website), or [Linux](https://www.focalboard.com/download/personal-edition/desktop/#linux-desktop) desktop app for your own todos and personal projects. * **[Personal Server](https://www.focalboard.com/download/personal-edition/ubuntu/)**: A standalone, multi-user server for development and personal use. ## Try Focalboard ### Personal Desktop (Windows, Mac or Linux Desktop) * **Windows**: Download from the [Windows App Store](https://www.microsoft.com/store/productId/9NLN2T0SX9VF) or download `focalboard-win.zip` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and run `Focalboard.exe`. * **Mac**: Download from the [Mac App Store](https://apps.apple.com/us/app/focalboard-insiders/id1556908618?mt=12). * **Linux Desktop**: Download `focalboard-linux.tar.gz` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and open `focalboard-app`. ### Personal Server **Ubuntu**: You can download and run the compiled Focalboard **Personal Server** on Ubuntu by following [our latest install guide](https://www.focalboard.com/download/personal-edition/ubuntu/). ### API Docs Boards API docs can be found over at ### Getting started Our [developer guide](https://developers.mattermost.com/contribute/focalboard/personal-server-setup-guide) has detailed instructions on how to set up your development environment for the **Personal Server**. You can also join the [~Focalboard community channel](https://community.mattermost.com/core/channels/focalboard) to connect with other developers. Create an `.env` file in the focalboard directory that contains: ``` EXCLUDE_ENTERPRISE="1" ``` To build the server: ``` make prebuild make ``` To run the server: ``` ./bin/focalboard-server ``` Then navigate your browser to [`http://localhost:8000`](http://localhost:8000) to access your Focalboard server. The port is configured in `config.json`. Once the server is running, you can rebuild just the web app via `make webapp` in a separate terminal window. Reload your browser to see the changes. ### Building and running standalone desktop apps You can build standalone apps that package the server to run locally against SQLite: * **Windows**: * *Requires Windows 10, [Windows 10 SDK](https://developer.microsoft.com/en-us/windows/downloads/sdk-archive/) 10.0.19041.0, and .NET 4.8 developer pack* * Open a `git-bash` prompt. * Run `make prebuild` * The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc. * Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes. * Run `make win-wpf-app` * Run `cd win-wpf/msix && focalboard.exe` * **Mac**: * *Requires macOS 11.3+ and Xcode 13.2.1+* * Run `make prebuild` * The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc. * Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes. * Run `make mac-app` * Run `open mac/dist/Focalboard.app` * **Linux**: * *Tested on Ubuntu 18.04* * Install `webgtk` dependencies * Run `sudo apt-get install libgtk-3-dev` * Run `sudo apt-get install libwebkit2gtk-4.0-dev` * Run `make prebuild` * The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc. * Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes. * Run `make linux-app` * Uncompress `linux/dist/focalboard-linux.tar.gz` to a directory of your choice * Run `focalboard-app` from the directory you have chosen * **Docker**: * To run it locally from offical image: * `docker run -it -p 80:8000 mattermost/focalboard` * To build it for your current architecture: * `docker build -f docker/Dockerfile .` * To build it for a custom architecture (experimental): * `docker build -f docker/Dockerfile --platform linux/arm64 .` Cross-compilation currently isn't fully supported, so please build on the appropriate platform. Refer to the GitHub Actions workflows (`build-mac.yml`, `build-win.yml`, `build-ubuntu.yml`) for the detailed list of steps on each platform. ### Unit testing Before checking in commits, run `make ci`, which is similar to the `.gitlab-ci.yml` workflow and includes: * **Server unit tests**: `make server-test` * **Web app ESLint**: `cd webapp; npm run check` * **Web app unit tests**: `cd webapp; npm run test` * **Web app UI tests**: `cd webapp; npm run cypress:ci` ### Staying informed * **Changes**: See the [CHANGELOG](CHANGELOG.md) for the latest updates * **Bug Reports**: [File a bug report](https://github.com/mattermost/focalboard/issues/new?assignees=&labels=bug&template=bug_report.md&title=) * **Chat**: Join the [~Focalboard community channel](https://community.mattermost.com/core/channels/focalboard) ================================================ FILE: SECURITY.md ================================================ Security ======== Safety and data security is of the utmost priority for the Mattermost community. If you are a security researcher and have discovered a security vulnerability in our codebase, we would appreciate your help in disclosing it to us in a responsible manner. Reporting security issues ------------------------- **Please do not use GitHub issues for security-sensitive communication.** Security issues in the community test server, any of the open source codebases maintained by Mattermost, or any of our commercial offerings should be reported via email to [responsibledisclosure@mattermost.com](mailto:responsibledisclosure@mattermost.com). Mattermost is committed to working together with researchers and keeping them updated throughout the patching process. Researchers who responsibly report valid security issues will be publicly credited for their efforts (if they so choose). For a more detailed description of the disclosure process and a list of researchers who have previously contributed to the disclosure program, see [Report a Security Vulnerability](https://mattermost.com/security-vulnerability-report/) on the Mattermost website. Security updates ---------------- Mattermost has a mandatory upgrade policy, and updates are only provided for the latest release. Critical updates are delivered as dot releases. Details on security updates are announced 30 days after the availability of the update. For more details about the security content of past releases, see the [Security Updates](https://mattermost.com/security-updates/) page on the Mattermost website. For timely notifications about new security updates, subscribe to the [Security Bulletins Mailing List](https://about.mattermost.com/security-bulletin). Contributing to this policy --------------------------- If you have feedback or suggestions on improving this policy document, please [create an issue](https://github.com/mattermost/focalboard/issues/new/choose). ================================================ FILE: app-config.json ================================================ { "serverRoot": "http://localhost:8088", "port": 8088, "dbtype": "sqlite3", "dbconfig": "./focalboard.db", "useSSL": false, "webpath": "./pack", "filespath": "./files", "telemetry": true, "localOnly": true } ================================================ FILE: config.json ================================================ { "serverRoot": "http://localhost:8000", "port": 8000, "dbtype": "sqlite3", "dbconfig": "./focalboard.db?_busy_timeout=5000", "dbpingattempts": 5, "dbtableprefix": "", "postgres_dbconfig": "dbname=focalboard sslmode=disable", "useSSL": false, "webpath": "./webapp/pack", "filesdriver": "local", "filespath": "./files", "telemetry": true, "prometheusaddress": ":9092", "webhook_update": [], "session_expire_time": 2592000, "session_refresh_time": 18000, "localOnly": false, "enableLocalMode": true, "localModeSocketLocation": "/var/tmp/focalboard_local.socket", "authMode": "native", "logging_cfg_file": "", "audit_cfg_file": "", "enablePublicSharedBoards": false } ================================================ FILE: docker/Dockerfile ================================================ ### Webapp build FROM node:16.3.0@sha256:ca6daf1543242acb0ca59ff425509eab7defb9452f6ae07c156893db06c7a9a4 as nodebuild WORKDIR /webapp ADD webapp/ /webapp ### 'CPPFLAGS="-DPNG_ARM_NEON_OPT=0"' Needed To Avoid Bug Described in: https://github.com/imagemin/optipng-bin/issues/118#issuecomment-1019838562 ### Can be Removed when Ticket will be Closed RUN CPPFLAGS="-DPNG_ARM_NEON_OPT=0" npm install --no-optional && \ npm run pack ### Go build FROM golang:1.18.3@sha256:b203dc573d81da7b3176264bfa447bd7c10c9347689be40540381838d75eebef AS gobuild WORKDIR /go/src/focalboard ADD . /go/src/focalboard # Get target architecture ARG TARGETOS ARG TARGETARCH RUN EXCLUDE_PLUGIN=true EXCLUDE_SERVER=true EXCLUDE_ENTERPRISE=true make server-docker os=${TARGETOS} arch=${TARGETARCH} ## Final image FROM debian:buster-slim@sha256:5b0b1a9a54651bbe9d4d3ee96bbda2b2a1da3d2fa198ddebbced46dfdca7f216 RUN mkdir -p /opt/focalboard/data/files RUN chown -R nobody:nogroup /opt/focalboard WORKDIR /opt/focalboard COPY --from=nodebuild --chown=nobody:nogroup /webapp/pack pack/ COPY --from=gobuild --chown=nobody:nogroup /go/src/focalboard/bin/docker/focalboard-server bin/ COPY --from=gobuild --chown=nobody:nogroup /go/src/focalboard/LICENSE.txt LICENSE.txt COPY --from=gobuild --chown=nobody:nogroup /go/src/focalboard/docker/server_config.json config.json USER nobody EXPOSE 8000/tcp EXPOSE 8000/tcp 9092/tcp VOLUME /opt/focalboard/data CMD ["/opt/focalboard/bin/focalboard-server"] ================================================ FILE: docker/README.md ================================================ # Deploy Focalboard with Docker ## Docker The Dockerfile gives a quick and easy way to build the latest Focalboard server and deploy it locally. In the example below, the Focalboard database and files will be persisted in a named volumed called `fbdata`. From the Focalboard project root directory: ```bash docker build -f docker/Dockerfile -t focalboard . docker run -it -v "fbdata:/opt/focalboard/data" -p 80:8000 focalboard ``` Open a browser to [localhost](http://localhost) to start ## Alternative architectures From the Focalboard project root directory: ```bash docker build -f docker/Dockerfile --platform linux/arm64 -t focalboard . docker run -it -v "fbdata:/opt/focalboard/data" -p 80:8000 focalboard ``` ## Docker-Compose Docker-Compose provides the option to automate the build and run step, or even include some of the steps from the [personal server setup](https://www.focalboard.com/download/personal-edition/ubuntu/). To start the server, change directory to `focalboard/docker` and run: ```bash docker-compose up ``` This will automatically build the focalboard image and start it with the http port mapping. These examples also create a persistent named volume called `fbdata`. To run Focalboard with a nginx proxy and a postgres backend, change directory to `focalboard/docker` and run: ```bash docker-compose -f docker-compose-db-nginx.yml up ``` ================================================ FILE: docker/config.json ================================================ { "serverRoot": "http://localhost:8000", "port": 8000, "dbtype": "postgres", "dbconfig": "postgres://boardsuser:boardsuser-password@focalboard-db/boards?sslmode=disable&connect_timeout=10", "postgres_dbconfig": "dbname=boards sslmode=disable", "useSSL": false, "webpath": "./pack", "filespath": "./data/files", "telemetry": true, "prometheusaddress": ":9092", "session_expire_time": 2592000, "session_refresh_time": 18000, "localOnly": false, "enableLocalMode": true, "localModeSocketLocation": "/var/tmp/focalboard_local.socket" } ================================================ FILE: docker/docker-compose-db-nginx.yml ================================================ version: "3" services: app: build: context: ../ dockerfile: docker/Dockerfile container_name: focalboard depends_on: - focalboard-db expose: - 8000 environment: - VIRTUAL_HOST=localhost - VIRTUAL_PORT=8000 - VIRTUAL_PROTO=http volumes: - "./config.json:/opt/focalboard/config.json" - fbdata:/opt/focalboard/data restart: always networks: - proxy - default proxy: image: jwilder/nginx-proxy:latest container_name: focalboard-proxy restart: always ports: - 80:80 volumes: - /var/run/docker.sock:/tmp/docker.sock:ro networks: - proxy focalboard-db: image: postgres:latest container_name: focalboard-postgres restart: always volumes: - pgdata:/var/lib/postgresql/data environment: POSTGRES_DB: boards POSTGRES_USER: boardsuser POSTGRES_PASSWORD: boardsuser-password volumes: fbdata: pgdata: networks: proxy: ================================================ FILE: docker/docker-compose.yml ================================================ version: "3" services: app: build: context: ../ dockerfile: docker/Dockerfile container_name: focalboard volumes: - fbdata:/opt/focalboard/data ports: - 80:8000 environment: - VIRTUAL_HOST=focalboard.local - VIRTUAL_PORT=8000 volumes: fbdata: ================================================ FILE: docker/server_config.json ================================================ { "serverRoot": "http://localhost:8000", "port": 8000, "dbtype": "sqlite3", "dbconfig": "./data/focalboard.db", "postgres_dbconfig": "dbname=focalboard sslmode=disable", "useSSL": false, "webpath": "./pack", "filespath": "./data/files", "telemetry": true, "session_expire_time": 2592000, "session_refresh_time": 18000, "localOnly": false, "enableLocalMode": true, "localModeSocketLocation": "/var/tmp/focalboard_local.socket" } ================================================ FILE: docker-testing/docker-compose-mariadb.yml ================================================ version: '2.4' services: mariadb: image: "mariadb:10.9.3" restart: always environment: MARIADB_ROOT_HOST: "%" MARIADB_ROOT_PASSWORD: mostest MARIADB_PASSWORD: mostest MARIADB_USER: mmuser healthcheck: test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "mmuser", "-pmostest"] interval: 5s timeout: 10s retries: 3 tmpfs: /var/lib/mariadb ports: - 44445:3306 start_dependencies: image: mattermost/mattermost-wait-for-dep:latest depends_on: - mariadb command: mariadb:3306 ================================================ FILE: docker-testing/docker-compose-mysql.yml ================================================ version: '2.4' services: mysql: image: "mysql/mysql-server:8.0.32" restart: always environment: MYSQL_ROOT_HOST: "%" MYSQL_ROOT_PASSWORD: mostest MYSQL_PASSWORD: mostest MYSQL_USER: mmuser healthcheck: test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] interval: 5s timeout: 10s retries: 3 tmpfs: /var/lib/mysql ports: - 44446:3306 start_dependencies: image: mattermost/mattermost-wait-for-dep:latest depends_on: - mysql command: mysql ================================================ FILE: docker-testing/docker-compose-postgres.yml ================================================ version: '2.4' services: postgres: image: "postgres:10" restart: always environment: POSTGRES_USER: mmuser POSTGRES_PASSWORD: mostest healthcheck: test: [ "CMD", "pg_isready", "-h", "localhost" ] interval: 5s timeout: 10s retries: 3 tmpfs: /var/lib/postgresql/data ports: - 44447:5432 start_dependencies: image: mattermost/mattermost-wait-for-dep:latest depends_on: - postgres command: postgres:5432 ================================================ FILE: docs/README.md ================================================ # Disclaimer > [!WARNING] > **Effective September 15th, 2023, Mattermost, Inc. staff are no longer reviewing or merging pull requests for either Focalboard or the Mattermost Boards plugin in this repository (`mattermost/focalboard`). We encourage the community to fork this repository for continued development and contributions.** > > The reason behind these changes is to focus Mattermost developer resources on improving the platform’s performance and core features to ensure Mattermost continues being resilient, stable, and best-in-breed for critical operations. > > ️💡 [Learn more](https://forum.mattermost.com/t/upcoming-product-changes-to-boards-and-various-plugins/16669) ================================================ FILE: docs/_config.yml ================================================ title: Focalboard Developers google_analytics: UA-64458817-2 theme: jekyll-theme-architect ================================================ FILE: docs/code-review.md ================================================ # Code Review Checklist Currently, all changes to the product must be reviewed by a [core committer](core-committers.md). ## If you are a community member seeking a review 1. Submit your pull request (PR). * Follow the [contribution checklist](../contribution-checklist/). 2. Wait for a reviewer to be assigned. * Product managers are on the lookout for new pull requests and usually handle this for you automatically. * If you have been working alongside a core committer, feel free to message them for help. * When in doubt, ask for help in the [Focalboard](https://community.mattermost.com/core/channels/focalboard) channel on our community server. * If you are still stuck, please message Chen Lim (@chenilim on GitHub). 3. [Wait for a review](#if-you-are-awaiting-a-review). * Expect some interaction with at least one reviewer within 5 business days (weekdays, Monday through Friday, excluding [statutory holidays](https://docs.mattermost.com/process/working-at-mattermost.html#holidays)). * Keep in mind that core committers are geographically distributed around the world and likely in a different time zone than your own. * If no interaction has occurred after 5 business days, please [at-mention](https://github.blog/2011-03-23-mention-somebody-they-re-notified/) a reviewer with a comment on your pull request. 4. Make any necessary changes. * If a reviewer requests changes, your pull request will disappear from their queue of reviews. * Once you've addressed the concerns, please at-mention the reviewer with a comment on your pull request. 5. Wait for your code to be merged. * Larger pull requests may require more time to review. * Once all reviewers have approved your changes, they will handle merging your code. ## If you are awaiting a review 1. Wait patiently for reviews to complete. * Expect some interaction with each of your reviewers within 5 business days. * There is no need to explicitly mention them on the pull request or to explicitly reach out on our community server. 2. Make any necessary changes. * If a reviewer requests changes, your pull request will disappear from their queue of reviews. * Once you've addressed the concerns, assign them as a reviewer again to put your pull request back in their queue. ## If you are a core committer asked to give a review 1. Respond promptly to requested reviews. * Assume the requested review is urgent and blocking unless explicitly stated otherwise. * Try to interact with the author within 2 business days. * Configure the GitHub plugin to automate notifications. * Review your outstanding requested reviews daily to avoid blocking authors. * Prioritize earlier milestones when reviewing to help with the release process. * Responding quickly doesn't necessarily mean reviewing quickly! Just don't leave the author hanging. 2. Feel free to clarify expectations with the author. * If the code is experimental, they may need only a cursory glance and thumbs up to proceed with productizing their changes. * If the review is large or complex, additional time may be required to complete your review. Be upfront with the author. * If you are not comfortable reviewing the code, avoid "rubber stamping" the review. Be honest with the author and ask them to consider another core committer. 3. Never rush a review. * Take the time necessary to review the code thoroughly. * Don't be afraid to ask for changes repeatedly until all concerns are addressed. * Feel free to challenge assumptions and timelines. Rushing a change into a patch release may cause more harm than good. 4. Avoid leaving a review hanging. * Try to accept or reject the review instead of just leaving comments. 5. Merge the pull request. * Do not merge if there are outstanding changes requested. * Merge the pull request, and delete the branch if not from a fork. ================================================ FILE: docs/contribution-checklist.md ================================================ # Contribution Checklist Thanks for your interest in contributing code! Follow this checklist for submitting a pull request (PR): 1. You've signed the [Contributor License Agreement](http://www.mattermost.org/mattermost-contributor-agreement/), so you can be added to the Mattermost [Approved Contributor List](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true). 2. Your ticket is a Help Wanted GitHub issue for the project you're contributing to. - If not, follow the process [here](contributions-without-ticket.md). 3. Your code is thoroughly tested, including appropriate unit tests, and manual testing. 4. If applicable, user interface strings are included in the localization file ([en.json](https://github.com/mattermost/focalboard/blob/main/webapp/i18n/en.json)) - In the webapp folder, run `npm run i18n-extract` to generate the new/updated strings. 5. The PR is submitted against the `main` branch from your fork. 6. The PR title begins with the GitHub Ticket ID (e.g. `[GH-394]`) and the summary template is filled out. Once submitted, the automated build process must pass in order for the PR to be accepted. Any errors or failures need to be addressed in order for the PR to be accepted. Next, the PR goes through [code review](code-review.md). To learn about the review process for each project, read the [CONTRIBUTING.md](https://github.com/mattermost/focalboard/blob/main/CONTRIBUTING.md) file of that GitHub repository. ================================================ FILE: docs/contributions-without-ticket.md ================================================ # Contributions Without Ticket Contributions for minor corrections and improvements without a corresponding `Help Wanted` ticket are welcome. For example, a pull request for a bug or incremental improvement, with less than 20 lines of code change, is usually accepted if the change to existing behaviour is minor. All pull requests submitted without a corresponding ticket will first be reviewed by a core team product manager. Some examples of minor corrections and improvements include: - [Fix a formatting error in help text](https://github.com/mattermost/mattermost-server/pull/5640) - [Fix success typo in Makefile](https://github.com/mattermost/mattermost-server/pull/5809) - [Fix broken Cancel button in Edit Webhooks screen](https://github.com/mattermost/mattermost-server/pull/5612) - [Fix Android app crashing when saving user notification settings](https://github.com/mattermost/mattermost-mobile/pull/364) - [Fix recent mentions search not working](https://github.com/mattermost/mattermost-server/pull/5878) **Note:** For pull requests greater than 20 lines of code, a `Help Wanted` ticket should be opened by the core team. This helps ensure that everything going into the project aligns with a unified vision. Core committers who review the PR are entitled to reject it if there isn't a `Help Wanted` ticket and feel it significantly changes behavior or user expectations. The best way to discuss opening a `Help Wanted` ticket with the core team is by [starting a conversation in Contributors channel](https://community.mattermost.com/core/channels/focalboard) or [opening an issue in the GitHub repository](https://github.com/mattermost/focalboard/issues/new). ================================================ FILE: docs/core-committers.md ================================================ # Core Committers A core committer is a maintainer on the Focalboard project who has merge access to the repositories. They are responsible for reviewing pull requests, cultivating the developer community, and guiding the technical vision of Focalboard. If you have a question or need some help, these are the people to ask. ## Core Committers Below is the list of core committers working on Focalboard: - **Scott Bishel** - @scott.bishel on [community.mattermost.com](https://community.mattermost.com/core/messages/@scott.bishel) and [@sbishel](https://github.com/sbishel) on GitHub - **Jesús Espino** - @jesus.espino on [community.mattermost.com](https://community.mattermost.com/core/messages/@jesus.espino) and [@jespino](https://github.com/jespino) on GitHub - **Doug Lauder** - @doug.lauder on [community.mattermost.com](https://community.mattermost.com/core/messages/@doug.lauder) and [@wiggin77](https://github.com/wiggin77) on GitHub - **Miguel de la Cruz** - @miguel.delacruz on [community.mattermost.com](https://community.mattermost.com/core/messages/@miguel.delacruz) and [@mgdelacroix](https://github.com/mgdelacroix) on GitHub - **Harshil Sharma** - @harshil.sharma on [community.mattermost.com](https://community.mattermost.com/core/messages/@harshil.sharma) and [@harshilsharma63](https://github.com/harshilsharma63) on GitHub - **Chen Lim** - @chen-i.lim on [community.mattermost.com](https://community.mattermost.com/core/messages/@chen-i.lim) and [@chenilim](https://github.com/chenilim) on GitHub ================================================ FILE: docs/dev-tips.md ================================================ # Developer Tips and Tricks These tips and tricks apply to developing the standalone Personal Server of Focalboard. For most features, this is the easiest way to get started working against code that ships across editions. For working with the Focalboard plugin, refer to the [Focalboard Plugin Developer's Guide](focalboard-dev-guide.md). ## Installation prerequisites Check that you have recent versions of the basic dependencies installed: * [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) * On Windows, install [Git for Windows](https://gitforwindows.org/) and use the git-bash terminal shell * [Go](https://golang.org/doc/install) * [Node](https://nodejs.org/en/download/) (v10+) and [npm](https://www.npmjs.com/get-npm) On Windows: * Install [Mingw64](https://chocolatey.org/packages/mingw) via [Chocolatey](https://chocolatey.org/) On macOS, to build the Mac app: * Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) (v12+) * Install the Xcode commandline tools, via the IDE or run `xcode-select --install` On Linux, to build the Linux app: * `sudo apt-get install libgtk-3-dev` * `sudo apt-get install libwebkit2gtk-4.0-dev` * `sudo apt-get install autoconf dh-autoreconf` ## Fork and clone the project source code Fork the [Focalboard GitHub repo](https://github.com/mattermost/focalboard), and clone it locally. ## Build and run from the terminal Follow the steps in the [main readme file](https://github.com/mattermost/focalboard#building-the-server). In summary, to build and run the server: ``` make prebuild make ./bin/focalboard-server ``` Then open a browser to `http://localhost:8000` to access it. The port is configured in `config.json`. Once the server is running, you can rebuild just the webapp with `make webapp` (in a separate terminal window), then reload the browser. ## VSCode setup Here's a recommended dev-test loop using VSCode: * Open a bash terminal window to the project folder * Run `make prebuild` to npm install * Do this again when dependencies change in `webapp/package.json` * Run `cd webapp && npm run watchdev` * This will auto-build the webapp on file changes * Open VSCode * Install the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go) and [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) VSCode extensions if you haven't already * Hit F5 and select `Go: Launch Server` * Or, press `Cmd+P` and type `debug ` and pick the option * Open a browser to `http://localhost:8000` * The port is configured in `config.json` You can now edit the webapp code and refresh the browser to see your changes. ## Debugging the web app You can use your favorite browser to debug the webapp code. With Chrome, open the dev tools with `Cmd+Alt+I` (`Ctrl+Alt+I` in Windows). * `npm run watchdev` builds the dev package, which includes source maps from js to typescript * In the Chrome devtools source tab, press `Cmd+P` to jump to a source file As a starting point, add a breakpoint to the `render()` function in `BoardPage.tsx`, then refresh the browser to walk through page rendering. ## Debugging the server Debug the Go code in VSCode. This is set up automatically when you launch the server from there. To start, add a breakpoint to `handleGetBlocks()` in `server/api/api.go`, then refresh the browser to see how data is retrieved. ## Localization/Internationalization/Translation We use `i18n` to localize the web app. Localized string generally use `intl.formatMessage`. When adding or modifying localized strings, run `npm run i18n-extract` in `webapp` to rebuild `webapp/i18n/en.json`. Translated strings are stored in other json files under `webapp/i18n`, e.g. `es.json` for Spanish. ## Database By default, data is stored in a sqlite database `focalboard.db`. You can view and edit this directly using `sqlite3 focalboard.db` from bash. ## Unit tests Before checking-in commits, run: `make ci`, which is simlar to the ci.yml workflow and includes: * Server unit tests: `make server-test` * Webapp eslint: `cd webapp; npm run check` * Webapp unit tests: `make webapp-test` * Webapp UI tests: `cd webapp; npm run cypress:ci` ## Running into problems or have questions? If you run into any issues with the steps here, or have any general questions, please don't hesitate to reach out either on [GitHub](https://github.com/mattermost/focalboard) or our [Mattermost community channel](https://community.mattermost.com/core/channels/focalboard). We welcome everyone, and appreciate any feedback. glhf! :) ================================================ FILE: docs/focalboard-dev-guide.md ================================================ # Focalboard Plugin Developer's Guide **Important**: Effective September 15th, 2023, Mattermost Boards transitions to being fully community supported as the Focalboard Plugin. Mattermost will no longer be maintaining this plugin - this includes bug fixes and feature additions. Instead, the plugin is open-sourced and made available indefinitely for community contributions in GitHub. To build your own version of it: 1. Build [mattermost-plugin](https://github.com/mattermost/focalboard/tree/main/mattermost-plugin) in the [Focalboard repo](https://github.com/mattermost/focalboard) 2. Upload it as a [custom plugin to your Mattermost server](https://developers.mattermost.com/integrate/admin-guide/admin-plugins-beta/#custom-plugins) Here are the steps in more detail: ### Building the Focalboard plugin Fork the [Focalboard repo](https://github.com/mattermost/focalboard), clone it locally, and follow the steps in the readme to set up your dev environment. Install dependencies: ``` # First-time setup dependencies cd mattermost-plugin/webapp npm install --no-optional cd ../.. make prebuild ``` Build the plugin: ``` # Build webapp make webapp # Build plugin cd mattermost-plugin make dist ``` Refer to the [dev-release.yml](https://github.com/mattermost/focalboard/blob/main/.github/workflows/dev-release.yml#L168) workflow for the up-to-date commands that are run as part of CI. ### Uploading the plugin to your server You can manually upload the plugin to your Mattermost Server: 1. Enable [custom plugins](https://developers.mattermost.com/integrate/admin-guide/admin-plugins-beta/#custom-plugins) by setting `PluginSettings > EnableUploads` to `true` in the Mattermost `config.json` file 2. Navigate to **System Console > Plugins > Management** and upload the generated `.tar.gz` package in your `mattermost-plugin/dist` directory 3. Enable it (if needed) Alternatively, you can install Mattermost locally and use `make deploy` to auto-deploy it for you: First, build and run Mattermost locally: 1. Follow the [Mattermost Developers Guide](https://developers.mattermost.com/contribute/server/developer-setup/) to set up your environment * In particuler, make sure Docker is set up and running 2. Fork [mattermost-webapp](https://github.com/mattermost/mattermost-webapp), clone it locally, and `make build` 3. Fork [mattermost-server](https://github.com/mattermost/mattermost-server) and clone it locally 3. Run `make config-reset` to generate the `config/config.json` file 4. Edit `config/config.json`: * Set `ServiceSettings > SiteURL` to `http://localhost:8065` ([docs](https://docs.mattermost.com/configure/configuration-settings.html#site-url)) * Set `ServiceSettings > EnableLocalMode` to `true` ([docs](https://docs.mattermost.com/configure/configuration-settings.html#enable-local-mode)) * Set `PluginSettings > EnableUploads` to `true` ([docs](https://developers.mattermost.com/integrate/admin-guide/admin-plugins-beta/#custom-plugins)) 5. Add an ENV var `MM_SERVICESETTINGS_SITEURL` with the same site URL used in the config 6. Run `make run-server` in Mattermost Now, to build and deploy the plugin: 1. Clone / fork [mattermost/focalboard](https://github.com/mattermost/focalboard) 2. Install the dependencies (see above) 3. Run: ``` make webapp cd mattermost-plugin make deploy ``` ================================================ FILE: docs/index.md ================================================ # Focalboard Plugin Documentation Welcome to the Focalboard plugin project! We're very glad you want to check it out and perhaps contribute code to this project in GitHub. ## Install the plugin Visit the [Mattermost Developer Documentation](https://developers.mattermost.com/integrate/plugins/using-and-managing-plugins/#custom-plugins) for details on how to install and enable the Focalboard plugin in your self-hosted Mattermost instance. ## Enable the plugin Once you've installed the Focalboard plugin, you can enable the plugin in the Mattermost System Console by going to **Plugins > Plugin Management**, and selecting the **Enable** option for the Focalboard plugin. ## Contribute to the Focalboard plugin project Follow these simple steps to contribute: 1. [Fork the Focalboard repo](https://github.com/mattermost/focalboard), clone it locally, and follow the steps in the README to build. Read the [Focalboard Developer's Guide](focalboard-dev-guide.md) and the [developer tips & tricks](dev-tips.md) documentation to get started. 2. Find [help wanted tickets that are up for grabs in GitHub](https://github.com/mattermost/focalboard/issues?q=is%3Aopen+is%3Aissue+label%3A%22Up+for+grabs%22). Comment to let everyone know you’re working on it, and to allow a core contributor to assign the issue to you. If there’s no ticket for what you want to work on see [contributions without a ticket](contributions-without-ticket.md). 3. When your changes are ready, run through our [checklist for pull requests](contribution-checklist.md). Note that if it’s your first contribution, there is a standard [CLA](https://www.mattermost.org/mattermost-contributor-agreement/) to sign. ## Just ask if you need help! You can find us on our [public Focalboard channel](https://community.mattermost.com/core/channels/focalboard) on the Mattermost community server. Also feel free to [file a bug](https://github.com/mattermost/focalboard/issues/new/choose) for any issues you run into, or [start a discussion](https://github.com/mattermost/focalboard/discussions). We're glad ❤️ you're here! Good luck and have fun! ================================================ FILE: experiments/webext/.gitignore ================================================ .parcel-cache web-ext-artifacts ================================================ FILE: experiments/webext/.parcelrc ================================================ { "extends": "@parcel/config-webextension" } ================================================ FILE: experiments/webext/README.md ================================================ # Focalboard Web Clipper Browser Extension ✂️ This is the Focalboard Web Clipper browser extension. It aims at supporting various use cases around converting web content from your browser directly into Focalboard cards. ⚠️ **Warning:** The extension is currently in an early and experimental state. Use it at your own risk only. Don't expect any eye candy. ## Status The extension currently is in a proof-of-concept state with minimal functionality. The only supported use case at the time is building a read-later list. Things that work: - Logging in to the Focalboard server from the extension settings - Selecting a board to capture cards into from the extension settings - Saving websites (title & URL) into cards from a page action (like e.g. Pocket does it) Only Firefox was tested so far but polyfills have already been enabled so there's a good chance that it'll work in Chrome and maybe even Safari, too. ### Next Steps We're really at the very beginning here so there's a lot to be done. Notable tasks include: - Improve the React code by extracting components - Style the options and popup pages to mimic the look and feel of Focalboard - Replace the logo with something better (the current one was snatched from the Focalboard Windows app) - Link to the extension's options page from page action error messages - Clip parts of a website into image attachments on cards - Extract website content in reader mode into card descriptions - Optimise the logic for finding the first URL property (currently the whole board subtree has to be requested because there is no other API available) - Add some tests - Test the extension on Chrome / Safari and add infrastructure to facilitate this in future (e.g. `.web-ext-config.js`) - Add an onboarding (displayed after first install) and upboarding (displayed after update) page - Distribute the extension via the various browser add-on stores (ok, maybe too early 😜) ## Hacking First, install dependencies with ``` $ npm i ``` You can then compile and bundle the code with ``` $ npm run watchdev ``` This will write output into `dist/dev/` and automatically recompile and bundle on any source change. To run the extension in a separate Firefox instance, use ``` $ npm run servedev ``` Note that in the above commands you can substitue `dev` with `prod` to build and run the extension with production settings. ## Distribution To build a distributable ZIP archive, run ``` $ npm run build ``` The archive will be placed into the `web-ext-artifacts` folder. ================================================ FILE: experiments/webext/manifest.json ================================================ { "manifest_version": 2, "name": "Focalboard Web Clipper", "version": "0.1.0", "description": "Save websites directly into Focalboard", "icons": { "48": "icons/48.png", "96": "icons/96.png" }, "page_action": { "browser_style": true, "default_icon": { "19": "icons/19.png", "38": "icons/38.png" }, "default_title": "Save to Focalboard", "default_popup": "src/views/popup.html", "show_matches": [""] }, "options_ui": { "page": "src/views/options.html", "browser_style": true }, "web_accessible_resources": [], "permissions": [ "", "storage" ], "browser_specific_settings": { "gecko": { "id": "focalboard-web-clipper@mattermost.com" } } } ================================================ FILE: experiments/webext/package.json ================================================ { "name": "focalboard-web-clipper", "version": "0.0.0", "targets": { "dev": { "sourceMap": { "inline": true, "inlineSources": true } }, "prod": {} }, "scripts": { "watchdev": "parcel watch manifest.json --target dev", "servedev": "web-ext run -s dist/dev/", "watchprod": "parcel watch manifest.json --target prod", "serveprod": "web-ext run -s dist/prod/", "build": "parcel build manifest.json --target prod && web-ext build -s dist/prod/" }, "devDependencies": { "@parcel/config-webextension": "^2.0.0", "@parcel/transformer-sass": "^2.0.0", "@types/react": "^17.0.32", "@types/react-dom": "^17.0.10", "parcel": "^2.0.0", "react": "^17.0.2", "react-dom": "^17.0.2", "typescript": "^4.4.4", "web-ext": "^6.4.0", "webextension-polyfill-ts": "^0.26.0" } } ================================================ FILE: experiments/webext/src/utils/Board.ts ================================================ interface BoardFields { isTemplate: boolean } export default interface Board { id: string title: string fields: BoardFields } ================================================ FILE: experiments/webext/src/utils/networking.ts ================================================ // Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Board from "../utils/Board" declare global { interface Window { msCrypto: Crypto } } async function request(method: string, host: string, resource: string, body: any, token: string | null) { const response = await fetch(`${host}/api/v2/${resource}`, { 'credentials': 'include', 'headers': { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Authorization': token ? `Bearer ${token}` : null } as HeadersInit, 'body': body ? JSON.stringify(body) : null, 'method': method }) const json = await response.json() if (json.error) { throw json.error } return json } export async function logIn(host: string, username: string, password: string) { const json = await request('POST', host, 'login', { username: username, password: password, type: 'normal' }, null) return json.token } export async function getBoards(host: string, token: string) { const json = await request('GET', host, 'workspaces/0/blocks?type=board', null, token) as Board[] return json.filter(board => !board.isTemplate) } export async function findUrlPropertyId(host: string, token: string, boardId: string) { const json = await request('GET', host, `workspaces/0/blocks/${boardId}/subtree`, null, token) for (let obj of json) { if (obj.type === 'board') { for (let property of obj.fields.cardProperties) { if (property.type === 'url') { return property.id } } break // Only one board in subtree, no need to continue } } return null } export async function createCard(host: string, token: string, boardId: string, urlPropertyId: string, title: string, url: string) { let properties = {} as any if (urlPropertyId) { properties[urlPropertyId] = url } const card = { id: createGuid(), schema: 1, workspaceId: '', parentId: boardId, rootId: boardId, createdBy: '', modifiedBy: '', type: 'card', fields: { icon: null, properties: properties, contentOrder: [], isTemplate: false }, title: title, createAt: Date.now(), updateAt: Date.now(), deleteAt: 0 } await request('POST', host, 'workspaces/0/blocks', [card], token) } function createGuid(): string { const data = randomArray(16) return '7' + base32encode(data, false) } function randomArray(size: number): Uint8Array { const crypto = window.crypto || window.msCrypto const rands = new Uint8Array(size) if (crypto && crypto.getRandomValues) { crypto.getRandomValues(rands) } else { for (let i = 0; i < size; i++) { rands[i] = Math.floor((Math.random() * 255)) } } return rands } const base32Alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769' function base32encode(data: Int8Array | Uint8Array | Uint8ClampedArray, pad: boolean): string { const dview = new DataView(data.buffer, data.byteOffset, data.byteLength) let bits = 0 let value = 0 let output = '' // adapted from https://github.com/LinusU/base32-encode for (let i = 0; i < dview.byteLength; i++) { value = (value << 8) | dview.getUint8(i) bits += 8 while (bits >= 5) { output += base32Alphabet[(value >>> (bits - 5)) & 31] bits -= 5 } } if (bits > 0) { output += base32Alphabet[(value << (5 - bits)) & 31] } if (pad) { while ((output.length % 8) !== 0) { output += '=' } } return output } ================================================ FILE: experiments/webext/src/utils/settings.ts ================================================ // Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import browser from 'webextension-polyfill' interface Settings { host: string | null username: string | null token: string | null boardId: string | null } export function loadSettings(): Settings { return browser.storage.sync.get(['host', 'username', 'token', 'boardId']) } export function storeSettings(host: string, username: string, token: string | null, boardId: string | null) { console.log(`storing host ${host}`) return browser.storage.sync.set({ host: host, username: username, token: token, boardId: boardId }) } export function storeToken(value: string | null) { return browser.storage.sync.set({ token: value }) } export function storeBoardId(value: string | null) { return browser.storage.sync.set({ boardId: value }) } ================================================ FILE: experiments/webext/src/views/OptionsApp.scss ================================================ /* Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. */ /* See LICENSE.txt for license information. */ .OptionsApp { label { display: block; font-size: 90%; font-style: italic; } input[type=text], input[type=password], select { width: 20em; max-width: 100%; margin-bottom: 1em; } input[type=submit] { display: block; } .status { margin: 1em 0 1em 0; .in-progress { background-color: grey; padding: 0.5em; } .success { background-color: lightgreen; padding: 0.5em; } .error { background-color: lightpink; padding: 0.5em; } } } ================================================ FILE: experiments/webext/src/views/OptionsApp.tsx ================================================ // Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, { ChangeEvent, MouseEvent, useEffect, useState } from "react" import Board from "../utils/Board" import { getBoards, logIn } from "../utils/networking"; import { loadSettings, storeSettings, storeToken, storeBoardId } from "../utils/settings"; import "./OptionsApp.scss" export default function OptionsApp() { const [host, setHost] = useState('') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [token, setToken] = useState('') const [boards, setBoards] = useState([] as Board[]) const [boardId, setBoardId] = useState(null as string | null) const [inProgress, setInProgress] = useState(false) const [error, setError] = useState(null as string | null) useEffect(() => { async function initialiseBoards() { const settings = await loadSettings() if (settings.host) { setHost(settings.host) } if (settings.username) { setUsername(settings.username) } if (settings.token) { setToken(settings.token) } if (settings.boardId) { setBoardId(settings.boardId) } if (!settings.host || !settings.username || !settings.token) { setError('Unauthenticated') return } setInProgress(true) try { setBoards(await getBoards(settings.host, settings.token)) } catch (error) { setError(`${error}`) } finally { setInProgress(false) } } initialiseBoards(); }, []) function onAuthenticateButtonClicked(event: MouseEvent) { authenticate(host, username, password) event.preventDefault() event.stopPropagation() } async function authenticate(host: string, username: string, password: string) { storeSettings(host, username, null, null) setBoards([]) setBoardId(null) setInProgress(true) setError(null) try { const token = await logIn(host, username, password) storeToken(token) setToken(token) setBoards(await getBoards(host, token)) const select = document.querySelector('select') as any select.value = null } catch (error) { setError(`${error}`) } finally { setInProgress(false) } } function onBoardSelectionChanged(event: ChangeEvent) { const id = (event.target as HTMLSelectElement).value storeBoardId(id) setBoardId(id) event.preventDefault() event.stopPropagation() } return
setHost(e.target.value)}/> setUsername(e.target.value)}/> setPassword(e.target.value)}/>
{inProgress &&
Connecting to Focalboard server...
} {!inProgress && !error &&
Token: {token}
} {!inProgress && error &&
{error}
}


} ================================================ FILE: experiments/webext/src/views/PopupApp.scss ================================================ /* Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. */ /* See LICENSE.txt for license information. */ .PopupApp { .status { .in-progress { background-color: grey; padding: 1em; } .success { background-color: lightgreen; padding: 1em; } .error { background-color: lightpink; padding: 1em; } } } ================================================ FILE: experiments/webext/src/views/PopupApp.tsx ================================================ // Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, { useEffect, useState } from "react" import browser from 'webextension-polyfill' import { createCard, findUrlPropertyId } from "../utils/networking"; import { loadSettings } from "../utils/settings"; import "./PopupApp.scss" export default function OptionsApp() { const [board, setBoard] = useState('') const [inProgress, setInProgress] = useState(false) const [error, setError] = useState(null as string | null) useEffect(() => { async function createCardFromCurrentTab() { const settings = await loadSettings() if (!settings.host || !settings.token) { setError('Looks like you\'re unauthenticated. Please configure the extension\'s settings first.') return } if (!settings.boardId) { setError('Looks like you haven\'t selected a board to save to yet. Please configure the extension\'s settings first.') return } setInProgress(true) try { const tabs = await browser.tabs.query({ active: true, currentWindow: true }) const urlPropertyId = await findUrlPropertyId(settings.host as string, settings.token as string, settings.boardId as string) await createCard(settings.host as string, settings.token as string, settings.boardId as string, urlPropertyId, tabs[0].title, tabs[0].url) setBoard(`${settings.host}/${settings.boardId}`) } catch (error) { setError(`${error}`) } finally { setInProgress(false) } } createCardFromCurrentTab(); }, []) return
{inProgress &&
Saving to Focalboard...
} {!inProgress && !error &&
Saved to board
} {!inProgress && error &&
{error}
}
} ================================================ FILE: experiments/webext/src/views/options.html ================================================
================================================ FILE: experiments/webext/src/views/options.tsx ================================================ // Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React from "react" import ReactDOM from "react-dom" import OptionsApp from "./OptionsApp" const app = document.getElementById("app") ReactDOM.render(, app) ================================================ FILE: experiments/webext/src/views/popup.html ================================================
================================================ FILE: experiments/webext/src/views/popup.tsx ================================================ // Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React from "react" import ReactDOM from "react-dom" import PopupApp from "./PopupApp" const app = document.getElementById("app") ReactDOM.render(, app) ================================================ FILE: experiments/webext/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, "resolveJsonModule": true, "incremental": false, "outDir": "./dist", "moduleResolution": "node" }, "include": [ "." ], "exclude": [ ".git", "**/node_modules/*", "dist", "pack" ] } ================================================ FILE: import/README.md ================================================ # Import scripts This subfolder contains scripts to import data from other systems. It is at an early stage. At present, there are examples of basic importing from the following: * Trello * Asana * Notion * Jira * Todoist * Nextcloud Deck [Contribute code](https://mattermost.github.io/focalboard/) to expand this. ================================================ FILE: import/asana/.eslintrc.json ================================================ { "extends": [ ], "plugins": [ ], "parser": "@typescript-eslint/parser", "env": { "jest": true }, "settings": { "import/resolver": "webpack", "react": { "pragma": "React", "version": "detect" } }, "rules": { "no-unused-expressions": 0, "eol-last": ["error", "always"], "import/no-unresolved": 2, "no-undefined": 0, "react/jsx-filename-extension": 0, "max-nested-callbacks": ["error", {"max": 5}] }, "overrides": [ { "files": ["**/*.tsx", "**/*.ts"], "extends": [ "plugin:@typescript-eslint/recommended" ], "rules": { "import/no-unresolved": 0, // ts handles this better "camelcase": 0, "semi": "off", "@typescript-eslint/naming-convention": [ 2, { "selector": "function", "format": ["camelCase", "PascalCase"] }, { "selector": "variable", "format": ["camelCase", "PascalCase", "UPPER_CASE"] }, { "selector": "parameter", "format": ["camelCase", "PascalCase"], "leadingUnderscore": "allow" }, { "selector": "typeLike", "format": ["PascalCase"] } ], "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-unused-vars": [ 2, { "vars": "all", "args": "after-used" } ], "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/prefer-interface": 0, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/semi": [2, "never"], "@typescript-eslint/indent": [ 2, 4, { "SwitchCase": 0 } ], "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": [ 2, { "classes": false, "functions": false, "variables": false } ], "no-useless-constructor": 0, "@typescript-eslint/no-useless-constructor": 2, "react/jsx-filename-extension": 0 } }, { "files": ["tests/**", "**/*.test.*"], "env": { "jest": true }, "rules": { "func-names": 0, "global-require": 0, "new-cap": 0, "prefer-arrow-callback": 0, "no-import-assign": 0 } } ] } ================================================ FILE: import/asana/.gitignore ================================================ test ================================================ FILE: import/asana/README.md ================================================ # Asana importer This node app converts an Asana json archive into a Focalboard archive. To use: 1. From the Asana Board Menu (dropdown next to board title), select `Export / Print`, and `JSON` 2. Save it locally, e.g. to `asana.json` 3. Run `npm install` from within `focalboard/webapp` 4. Run `npm install` from within `focalboard/import/asana` 5. Run `npx ts-node importAsana.ts -i -o archive.boardarchive` 6. In Focalboard, click `Settings`, then `Import archive` and select `archive.boardarchive` ## Import scope Currently, the script imports all cards from a single board, including their section (column) membership, names, and notes. [Contribute code](https://mattermost.github.io/focalboard/) to expand this. ================================================ FILE: import/asana/asana.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. // Generated by https://quicktype.io // // To change quicktype's target language, run command: // // "Set quicktype target language" export interface Asana { data: Datum[]; } export interface Datum { gid: string; assignee: null; assignee_status: AssigneeStatus; completed: boolean; completed_at: null; created_at: string; custom_fields: CustomField[]; due_at: null; due_on: null; followers: Workspace[]; hearted: boolean; hearts: any[]; liked: boolean; likes: any[]; memberships: Membership[]; modified_at: string; name: string; notes: string; num_hearts: number; num_likes: number; parent: Workspace | null; permalink_url: string; projects: Workspace[]; resource_type: WorkspaceResourceType; start_on: null; subtasks: Datum[]; tags: any[]; resource_subtype: ResourceSubtype; workspace: Workspace; } export enum AssigneeStatus { Upcoming = "upcoming", } export interface CustomField { gid: string; enabled: boolean; enum_options: Enum[]; enum_value: Enum | null; name: CustomFieldName; created_by: null; resource_subtype: Type; resource_type: CustomFieldResourceType; type: Type; } export interface Enum { gid: string; color: Color; enabled: boolean; name: EnumOptionName; resource_type: EnumOptionResourceType; } export enum Color { Blue = "blue", BlueGreen = "blue-green", CoolGray = "cool-gray", Orange = "orange", Red = "red", Yellow = "yellow", YellowOrange = "yellow-orange", } export enum EnumOptionName { Deferred = "Deferred", Done = "Done", High = "High", InProgress = "In Progress", Low = "Low", Medium = "Medium", NotStarted = "Not Started", Waiting = "Waiting", } export enum EnumOptionResourceType { EnumOption = "enum_option", } export enum CustomFieldName { Priority = "Priority", TaskProgress = "Task Progress", } export enum Type { Enum = "enum", } export enum CustomFieldResourceType { CustomField = "custom_field", } export interface Workspace { gid: string; name: string; resource_type: WorkspaceResourceType; } export enum WorkspaceResourceType { Project = "project", Section = "section", Task = "task", User = "user", Workspace = "workspace", } export interface Membership { project: Workspace; section: Workspace; } export enum ResourceSubtype { DefaultTask = "default_task", } ================================================ FILE: import/asana/importAsana.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as fs from 'fs' import minimist from 'minimist' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' import {createTextBlock} from '../../webapp/src/blocks/textBlock' import {Asana, Workspace} from './asana' import {Utils} from './utils' // HACKHACK: To allow Utils.CreateGuid to work (global.window as any) = {} const optionColors = [ // 'propColorDefault', 'propColorGray', 'propColorBrown', 'propColorOrange', 'propColorYellow', 'propColorGreen', 'propColorBlue', 'propColorPurple', 'propColorPink', 'propColorRed', ] let optionColorIndex = 0 function main() { const args: minimist.ParsedArgs = minimist(process.argv.slice(2)) const inputFile = args['i'] const outputFile = args['o'] || 'archive.boardarchive' if (!inputFile) { showHelp() } if (!fs.existsSync(inputFile)) { console.error(`File not found: ${inputFile}`) exit(2) } // Read input const inputData = fs.readFileSync(inputFile, 'utf-8') const input = JSON.parse(inputData) as Asana // Convert const [boards, blocks] = convert(input) // Save output // TODO: Stream output const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) } function getProjects(input: Asana): Workspace[] { const projectMap = new Map() input.data.forEach(datum => { datum.projects.forEach(project => { if (!projectMap.get(project.gid)) { projectMap.set(project.gid, project) } }) }) return [...projectMap.values()] } function getSections(input: Asana, projectId: string): Workspace[] { const sectionMap = new Map() input.data.forEach(datum => { const membership = datum.memberships.find(o => o.project.gid === projectId) if (membership) { if (!sectionMap.get(membership.section.gid)) { sectionMap.set(membership.section.gid, membership.section) } } }) return [...sectionMap.values()] } function convert(input: Asana): [Board[], Block[]] { const projects = getProjects(input) if (projects.length < 1) { console.error('No projects found') return [[],[]] } // TODO: Handle multiple projects const project = projects[0] const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${project.name}`) board.title = project.name // Convert sections (columns) to a Select property const optionIdMap = new Map() const options: IPropertyOption[] = [] const sections = getSections(input, project.gid) sections.forEach(section => { const optionId = Utils.createGuid() optionIdMap.set(section.gid, optionId) const color = optionColors[optionColorIndex % optionColors.length] optionColorIndex += 1 const option: IPropertyOption = { id: optionId, value: section.name, color, } options.push(option) }) const cardProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'Section', type: 'select', options } board.cardProperties = [cardProperty] boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' view.parentId = board.id view.boardId = board.id blocks.push(view) // Cards input.data.forEach(card => { console.log(`Card: ${card.name}`) const outCard = createCard() outCard.title = card.name outCard.boardId = board.id outCard.parentId = board.id // Map lists to Select property options const membership = card.memberships.find(o => o.project.gid === project.gid) if (membership) { const optionId = optionIdMap.get(membership.section.gid) if (optionId) { outCard.fields.properties[cardProperty.id] = optionId } else { console.warn(`Invalid idList: ${membership.section.gid} for card: ${card.name}`) } } else { console.warn(`Missing idList for card: ${card.name}`) } blocks.push(outCard) if (card.notes) { // console.log(`\t${card.notes}`) const text = createTextBlock() text.title = card.notes text.parentId = outCard.id text.boardId = board.id blocks.push(text) outCard.fields.contentOrder = [text.id] } }) console.log('') console.log(`Found ${input.data.length} card(s).`) return [boards, blocks] } function showHelp() { console.log('import -i -o [output.boardarchive]') exit(1) } main() ================================================ FILE: import/asana/package.json ================================================ { "name": "focalboard-asana-importer", "version": "1.0.0", "private": true, "description": "", "main": "importAsana.js", "scripts": { "lint": "eslint --ext .tsx,.ts . --quiet --cache", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache", "test": "ts-node importAsana.ts -i test/asana.json -o test/asana-import.focalboard", "debug:test": "node --inspect=5858 -r ts-node/register importAsana.ts -i test/asana.json -o test/asana-import.focalboard" }, "keywords": [], "author": "", "devDependencies": { "@types/minimist": "^1.2.1", "@types/node": "^14.14.28", "@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/parser": "^4.15.0", "eslint": "^7.20.0", "ts-node": "^9.1.1", "typescript": "^4.1.5" }, "dependencies": { "minimist": "^1.2.6" } } ================================================ FILE: import/asana/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, "resolveJsonModule": true, "incremental": false, "outDir": "./dist", "moduleResolution": "node" }, "include": [ "." ], "exclude": [ ".git", "**/node_modules/*", "dist", "pack" ] } ================================================ FILE: import/asana/utils.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as crypto from 'crypto' class Utils { static createGuid(): string { function randomDigit() { if (crypto && crypto.randomBytes) { const rands = crypto.randomBytes(1) return (rands[0] % 16).toString(16) } return (Math.floor((Math.random() * 16))).toString(16) } return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit) } } export { Utils } ================================================ FILE: import/jira/.gitignore ================================================ test ================================================ FILE: import/jira/README.md ================================================ # Jira importer This node app converts a Jira xml export into a Focalboard archive. To use: 1. Open Jira advanced search, and search for all the items to export 2. Select `Export`, then `Export XML` 3. Save it locally, e.g. to `jira_export.xml` 4. Run `npm install` from within `focalboard/webapp` 5. Run `npm install` from within `focalboard/import/jira` 6. Run `npx ts-node importJira.ts -i -o archive.boardarchive` (also from within `focalboard/import/jira`) 7. In Focalboard, click `Settings`, then `Import archive` and select `archive.boardarchive` ## Import scope and known limitations Currently, the script imports each item as a card into a single board. Note that Jira XML export is limited to 1000 issues at a time. Users are imported as Select properties, with the name of the user. The following aren't currently imported: * Custom properties * Comments * Embedded files [Contribute code](https://mattermost.github.io/focalboard/) to expand this. ================================================ FILE: import/jira/importJira.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import minimist from 'minimist' import {run} from './jiraImporter' async function main() { const args: minimist.ParsedArgs = minimist(process.argv.slice(2)) const inputFile = args['i'] const outputFile = args['o'] || 'archive.boardarchive' return run(inputFile, outputFile) } main() ================================================ FILE: import/jira/jiraImporter.test.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {run} from './jiraImporter' import * as fs from 'fs' import {ArchiveUtils} from '../util/archive' const inputFile = './test/jira-export.xml' const outputFile = './test/jira.focalboard' describe('import from Jira', () => { test('import', async () => { const blockCount = await run(inputFile, outputFile) expect(blockCount === 4) }) test('import was complete', async () => { const archiveData = fs.readFileSync(outputFile, 'utf-8') const blocks = ArchiveUtils.parseBlockArchive(archiveData) console.debug(blocks) blocks.forEach(block => { console.log(block.title) }) expect(blocks).toEqual( expect.arrayContaining([ expect.objectContaining({ title: 'Board View', type: 'view' }), expect.objectContaining({ title: 'Investigate feature area', type: 'card' }), expect.objectContaining({ title: 'Investigate feature', type: 'card' }), ]) ) }) afterAll(() => { fs.rmSync(outputFile) }); }) ================================================ FILE: import/jira/jiraImporter.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as fs from 'fs' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {Card, createCard} from '../../webapp/src/blocks/card' import {createTextBlock} from '../../webapp/src/blocks/textBlock' import {Utils} from './utils' import xml2js, {ParserOptions} from 'xml2js' import TurndownService from 'turndown' // HACKHACK: To allow Utils.CreateGuid to work (global.window as any) = {} const optionColors = [ 'propColorGray', 'propColorBrown', 'propColorOrange', 'propColorYellow', 'propColorGreen', 'propColorBlue', 'propColorPurple', 'propColorPink', 'propColorRed', ] let optionColorIndex = 0 var turndownService = new TurndownService() async function run(inputFile: string, outputFile: string): Promise { console.log(`input: ${inputFile}`) console.log(`output: ${outputFile}`) if (!inputFile) { showHelp() } if (!fs.existsSync(inputFile)) { console.error(`File not found: ${inputFile}`) exit(2) } // Read input console.log(`Reading ${inputFile}`) const inputData = fs.readFileSync(inputFile, 'utf-8') if (!inputData) { console.error(`Unable to read data from file: ${inputFile}`) exit(2) } console.log(`Read ${Math.round(inputData.length / 1024)} KB`) const parserOptions: ParserOptions = { explicitArray: false } const parser = new xml2js.Parser(parserOptions); const input = await parser.parseStringPromise(inputData) if (!input?.rss?.channel) { console.error(`No channels in xml: ${inputFile}`) exit(2) } const channel = input.rss.channel const items = channel.item // console.dir(items); // Convert const [boards, blocks] = convert(items) // Save output // TODO: Stream output const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported ${blocks.length} block(s) to ${outputFile}`) return blocks.length } function convert(items: any[]): [Board[], Block[]] { const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() board.title = 'Jira import' // Compile standard properties board.cardProperties = [] const priorityProperty = buildCardPropertyFromValues('Priority', items.map(o => o.priority?._)) board.cardProperties.push(priorityProperty) const statusProperty = buildCardPropertyFromValues('Status', items.map(o => o.status?._)) board.cardProperties.push(statusProperty) const resolutionProperty = buildCardPropertyFromValues('Resolution', items.map(o => o.resolution?._)) board.cardProperties.push(resolutionProperty) const typeProperty = buildCardPropertyFromValues('Type', items.map(o => o.type?._)) board.cardProperties.push(typeProperty) const assigneeProperty = buildCardPropertyFromValues('Assignee', items.map(o => o.assignee?._)) board.cardProperties.push(assigneeProperty) const reporterProperty = buildCardPropertyFromValues('Reporter', items.map(o => o.reporter?._)) board.cardProperties.push(reporterProperty) const originalUrlProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'Original URL', type: 'url', options: [] } board.cardProperties.push(originalUrlProperty) const createdDateProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'Created Date', type: 'date', options: [] } board.cardProperties.push(createdDateProperty) boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' view.boardId = board.id view.parentId = board.id blocks.push(view) for (const item of items) { console.log( `Item: ${item.summary}, ` + `priority: ${item.priority?._}, ` + `status: ${item.status?._}, ` + `type: ${item.type?._}`) const card = createCard() card.title = item.summary card.boardId = board.id card.parentId = board.id // Map standard properties if (item.priority?._) { setSelectProperty(card, priorityProperty, item.priority._) } if (item.status?._) { setSelectProperty(card, statusProperty, item.status._) } if (item.resolution?._) { setSelectProperty(card, resolutionProperty, item.resolution._) } if (item.type?._) { setSelectProperty(card, typeProperty, item.type._) } if (item.assignee?._) { setSelectProperty(card, assigneeProperty, item.assignee._) } if (item.reporter?._) { setSelectProperty(card, reporterProperty, item.reporter._) } if (item.link) { setProperty(card, originalUrlProperty.id, item.link)} if (item.created) { const dateInMs = Date.parse(item.created) setProperty(card, createdDateProperty.id, dateInMs.toString()) } // TODO: Map custom properties if (item.description) { const description = turndownService.turndown(item.description) console.log(`\t${description}`) const text = createTextBlock() text.title = description text.boardId = board.id text.parentId = card.id blocks.push(text) card.fields.contentOrder = [text.id] } blocks.push(card) } return [boards, blocks] } function buildCardPropertyFromValues(propertyName: string, allValues: string[]) { const options: IPropertyOption[] = [] // Remove empty and duplicate values const values = allValues. filter(o => !!o). filter((x, y) => allValues.indexOf(x) == y); for (const value of values) { const optionId = Utils.createGuid() const color = optionColors[optionColorIndex % optionColors.length] optionColorIndex += 1 const option: IPropertyOption = { id: optionId, value, color, } options.push(option) } const cardProperty: IPropertyTemplate = { id: Utils.createGuid(), name: propertyName, type: 'select', options } console.log(`Property: ${propertyName}, values: ${values}`) return cardProperty } function setSelectProperty(card: Card, cardProperty: IPropertyTemplate, propertyValue: string) { const option = optionForPropertyValue(cardProperty, propertyValue) if (option) { card.fields.properties[cardProperty.id] = option.id } } function setProperty(card: Card, cardPropertyId: string, propertyValue: string) { card.fields.properties[cardPropertyId] = propertyValue } function optionForPropertyValue(cardProperty: IPropertyTemplate, propertyValue: string): IPropertyOption | null { const option = cardProperty.options.find(o => o.value === propertyValue) if (!option) { console.error(`Property value not found: ${propertyValue}`) return null } return option } function showHelp() { console.log('import -i -o [output.boardarchive]') exit(1) } export { run } ================================================ FILE: import/jira/package.json ================================================ { "name": "focalboard-jira-importer", "version": "1.0.0", "private": true, "description": "", "main": "importJira.js", "scripts": { "lint": "eslint --ext .tsx,.ts . --quiet --cache", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache", "test": "jest", "testRun": "ts-node importJira.ts -i test/jira_export.xml -o test/jira-import.focalboard", "debug:test": "node --inspect=5858 -r ts-node/register importJira.ts -i test/jira_export.xml -o test/jira-import.focalboard" }, "keywords": [], "author": "", "jest": { "globals": { "ts-jest": { "tsconfig": "./tsconfig.json" } }, "transform": { "^.+\\.tsx?$": "ts-jest" }, "collectCoverage": true, "collectCoverageFrom": [ "*.{ts,tsx,js,jsx}", "!test/**" ] }, "devDependencies": { "@types/jest": "^27.0.2", "@types/minimist": "^1.2.1", "@types/node": "^14.14.28", "@types/turndown": "^5.0.1", "@types/xml2js": "^0.4.9", "@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/parser": "^4.15.0", "eslint": "^7.20.0", "jest": "^27.3.1", "ts-jest": "^27.0.7", "ts-node": "^9.1.1", "typescript": "^4.1.5" }, "dependencies": { "minimist": "^1.2.6", "turndown": "^7.1.1", "xml2js": "^0.4.23" } } ================================================ FILE: import/jira/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, "resolveJsonModule": true, "incremental": false, "outDir": "./dist", "moduleResolution": "node" }, "include": [ "." ], "exclude": [ ".git", "**/node_modules/*", "dist", "pack" ] } ================================================ FILE: import/jira/utils.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as crypto from 'crypto' class Utils { static createGuid(): string { function randomDigit() { if (crypto && crypto.randomBytes) { const rands = crypto.randomBytes(1) return (rands[0] % 16).toString(16) } return (Math.floor((Math.random() * 16))).toString(16) } return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit) } } export { Utils } ================================================ FILE: import/nextcloud-deck/.eslintrc.json ================================================ { "extends": [ ], "plugins": [ ], "parser": "@typescript-eslint/parser", "env": { "jest": true }, "settings": { "import/resolver": "webpack", "react": { "pragma": "React", "version": "detect" } }, "rules": { "no-unused-expressions": 0, "eol-last": ["error", "always"], "import/no-unresolved": 2, "no-undefined": 0, "react/jsx-filename-extension": 0, "max-nested-callbacks": ["error", {"max": 5}] }, "overrides": [ { "files": ["**/*.tsx", "**/*.ts"], "extends": [ "plugin:@typescript-eslint/recommended" ], "rules": { "import/no-unresolved": 0, // ts handles this better "camelcase": 0, "semi": "off", "@typescript-eslint/naming-convention": [ 2, { "selector": "function", "format": ["camelCase", "PascalCase"] }, { "selector": "variable", "format": ["camelCase", "PascalCase", "UPPER_CASE"] }, { "selector": "parameter", "format": ["camelCase", "PascalCase"], "leadingUnderscore": "allow" }, { "selector": "typeLike", "format": ["PascalCase"] } ], "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-unused-vars": [ 2, { "vars": "all", "args": "after-used" } ], "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/prefer-interface": 0, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/semi": [2, "never"], "@typescript-eslint/indent": [ 2, 4, { "SwitchCase": 0 } ], "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": [ 2, { "classes": false, "functions": false, "variables": false } ], "no-useless-constructor": 0, "@typescript-eslint/no-useless-constructor": 2, "react/jsx-filename-extension": 0 } }, { "files": ["tests/**", "**/*.test.*"], "env": { "jest": true }, "rules": { "func-names": 0, "global-require": 0, "new-cap": 0, "prefer-arrow-callback": 0, "no-import-assign": 0 } } ] } ================================================ FILE: import/nextcloud-deck/.gitignore ================================================ test output.focalboard ================================================ FILE: import/nextcloud-deck/README.md ================================================ # Nextcloud Deck importer This node app converts data from a Nextcloud Server with the [app Deck](https://apps.nextcloud.com/apps/deck) installed into a Focalboard archive. To use: 1. Run `npm install` from within `focalboard/webapp` 2. Run `npm install` from within `focalboard/import/nextcloud-deck` 3. Run `npx ts-node importDeck.ts -o archive.boardarchive` (also from within `focalboard/import/nextcloud-deck`) 1. Enter URL and credentials (can also be provided via cli arguments) 2. Enter ID of the board to convert 4. In Focalboard, click `Settings`, then `Import archive` and select `archive.boardarchive` ## Import scope Currently, the script imports all cards from a single board, including their stacks (column) membership, labels, names, descriptions, duedate and comments. [Contribute code](https://mattermost.github.io/focalboard/) to expand this. ================================================ FILE: import/nextcloud-deck/deck.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable @typescript-eslint/no-empty-interface */ // Generated by https://quicktype.io // // To change quicktype's target language, run command: // // "Set quicktype target language" import fetch from 'node-fetch' // Types export interface Board { title: string; owner: User; color: string; archived: boolean; labels: Label[]; acl: any[]; permissions: { PERMISSION_READ: boolean; PERMISSION_EDIT: boolean; PERMISSION_MANAGE: boolean; PERMISSION_SHARE: boolean; }; users: User[]; shared: number; deletedAt: number; id: number; lastModified: number; } export interface Stack { title: string; boardId: number; deletedAt: number; lastModified: number; cards: Card[]; order: number; id: number; } export interface Card { title: string; description: string; stackId: number; type: "plain"; lastModified: number; createdAt: number; labels: Label[]; assignedUsers: unknown; attachments: unknown; attachmentCount: unknown; owner: string; order: number; archived: boolean; duedate: string; deletedAt: number; commentsUnread: number; commentsCount: number; comments?: Comment[] id: number; overdue: number; } export interface CommentResponse { ocs: { meta: { status: string; statuscode: number; message: string; }; data: Comment[]; }; } export interface Comment { id: number; objectId: number; message: string; actorId: string; actorType: string; actorDisplayName: string; creationDateTime: string; mentions: [ { mentionId: string; mentionType: string; mentionDisplayName: string; } ]; replyTo?: Comment; } export interface Label { title: string; color: string; cardId: any; id: number; } export interface User { primaryKey: string; uid: string; displayname: string; } export interface NextcloudDeckClientConfig { url: string auth: Auth } export interface Auth { username: string password: string } // api export const defaultHeaders = { "OCS-APIRequest": "true", "Content-Type": "application/json", "Accept": "application/json", } export class NextcloudDeckClient { config: NextcloudDeckClientConfig /** * Create a new Nextcloud Deck client */ constructor(config: NextcloudDeckClientConfig) { this.config = config } async fetchWrapper(path: string): Promise { const response = await fetch(`${this.config.url}/index.php/apps/deck/api/v1.0/${path}`, { method: "GET", headers: { ...defaultHeaders, "Authorization": 'Basic ' + Buffer.from(`${this.config.auth.username}:${this.config.auth.password}`).toString('base64') } }) if (!response.ok) { throw new Error(`Request failed with info: ${await response.text()}`) } return await response.json() } async fetchWrapperOCS(path: string): Promise { const response = await fetch(`${this.config.url}/ocs/v2.php/apps/deck/api/v1.0/${path}`, { method: "GET", headers: { ...defaultHeaders, "Authorization": 'Basic ' + Buffer.from(`${this.config.auth.username}:${this.config.auth.password}`).toString('base64') } }) if (!response.ok) { throw new Error(`Request failed with info: ${await response.text()}`) } return await response.json() } async getBoards(): Promise { return await this.fetchWrapper('boards') } async getBoardDetails(boardId: number): Promise { return await this.fetchWrapper(`boards/${boardId}`) } async getStacks(boardId: number): Promise { return await this.fetchWrapper(`boards/${boardId}/stacks`) } async getStacksArchived(boardId: number): Promise { return await this.fetchWrapper(`boards/${boardId}/stacks/archived`) } async getStackDetails(boardId: number, stackId: number): Promise { return await this.fetchWrapper(`boards/${boardId}/stacks/${stackId}`) } async getCardDetails(boardId: number, stackId: number, cardId: number): Promise { return await this.fetchWrapper(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`) } async getComments(cardId: number): Promise { const response = await this.fetchWrapperOCS(`cards/${cardId}/comments`) as CommentResponse return response.ocs.data } } ================================================ FILE: import/nextcloud-deck/importDeck.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as fs from 'fs' import minimist from 'minimist' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' import {Board as FBBoard} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' import {createTextBlock} from '../../webapp/src/blocks/textBlock' import {NextcloudDeckClient, Stack, Board} from './deck' import {Utils} from './utils' import readline from 'readline-sync' import {createCommentBlock} from '../../webapp/src/blocks/commentBlock' // HACKHACK: To allow Utils.CreateGuid to work (global.window as any) = {} const optionColors = [ // 'propColorDefault', 'propColorGray', 'propColorBrown', 'propColorOrange', 'propColorYellow', 'propColorGreen', 'propColorBlue', 'propColorPurple', 'propColorPink', 'propColorRed', ] async function main() { const args: minimist.ParsedArgs = minimist(process.argv.slice(2)) console.log("Transform a nextcloud deck into a mattermost Board.") if (args['h'] || args['help']) { showHelp() } // Get Options const url = args['url'] ?? readline.question('Nextcloud URL: ') const username = args['u'] ?? readline.question('Username: ') const password = args['p'] ?? readline.question('Password: ', {hideEchoBack: true}) const boardIdString = args['b'] const outputFile = args['o'] || 'archive.boardarchive' // Create Client const deckClient = new NextcloudDeckClient({auth: {username, password}, url}) // Select board (Either from cli or by interactive selection) const boardId = boardIdString ? parseInt(boardIdString) : await selectBoard(deckClient) // Get Data const board = await deckClient.getBoardDetails(boardId) const stacks = await Promise.all((await deckClient.getStacks(boardId)).map(async s => { return { ...s, cards: await Promise.all(s.cards.map(async c => { if (c.commentsCount > 0) { c.comments = await deckClient.getComments(c.id) } return c })) } })) // Convert const [boards, blocks] = convert(board, stacks) // // Save output const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) } async function selectBoard(deckClient: NextcloudDeckClient): Promise { console.log("\nAvailable boards for this user:") const boards = await deckClient.getBoards() boards.forEach(b => console.log(`\t${b.id}: ${b.title} (${b.owner.uid})`)) return readline.questionInt("Enter Board ID: ") } function convert(deckBoard: Board, stacks: Stack[]): [FBBoard[], Block[]] { const boards: FBBoard[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${deckBoard.title}`) board.title = deckBoard.title let colorIndex = 0 // Convert stacks (columns) to a Select property const stackOptionsIdMap = new Map() const stackOptions: IPropertyOption[] = [] stacks.forEach(stack => { const optionId = Utils.createGuid() stackOptionsIdMap.set(stack.id, optionId) const color = optionColors[colorIndex % optionColors.length] colorIndex += 1 const option: IPropertyOption = { id: optionId, value: stack.title, color, } stackOptions.push(option) }) const stackProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'List', type: 'select', options: stackOptions } // Convert labels (tags) to a Select property const labelOptionsIdMap = new Map() const labelOptions: IPropertyOption[] = [] deckBoard.labels.forEach(label => { const optionId = Utils.createGuid() labelOptionsIdMap.set(label.id, optionId) const color = optionColors[colorIndex % optionColors.length] colorIndex += 1 const option: IPropertyOption = { id: optionId, value: label.title, color, } labelOptions.push(option) }) const labelProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'Label', type: 'multiSelect', options: labelOptions } const dueDateProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'Due Date', type: 'date', options: [] } board.cardProperties = [stackProperty, labelProperty, dueDateProperty] boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' view.boardId = board.id view.parentId = board.id blocks.push(view) // Cards stacks.forEach(stack => stack.cards.forEach( card => { console.log(`Card: ${card.title}`) const outCard = createCard() outCard.title = card.title outCard.boardId = board.id outCard.parentId = board.id // Map Stacks to Select property options const stackOptionId = stackOptionsIdMap.get(card.stackId) if (stackOptionId) { outCard.fields.properties[stackProperty.id] = stackOptionId } else { console.warn(`Invalid idList: ${card.stackId} for card: ${card.title}`) } // Map Labels to Multiselect options outCard.fields.properties[labelProperty.id] = card.labels?.map(label => labelOptionsIdMap.get(label.id)).filter((id): id is string => !!id) // Add duedate if (card.duedate) { const duedate = new Date(card.duedate) outCard.fields.properties[dueDateProperty.id] = `{\"from\":${duedate.getTime()}}` } blocks.push(outCard) // Description if (card.description) { const text = createTextBlock() text.title = card.description text.boardId = board.id text.parentId = outCard.id blocks.push(text) outCard.fields.contentOrder = [text.id] } // Add Comments (Author cannot be determined since uid's are different) card.comments?.forEach(comment => { const commentBlock = createCommentBlock() commentBlock.title = comment.message commentBlock.boardId = board.id commentBlock.parentId = outCard.id blocks.push(commentBlock) }) }) ) console.log('') console.log(`Transformed Board ${deckBoard.title} into ${blocks.length} blocks.`) return [boards, blocks] } function showHelp() { console.log('import [--url ] [-u ] [-p ] [-o ]') exit(1) } main() ================================================ FILE: import/nextcloud-deck/package.json ================================================ { "name": "focalboard-nextcloud-deck-importer", "version": "1.0.0", "private": true, "description": "", "main": "importDeck.js", "scripts": { "lint": "eslint --ext .tsx,.ts . --quiet --cache", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache", "test": "ts-node importTrello.ts -i test/trello.json -o test/trello-import.focalboard", "debug:test": "node --inspect=5858 -r ts-node/register importTrello.ts -i test/trello.json -o test/trello-import.focalboard" }, "keywords": [], "author": "", "devDependencies": { "@types/minimist": "^1.2.1", "@types/node": "^14.14.28", "@types/node-fetch": "^2.5.0", "@types/readline-sync": "^1.4.4", "@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/parser": "^4.15.0", "eslint": "^7.20.0", "minimist": "^1.2.6", "node-fetch": "^2.6.7", "readline-sync": "^1.4.10", "ts-node": "^10.4.0", "typescript": "^4.5.5" } } ================================================ FILE: import/nextcloud-deck/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, "resolveJsonModule": true, "incremental": false, "outDir": "./dist", "moduleResolution": "node" }, "include": [ "." ], "exclude": [ ".git", "**/node_modules/*", "dist", "pack" ] } ================================================ FILE: import/nextcloud-deck/utils.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as crypto from 'crypto' class Utils { static createGuid(): string { function randomDigit() { if (crypto && crypto.randomBytes) { const rands = crypto.randomBytes(1) return (rands[0] % 16).toString(16) } return (Math.floor((Math.random() * 16))).toString(16) } return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit) } } export { Utils } ================================================ FILE: import/notion/.eslintrc.json ================================================ { "extends": [ ], "plugins": [ ], "parser": "@typescript-eslint/parser", "env": { "jest": true }, "settings": { "import/resolver": "webpack", "react": { "pragma": "React", "version": "detect" } }, "rules": { "no-unused-expressions": 0, "eol-last": ["error", "always"], "import/no-unresolved": 2, "no-undefined": 0, "react/jsx-filename-extension": 0, "max-nested-callbacks": ["error", {"max": 5}] }, "overrides": [ { "files": ["**/*.tsx", "**/*.ts"], "extends": [ "plugin:@typescript-eslint/recommended" ], "rules": { "import/no-unresolved": 0, // ts handles this better "camelcase": 0, "semi": "off", "@typescript-eslint/naming-convention": [ 2, { "selector": "function", "format": ["camelCase", "PascalCase"] }, { "selector": "variable", "format": ["camelCase", "PascalCase", "UPPER_CASE"] }, { "selector": "parameter", "format": ["camelCase", "PascalCase"], "leadingUnderscore": "allow" }, { "selector": "typeLike", "format": ["PascalCase"] } ], "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-unused-vars": [ 2, { "vars": "all", "args": "after-used" } ], "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/prefer-interface": 0, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/semi": [2, "never"], "@typescript-eslint/indent": [ 2, 4, { "SwitchCase": 0 } ], "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": [ 2, { "classes": false, "functions": false, "variables": false } ], "no-useless-constructor": 0, "@typescript-eslint/no-useless-constructor": 2, "react/jsx-filename-extension": 0 } }, { "files": ["tests/**", "**/*.test.*"], "env": { "jest": true }, "rules": { "func-names": 0, "global-require": 0, "new-cap": 0, "prefer-arrow-callback": 0, "no-import-assign": 0 } } ] } ================================================ FILE: import/notion/.gitignore ================================================ test ================================================ FILE: import/notion/README.md ================================================ # Notion importer This node app converts a Notion CSV and markdown export into a Focalboard archive. To use: 1. From a Notion Board, open the ... menu at the top right 2. Select `Export`, pick `Markdown & CSV` as the export format, select true to include subpages. 3. Save it locally, and unzip the folder e.g. to `notion-export` 4. Run `npm install` from within `focalboard/webapp` 5. Run `npm install` from within `focalboard/import/notion` 6. Run `npx ts-node importNotion.ts -i -o archive.boardarchive` 7. In Focalboard, click `Settings`, then `Import archive` and select `archive.boardarchive` ## Import scope Currently, the script imports all cards from a single board, including their properties and markdown content. The Notion export format does not preserve property types, so the script currently imports all card properties as a Select type. You can change the type after importing into Focalboard. [Contribute code](https://mattermost.github.io/focalboard/) to expand this. ================================================ FILE: import/notion/importNotion.ts ================================================ import csv from 'csvtojson' import * as fs from 'fs' import minimist from 'minimist' import path from 'path' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' import {Board} from '../../webapp/src/blocks/board' import {IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' import {createTextBlock} from '../../webapp/src/blocks/textBlock' import {Utils} from './utils' // HACKHACK: To allow Utils.CreateGuid to work (global.window as any) = {} let markdownFolder: string const optionColors = [ // 'propColorDefault', 'propColorGray', 'propColorBrown', 'propColorOrange', 'propColorYellow', 'propColorGreen', 'propColorBlue', 'propColorPurple', 'propColorPink', 'propColorRed', ] let optionColorIndex = 0 async function main() { const args: minimist.ParsedArgs = minimist(process.argv.slice(2)) const inputFolder = args['i'] const outputFile = args['o'] || 'archive.boardarchive' if (!inputFolder) { showHelp() } if (!fs.existsSync(inputFolder)){ console.log(`Folder not found: ${inputFolder}`) exit(2) } const inputFile = getCsvFilePath(inputFolder) if (!inputFile) { console.log(`.csv file not found in folder: ${inputFolder}`) exit(2) } console.log(`inputFile: ${inputFile}`) // Read input const input = await csv().fromFile(inputFile) console.log(`Read ${input.length} rows.`) console.log(input) const basename = path.basename(inputFile, '.csv') const components = basename.split(' ') components.pop() const title = components.join(' ') console.log(`title: ${title}`) markdownFolder = path.join(inputFolder, basename) // Convert const [boards, blocks] = convert(input, title) // Save output // TODO: Stream output const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) } function getCsvFilePath(inputFolder: string): string | undefined { const files = fs.readdirSync(inputFolder) const file = files.find(o => path.extname(o).toLowerCase() === '.csv') return file ? path.join(inputFolder, file) : undefined } function getMarkdown(cardTitle: string): string | undefined { if (!fs.existsSync(markdownFolder)){ return undefined} const files = fs.readdirSync(markdownFolder) const file = files.find((o) => { const basename = path.basename(o) const components = basename.split(' ') const fileCardTitle = components.slice(0, components.length-1).join(' ') if (fileCardTitle === cardTitle) { return o } }) if (file) { const filePath = path.join(markdownFolder, file) const markdown = fs.readFileSync(filePath, 'utf-8') // TODO: Remove header from markdown, which repets card title and properties return markdown } return undefined } function getColumns(input: any[]) { const row = input[0] const keys = Object.keys(row) // The first key (column) is the card title return keys.slice(1) } function convert(input: any[], title: string): [Board[], Block[]] { const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${title}`) board.title = title // Each column is a card property const columns = getColumns(input) columns.forEach(column => { const cardProperty: IPropertyTemplate = { id: Utils.createGuid(), name: column, type: 'select', options: [] } board.cardProperties.push(cardProperty) }) // Set all column types to select // TODO: Detect column type boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' view.boardId = board.id view.parentId = board.id blocks.push(view) // Cards input.forEach(row => { const keys = Object.keys(row) console.log(keys) if (keys.length < 1) { console.error(`Expected at least one column`) return blocks } const titleKey = keys[0] const title = row[titleKey] console.log(`Card: ${title}`) const outCard = createCard() outCard.title = title outCard.boardId = board.id outCard.parentId = board.id // Card properties, skip first key which is the title for (const key of keys.slice(1)) { const value = row[key] if (!value) { // Skip empty values continue } const cardProperty = board.cardProperties.find((o) => o.name === key)! let option = cardProperty.options.find((o) => o.value === value) if (!option) { const color = optionColors[optionColorIndex % optionColors.length] optionColorIndex += 1 option = { id: Utils.createGuid(), value, color: color, } cardProperty.options.push(option) } outCard.fields.properties[cardProperty.id] = option.id } blocks.push(outCard) // Card notes from markdown const markdown = getMarkdown(title) if (markdown) { console.log(`Markdown: ${markdown.length} bytes`) const text = createTextBlock() text.title = markdown text.boardId = board.id text.parentId = outCard.id blocks.push(text) outCard.fields.contentOrder = [text.id] } }) console.log('') console.log(`Found ${input.length} card(s).`) return [boards, blocks] } function showHelp() { console.log('import -i -o [output.boardarchive]') exit(1) } main() ================================================ FILE: import/notion/package.json ================================================ { "name": "focalboard-notion-importer", "version": "1.0.0", "private": true, "description": "", "main": "importNotion.js", "scripts": { "lint": "eslint --ext .tsx,.ts . --quiet --cache", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache", "test": "ts-node importNotion.ts -i test/export -o test/notion-import.focalboard", "debug:test": "node --inspect=5858 -r ts-node/register importNotion.ts -i test/export -o test/notion-import.focalboard" }, "keywords": [], "author": "", "devDependencies": { "@types/minimist": "^1.2.1", "@types/node": "^14.14.28", "@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/parser": "^4.15.0", "eslint": "^7.20.0", "ts-node": "^9.1.1", "typescript": "^4.1.5" }, "dependencies": { "csvtojson": "^2.0.10", "minimist": "^1.2.6" } } ================================================ FILE: import/notion/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, "resolveJsonModule": true, "incremental": false, "outDir": "./dist", "moduleResolution": "node" }, "include": [ "." ], "exclude": [ ".git", "**/node_modules/*", "dist", "pack" ] } ================================================ FILE: import/notion/utils.ts ================================================ import * as crypto from 'crypto' class Utils { static createGuid(): string { function randomDigit() { if (crypto && crypto.randomBytes) { const rands = crypto.randomBytes(1) return (rands[0] % 16).toString(16) } return (Math.floor((Math.random() * 16))).toString(16) } return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit) } } export { Utils } ================================================ FILE: import/todoist/.eslintrc.json ================================================ { "extends": [ ], "plugins": [ ], "parser": "@typescript-eslint/parser", "env": { "jest": true }, "settings": { "import/resolver": "webpack", "react": { "pragma": "React", "version": "detect" } }, "rules": { "no-unused-expressions": 0, "eol-last": ["error", "always"], "import/no-unresolved": 2, "no-undefined": 0, "react/jsx-filename-extension": 0, "max-nested-callbacks": ["error", {"max": 5}] }, "overrides": [ { "files": ["**/*.tsx", "**/*.ts"], "extends": [ "plugin:@typescript-eslint/recommended" ], "rules": { "import/no-unresolved": 0, // ts handles this better "camelcase": 0, "semi": "off", "@typescript-eslint/naming-convention": [ 2, { "selector": "function", "format": ["camelCase", "PascalCase"] }, { "selector": "variable", "format": ["camelCase", "PascalCase", "UPPER_CASE"] }, { "selector": "parameter", "format": ["camelCase", "PascalCase"], "leadingUnderscore": "allow" }, { "selector": "typeLike", "format": ["PascalCase"] } ], "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-unused-vars": [ 2, { "vars": "all", "args": "after-used" } ], "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/prefer-interface": 0, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/semi": [2, "never"], "@typescript-eslint/indent": [ 2, 4, { "SwitchCase": 0 } ], "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": [ 2, { "classes": false, "functions": false, "variables": false } ], "no-useless-constructor": 0, "@typescript-eslint/no-useless-constructor": 2, "react/jsx-filename-extension": 0 } }, { "files": ["tests/**", "**/*.test.*"], "env": { "jest": true }, "rules": { "func-names": 0, "global-require": 0, "new-cap": 0, "prefer-arrow-callback": 0, "no-import-assign": 0 } } ] } ================================================ FILE: import/todoist/.gitignore ================================================ test ================================================ FILE: import/todoist/README.md ================================================ # Todoist importer This node app converts a Todoist json archive into a Focalboard archive. To use: 1. Visit the open source Todoist data export service at https://darekkay.com/todoist-export/. 1. Select `JSON (all data)` in **Export As** option. 1. Uncheck the **Archived** option if checked. 1. Click on **Authorize and Backup**. This wil take you to your Todoist account. Follow the instructions on screen. 1. Note the name and location of the downloaded *json* file. 3. Run `npm install` from within `focalboard/webapp` 4. Run `npm install` from within `focalboard/import/todoist` 5. Run `npx ts-node importTodoist.ts -i -o archive.boardarchive` (also from within `focalboard/import/todoist`) 6. In Focalboard, click `Settings`, then `Import archive` and select `archive.boardarchive` ================================================ FILE: import/todoist/importTodoist.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as fs from 'fs' import minimist from 'minimist' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' import {createTextBlock} from '../../webapp/src/blocks/textBlock' import {Item, Project, Section, Todoist} from './todoist' import {Utils} from './utils' // HACKHACK: To allow Utils.CreateGuid to work (global.window as any) = {} const optionColors = [ // 'propColorDefault', 'propColorGray', 'propColorBrown', 'propColorOrange', 'propColorYellow', 'propColorGreen', 'propColorBlue', 'propColorPurple', 'propColorPink', 'propColorRed', ] const defaultSections = ['No Status', 'Next Up', 'In Progress', 'Completed', 'Archived'].map(title => { return { id: Utils.createGuid(), name: title, } as Section }) let noStatusSectionID: any let optionColorIndex = 0 function main() { const args: minimist.ParsedArgs = minimist(process.argv.slice(2)) const inputFile = args['i'] const outputFile = args['o'] || 'archive.boardarchive' if (!inputFile) { showHelp() } if (!fs.existsSync(inputFile)) { console.error(`File not found: ${inputFile}`) exit(2) } // Read input const inputData = fs.readFileSync(inputFile, 'utf-8') const input = JSON.parse(inputData) as Todoist const boards = [] as Board[] const blocks = [] as Block[] input.projects.forEach(project => { const [brds, blks] = convert(input, project) boards.push(...brds) blocks.push(...blks) }) // Save output // TODO: Stream output const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) } function convert(input: Todoist, project: Project): [Board[], Block[]] { const boards: Board[] = [] const blocks: Block[] = [] if (project.name === 'Inbox') { return [boards, blocks] } // Board const board = createBoard() console.log(`Board: ${project.name}`) board.title = project.name board.description = project.name // Convert lists (columns) to a Select property const optionIdMap = new Map() const options: IPropertyOption[] = [] const columns = getProjectColumns(input, project) console.log("columns: " + JSON.stringify(columns)) columns.forEach(list => { const optionId = Utils.createGuid() if (list.name === 'No Status') { noStatusSectionID = list.id } optionIdMap.set(String(list.id), optionId) const color = optionColors[optionColorIndex % optionColors.length] optionColorIndex += 1 const option: IPropertyOption = { id: optionId, value: list.name, color, } options.push(option) }) const cardProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'List', type: 'select', options } board.cardProperties = [cardProperty] boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' view.boardId = board.id view.parentId = board.id blocks.push(view) // Cards const cards = getProjectCards(input, project) cards.forEach(card => { const outCard = createCard() outCard.title = card.content outCard.boardId = board.id outCard.parentId = board.id // Map lists to Select property options const cardSectionId = card.section_id ? card.section_id : noStatusSectionID const optionId = optionIdMap.get(String(cardSectionId)) if (optionId) { outCard.fields.properties[cardProperty.id] = optionId } else { console.warn(`Invalid idList: ${cardSectionId} for card: ${card.content}`) } blocks.push(outCard) // console.log(`\t${card.desc}`) const text = createTextBlock() text.title = getCardDescription(input, card).join('\n\n') text.boardId = board.id text.parentId = outCard.id blocks.push(text) outCard.fields.contentOrder = [text.id] }) return [boards, blocks] } function getProjectColumns(input: Todoist, project: Project): Array
{ const sections = [{ id: noStatusSectionID, name: 'No Section', } as Section] sections.push(...input.sections.filter(section => section.project_id === project.id)) return sections.length > 1 ? sections : defaultSections } function getProjectCards(input: Todoist, project: Project): Array { return input.items.filter(item => item.project_id === project.id) } function getCardDescription(input: Todoist, item: Item): Array { return input.notes.filter(note => note.item_id === item.id).map(item => { let description = "" if (item.content) { description = item.content } if (item.file_attachment) { description += `\n\nAttachment: [${item.file_attachment.title}](${item.file_attachment.url})` } return description }) } function showHelp() { exit(1) } main() ================================================ FILE: import/todoist/package.json ================================================ { "name": "focalboard-todoist-importer", "version": "1.0.0", "private": true, "description": "", "main": "importTodoist.ts", "scripts": { "lint": "eslint --ext .tsx,.ts . --quiet --cache", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache", "test": "ts-node importTodoist.ts -i test/todoist.json -o test/todoist-import.focalboard", "debug:test": "node --inspect=5858 -r ts-node/register importTodoist.ts -i test/todoist.json -o test/todoist-import.focalboard" }, "keywords": [], "author": "", "devDependencies": { "@types/minimist": "^1.2.1", "@types/node": "^14.14.28", "@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/parser": "^4.15.0", "eslint": "^7.20.0", "ts-node": "^9.1.1", "typescript": "^4.1.5" }, "dependencies": { "minimist": "^1.2.6" } } ================================================ FILE: import/todoist/todoist.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable @typescript-eslint/no-empty-interface */ // Generated by https://quicktype.io export interface Todoist { collaborator_states: any[]; collaborators: any[]; day_orders: DayOrders; day_orders_timestamp: string; due_exceptions: any[]; filters: Filter[]; full_sync: boolean; incomplete_item_ids: any[]; incomplete_project_ids: any[]; items: Item[]; labels: any[]; live_notifications: any[]; live_notifications_last_read_id: number; locations: any[]; notes: Note[]; project_notes: any[]; projects: Project[]; reminders: any[]; sections: Section[]; stats: Stats; sync_token: string; temp_id_mapping: DayOrders; tooltips: Tooltips; user: User; user_plan_limits: UserPlanLimits; user_settings: UserSettings; view_options: any[]; } export interface DayOrders { } export interface Filter { color: number; id: number; is_deleted: number; is_favorite: number; item_order: number; name: string; query: string; } export interface Item { added_by_uid: number; assigned_by_uid: null; checked: number; child_order: number; collapsed: number; content: string; date_added: Date; date_completed: null; day_order: number; due: Due | null; has_more_notes: boolean; id: number; in_history: number; is_deleted: number; labels: any[]; parent_id: number | null; priority: number; project_id: number; responsible_uid: null; section_id: number; sync_id: null; user_id: number; } export interface Due { date: Date; is_recurring: boolean; lang: string; string: string; timezone: null; } export interface Note { content: string; file_attachment: FileAttachment | null; id: number; is_deleted: number; item_id: number; posted: Date; posted_uid: number; project_id: number; reactions: null; uids_to_notify: null; } export interface FileAttachment { description: string; favicon: string; image: string; image_height: number; image_width: number; resource_type: string; site_name: string; title: string; url: string; } export interface Project { child_order: number; collapsed: number; color: number; has_more_notes: boolean; id: number; inbox_project?: boolean; is_archived: number; is_deleted: number; is_favorite: number; name: string; parent_id: null; shared: boolean; sync_id: null; } export interface Section { collapsed: boolean; date_added: Date; date_archived: null; id: number | string; is_archived: boolean; is_deleted: boolean; name: string; project_id: number; section_order: number; sync_id: null; user_id: number; } export interface Stats { completed_count: number; days_items: DaysItem[]; week_items: WeekItem[]; } export interface DaysItem { date: Date; total_completed: number; } export interface WeekItem { from: Date; to: Date; total_completed: number; } export interface Tooltips { scheduled: string[]; seen: string[]; } export interface User { auto_reminder: number; business_account_id: null; daily_goal: number; date_format: number; dateist_inline_disabled: boolean; dateist_lang: null; days_off: number[]; default_reminder: string; email: string; features: Features; full_name: string; id: number; image_id: null; inbox_project: number; is_biz_admin: boolean; is_premium: boolean; join_date: Date; karma: number; karma_trend: string; lang: string; mobile_host: null; mobile_number: null; next_week: number; premium_until: null; share_limit: number; sort_order: number; start_day: number; start_page: string; theme: number; time_format: number; tz_info: TzInfo; unique_prefix: number; websocket_url: string; weekly_goal: number; } export interface Features { beta: number; dateist_inline_disabled: boolean; dateist_lang: null; has_push_reminders: boolean; karma_disabled: boolean; karma_vacation: boolean; restriction: number; } export interface TzInfo { gmt_string: string; hours: number; is_dst: number; minutes: number; timezone: string; } export interface UserPlanLimits { current: Current; next: Current; } export interface Current { activity_log: boolean; activity_log_limit: number; automatic_backups: boolean; calendar_feeds: boolean; comments: boolean; completed_tasks: boolean; customization_color: boolean; email_forwarding: boolean; filters: boolean; labels: boolean; max_collaborators: number; max_filters: number; max_labels: number; max_projects: number; max_reminders_location: number; max_reminders_time: number; max_sections: number; max_tasks: number; plan_name: string; reminders: boolean; templates: boolean; upload_limit_mb: number; uploads: boolean; weekly_trends: boolean; } export interface UserSettings { completed_sound_desktop: boolean; completed_sound_mobile: boolean; legacy_pricing: boolean; reminder_desktop: boolean; reminder_email: boolean; reminder_push: boolean; sound_on_completed: boolean; } ================================================ FILE: import/todoist/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, "resolveJsonModule": true, "incremental": false, "outDir": "./dist", "moduleResolution": "node" }, "include": [ "." ], "exclude": [ ".git", "**/node_modules/*", "dist", "pack" ] } ================================================ FILE: import/todoist/utils.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as crypto from 'crypto' class Utils { static createGuid(): string { function randomDigit() { if (crypto && crypto.randomBytes) { const rands = crypto.randomBytes(1) return (rands[0] % 16).toString(16) } return (Math.floor((Math.random() * 16))).toString(16) } return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit) } } export { Utils } ================================================ FILE: import/trello/.eslintrc.json ================================================ { "extends": [ ], "plugins": [ ], "parser": "@typescript-eslint/parser", "env": { "jest": true }, "settings": { "import/resolver": "webpack", "react": { "pragma": "React", "version": "detect" } }, "rules": { "no-unused-expressions": 0, "eol-last": ["error", "always"], "import/no-unresolved": 2, "no-undefined": 0, "react/jsx-filename-extension": 0, "max-nested-callbacks": ["error", {"max": 5}] }, "overrides": [ { "files": ["**/*.tsx", "**/*.ts"], "extends": [ "plugin:@typescript-eslint/recommended" ], "rules": { "import/no-unresolved": 0, // ts handles this better "camelcase": 0, "semi": "off", "@typescript-eslint/naming-convention": [ 2, { "selector": "function", "format": ["camelCase", "PascalCase"] }, { "selector": "variable", "format": ["camelCase", "PascalCase", "UPPER_CASE"] }, { "selector": "parameter", "format": ["camelCase", "PascalCase"], "leadingUnderscore": "allow" }, { "selector": "typeLike", "format": ["PascalCase"] } ], "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-unused-vars": [ 2, { "vars": "all", "args": "after-used" } ], "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/prefer-interface": 0, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/semi": [2, "never"], "@typescript-eslint/indent": [ 2, 4, { "SwitchCase": 0 } ], "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": [ 2, { "classes": false, "functions": false, "variables": false } ], "no-useless-constructor": 0, "@typescript-eslint/no-useless-constructor": 2, "react/jsx-filename-extension": 0 } }, { "files": ["tests/**", "**/*.test.*"], "env": { "jest": true }, "rules": { "func-names": 0, "global-require": 0, "new-cap": 0, "prefer-arrow-callback": 0, "no-import-assign": 0 } } ] } ================================================ FILE: import/trello/.gitignore ================================================ test ================================================ FILE: import/trello/README.md ================================================ # Trello importer This node app converts a Trello json archive into a Focalboard archive. To use: 1. From the Trello Board Menu, `...Show Menu` on right 2. Select `More`, then `Print and Export`, and `Export to JSON` 3. Save it locally, e.g. to `trello.json` 4. Run `npm install` from within `focalboard/webapp` 5. Run `npm install` from within `focalboard/import/trello` 6. Run `npx ts-node importTrello.ts -i -o archive.boardarchive` (also from within `focalboard/import/trello`) 7. In Focalboard, click `Settings`, then `Import archive` and select `archive.boardarchive` ## Import scope Currently, the script imports all cards from a single board, including their list (column) membership, names, and descriptions. [Contribute code](https://mattermost.github.io/focalboard/) to expand this. ================================================ FILE: import/trello/importTrello.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as fs from 'fs' import minimist from 'minimist' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' import {createTextBlock} from '../../webapp/src/blocks/textBlock' import {createCheckboxBlock} from '../../webapp/src/blocks/checkboxBlock' import {Trello} from './trello' import {Utils} from './utils' // HACKHACK: To allow Utils.CreateGuid to work (global.window as any) = {} const optionColors = [ // 'propColorDefault', 'propColorGray', 'propColorBrown', 'propColorOrange', 'propColorYellow', 'propColorGreen', 'propColorBlue', 'propColorPurple', 'propColorPink', 'propColorRed', ] let optionColorIndex = 0 function main() { const args: minimist.ParsedArgs = minimist(process.argv.slice(2)) const inputFile = args['i'] const outputFile = args['o'] || 'archive.boardarchive' if (!inputFile) { showHelp() } if (!fs.existsSync(inputFile)) { console.error(`File not found: ${inputFile}`) exit(2) } // Read input const inputData = fs.readFileSync(inputFile, 'utf-8') const input = JSON.parse(inputData) as Trello // Convert const [boards, blocks] = convert(input) // Save output // TODO: Stream output const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) } function convert(input: Trello): [Board[], Block[]] { const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${input.name}`) board.title = input.name board.description = input.desc // Convert lists (columns) to a Select property const optionIdMap = new Map() const options: IPropertyOption[] = [] input.lists.forEach(list => { const optionId = Utils.createGuid() optionIdMap.set(list.id, optionId) const color = optionColors[optionColorIndex % optionColors.length] optionColorIndex += 1 const option: IPropertyOption = { id: optionId, value: list.name, color, } options.push(option) }) const cardProperty: IPropertyTemplate = { id: Utils.createGuid(), name: 'List', type: 'select', options } board.cardProperties = [cardProperty] boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' view.boardId = board.id view.parentId = board.id blocks.push(view) // Cards input.cards.forEach(card => { console.log(`Card: ${card.name}`) const outCard = createCard() outCard.title = card.name outCard.boardId = board.id outCard.parentId = board.id // Map lists to Select property options if (card.idList) { const optionId = optionIdMap.get(card.idList) if (optionId) { outCard.fields.properties[cardProperty.id] = optionId } else { console.warn(`Invalid idList: ${card.idList} for card: ${card.name}`) } } else { console.warn(`Missing idList for card: ${card.name}`) } blocks.push(outCard) if (card.desc) { // console.log(`\t${card.desc}`) const text = createTextBlock() text.title = card.desc text.boardId = board.id text.parentId = outCard.id blocks.push(text) outCard.fields.contentOrder = [text.id] } // Add Checklists if (card.idChecklists && card.idChecklists.length > 0) { card.idChecklists.forEach(checklistID => { const lookup = input.checklists.find(e => e.id === checklistID) if (lookup) { lookup.checkItems.forEach(trelloCheckBox=> { const checkBlock = createCheckboxBlock() checkBlock.title = trelloCheckBox.name if (trelloCheckBox.state === 'complete') { checkBlock.fields.value = true } else { checkBlock.fields.value = false } checkBlock.boardId = board.id checkBlock.parentId = outCard.id blocks.push(checkBlock) outCard.fields.contentOrder.push(checkBlock.id) }) } }) } }) console.log('') console.log(`Found ${input.cards.length} card(s).`) return [boards, blocks] } function showHelp() { console.log('import -i -o [output.boardarchive]') exit(1) } main() ================================================ FILE: import/trello/package.json ================================================ { "name": "focalboard-trello-importer", "version": "1.0.0", "private": true, "description": "", "main": "importTrello.js", "scripts": { "lint": "eslint --ext .tsx,.ts . --quiet --cache", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache", "test": "ts-node importTrello.ts -i test/trello.json -o test/trello-import.focalboard", "debug:test": "node --inspect=5858 -r ts-node/register importTrello.ts -i test/trello.json -o test/trello-import.focalboard" }, "keywords": [], "author": "", "devDependencies": { "@types/minimist": "^1.2.1", "@types/node": "^14.14.28", "@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/parser": "^4.15.0", "eslint": "^7.20.0", "ts-node": "^9.1.1", "typescript": "^4.1.5" }, "dependencies": { "minimist": "^1.2.6" } } ================================================ FILE: import/trello/trello.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable @typescript-eslint/no-empty-interface */ // Generated by https://quicktype.io // // To change quicktype's target language, run command: // // "Set quicktype target language" export interface Trello { id: IDBoardEnum; name: BoardName; desc: string; descData: null; closed: boolean; idOrganization: null; shortLink: ShortLink; powerUps: any[]; dateLastActivity: string; idTags: any[]; datePluginDisable: null; creationMethod: null; idBoardSource: string; idMemberCreator: null; idEnterprise: null; pinned: boolean; starred: boolean; url: string; shortUrl: string; enterpriseOwned: boolean; premiumFeatures: any[]; ixUpdate: string; limits: TrelloLimits; prefs: Prefs; subscribed: boolean; templateGallery: null; dateLastView: string; labelNames: LabelNames; actions: Action[]; cards: CardElement[]; labels: Label[]; lists: List[]; members: MemberElement[]; checklists: ChecklistElement[]; customFields: CustomFieldElement[]; memberships: Membership[]; pluginData: PluginDatum[]; } export interface Action { id: string; idMemberCreator: IDMemberCreator; data: Data; type: string; date: string; appCreator: AppCreator | null; limits: ActionLimits; memberCreator: MemberCreatorClass; member?: MemberCreatorClass; } export interface AppCreator { id: string; name: string; icon: Icon; } export interface Icon { url: string; } export interface Data { old?: Old; customField?: DataCustomField; customFieldItem?: CustomFieldItem; board: Board; card?: DataCard; list?: ListClass; listBefore?: ListClass; listAfter?: ListClass; idMember?: IDMemberCreator; member?: ListClass; fromCopy?: boolean; cardSource?: Board; deactivated?: boolean; text?: string; checklist?: ListClass; checkItem?: DataCheckItem; boardSource?: BoardSource; } export interface Board { id: IDBoardEnum; name: BoardName; shortLink: ShortLink; idShort?: number; } export enum IDBoardEnum { The5F4800F49696D280D52Bb2Ff = "5f4800f49696d280d52bb2ff", The5F58E6144A949F4C1879A32A = "5f58e6144a949f4c1879a32a", } export enum BoardName { AgileSprintBoard = "Agile Sprint Board", StandardTask = "Standard Task", } export enum ShortLink { WduAIKhy = "wduAiKhy", ZCbHMXU8 = "ZCbHMxU8", } export interface BoardSource { id: string; } export interface DataCard { id: string; name: string; idShort: number; shortLink: string; pos?: number; idList?: IDMemberCreator; due?: string; dueReminder?: number; cover?: OldCover; desc?: string; } export interface OldCover { color: null | string; idAttachment: null; idUploadedBackground: IDUploadedBackground | null; size: MemberType; brightness: Brightness; url?: string; } export enum Brightness { Dark = "dark", Light = "light", } export enum IDUploadedBackground { The5F46Cbb00E54E3660C1A7B22 = "5f46cbb00e54e3660c1a7b22", The5F46Cbe1C839Ef48989Cd124 = "5f46cbe1c839ef48989cd124", The5F46Cbe8B0A6Bb3B7F91A0B8 = "5f46cbe8b0a6bb3b7f91a0b8", } export enum MemberType { Full = "full", Normal = "normal", } export enum IDMemberCreator { The5F4800A4621Dfe2935798972 = "5f4800a4621dfe2935798972", The5F4800F49696D280D52Bb300 = "5f4800f49696d280d52bb300", The5F4800F49696D280D52Bb301 = "5f4800f49696d280d52bb301", The5F4800F49696D280D52Bb302 = "5f4800f49696d280d52bb302", The5F4800F49696D280D52Bb303 = "5f4800f49696d280d52bb303", The5F4800F49696D280D52Bb304 = "5f4800f49696d280d52bb304", The5F480131E778365Be477Add3 = "5f480131e778365be477add3", } export interface DataCheckItem { id: string; name: string; state: string; } export interface ListClass { id: IDMemberCreator; name: FullNameEnum; } export enum FullNameEnum { Backlog = "Backlog", Checklist = "Checklist", JohnSmith = "John Smith", InProgress = "In Progress", SprintBacklog = "Sprint Backlog", The8217SprintComplete = "8.2.17 Sprint - Complete", The8917SprintComplete = "8.9.17 Sprint - Complete", } export interface DataCustomField { id: IDCustomFieldEnum; name: string; type?: string; } export enum IDCustomFieldEnum { The5F4802F5905B9A640C49Be08 = "5f4802f5905b9a640c49be08", The5F480309D1D96A703F2F3143 = "5f480309d1d96a703f2f3143", } export interface CustomFieldItem { id: string; value: CustomFieldItemValue; idCustomField: IDCustomFieldEnum; idModel: string; modelType: ModelType; } export enum ModelType { Card = "card", } export interface CustomFieldItemValue { number?: string; checked?: string; } export interface Old { value?: OldValue | null; pos?: number; idList?: IDMemberCreator; name?: string; due?: null; dueReminder?: null; cover?: OldCover; desc?: string; } export interface OldValue { number: string; } export interface ActionLimits { reactions?: Reactions; } export interface Reactions { perAction: PerBoard; uniquePerAction: PerBoard; } export interface PerBoard { status: Status; disableAt: number; warnAt: number; } export enum Status { Ok = "ok", } export interface MemberCreatorClass { id: IDMemberCreator; username: Username; activityBlocked: boolean; avatarHash: AvatarHash; avatarUrl: string; fullName: FullNameEnum; idMemberReferrer: null; initials: Initials; nonPublic: NonPublic; nonPublicAvailable: boolean; } export enum AvatarHash { Ea6D6D7Da6B79Dc0Cf31301Bc672487F = "ea6d6d7da6b79dc0cf31301bc672487f", } export enum Initials { Cl = "CL", } export interface NonPublic { fullName: FullNameEnum; initials: Initials; avatarHash: null; } export enum Username { johnsmith = "johnsmith", } export interface CardElement { id: string; address: null; checkItemStates: null; closed: boolean; coordinates: null; creationMethod: null; dateLastActivity: string; desc: string; descData: DescDataClass | null; dueReminder: number | null; idBoard: IDBoardEnum; idLabels: string[]; idList: IDMemberCreator; idMembersVoted: any[]; idShort: number; idAttachmentCover: null; locationName: null; manualCoverAttachment: boolean; name: string; pos: number; shortLink: string; isTemplate: boolean; cardRole: null; badges: Badges; dueComplete: boolean; due: null | string; email: string; idChecklists: string[]; idMembers: IDMemberCreator[]; labels: Label[]; limits: CardLimits; shortUrl: string; start: null; subscribed: boolean; url: string; cover: PurpleCover; attachments: Attachment[]; pluginData: any[]; customFieldItems: CustomFieldItem[]; } export interface Attachment { bytes: number | null; date: string; edgeColor: null | string; idMember: string; isUpload: boolean; mimeType: null | string; name: string; previews: Scaled[]; url: string; pos: number; id: string; fileName?: string; } export interface Scaled { _id: string; id: string; scaled: boolean; url: string; bytes: number; height: number; width: number; } export interface Badges { attachmentsByType: AttachmentsByType; location: boolean; votes: number; viewingMemberVoted: boolean; subscribed: boolean; fogbugz: string; checkItems: number; checkItemsChecked: number; checkItemsEarliestDue: null; comments: number; attachments: number; description: boolean; due: null | string; dueComplete: boolean; start: null; } export interface AttachmentsByType { trello: TrelloClass; } export interface TrelloClass { board: number; card: number; } export interface PurpleCover { idAttachment: null; color: null; idUploadedBackground: IDUploadedBackground | null; size: MemberType; brightness: Brightness; idPlugin: null; scaled?: Scaled[]; edgeColor?: string; sharedSourceUrl?: string; } export interface DescDataClass { emoji: Emoji; } export interface Emoji { } export interface Label { id: string; idBoard: IDBoardEnum; name: string; color: string; } export interface CardLimits { attachments: Stickers; checklists: Stickers; stickers: Stickers; } export interface Stickers { perCard: PerBoard; } export interface ChecklistElement { id: string; name: FullNameEnum; idCard: string; pos: number; creationMethod: null; idBoard: IDBoardEnum; limits: ChecklistLimits; checkItems: CheckItemElement[]; } export interface CheckItemElement { idChecklist: string; state: string; id: string; name: string; nameData: DescDataClass | null; pos: number; due: null; idMember: null | string; } export interface ChecklistLimits { checkItems: CheckItems; } export interface CheckItems { perChecklist: PerBoard; } export interface CustomFieldElement { id: IDCustomFieldEnum; idModel: IDBoardEnum; modelType: string; fieldGroup: string; display: Display; name: string; pos: number; type: string; isSuggestedField: boolean; } export interface Display { cardFront: boolean; } export interface LabelNames { green: string; yellow: string; orange: string; red: string; purple: string; blue: string; sky: string; lime: string; pink: string; black: string; } export interface TrelloLimits { attachments: Attachments; boards: Boards; cards: PurpleCards; checklists: Attachments; checkItems: CheckItems; customFields: CustomFields; customFieldOptions: CustomFieldOptions; labels: CustomFields; lists: Lists; stickers: Stickers; reactions: Reactions; } export interface Attachments { perBoard: PerBoard; perCard: PerBoard; } export interface Boards { totalMembersPerBoard: PerBoard; } export interface PurpleCards { openPerBoard: PerBoard; openPerList: PerBoard; totalPerBoard: PerBoard; totalPerList: PerBoard; } export interface CustomFieldOptions { perField: PerBoard; } export interface CustomFields { perBoard: PerBoard; } export interface Lists { openPerBoard: PerBoard; totalPerBoard: PerBoard; } export interface List { id: IDMemberCreator; name: FullNameEnum; closed: boolean; pos: number; softLimit: null; creationMethod: null; idBoard: IDBoardEnum; limits: ListLimits; subscribed: boolean; } export interface ListLimits { cards: FluffyCards; } export interface FluffyCards { openPerList: PerBoard; totalPerList: PerBoard; } export interface MemberElement { id: IDMemberCreator; bio: string; bioData: null; confirmed: boolean; memberType: MemberType; username: Username; activityBlocked: boolean; avatarHash: AvatarHash; avatarUrl: string; fullName: FullNameEnum; idEnterprise: null; idEnterprisesDeactivated: any[]; idMemberReferrer: null; idPremOrgsAdmin: any[]; initials: Initials; nonPublic: NonPublic; nonPublicAvailable: boolean; products: any[]; url: string; status: string; } export interface Membership { id: string; idMember: IDMemberCreator; memberType: string; unconfirmed: boolean; deactivated: boolean; } export interface PluginDatum { id: string; idPlugin: string; scope: string; idModel: IDBoardEnum; value: string; access: string; } export interface Prefs { permissionLevel: string; hideVotes: boolean; voting: string; comments: string; invitations: string; selfJoin: boolean; cardCovers: boolean; isTemplate: boolean; cardAging: string; calendarFeedEnabled: boolean; background: string; backgroundImage: string; backgroundImageScaled: BackgroundImageScaled[]; backgroundTile: boolean; backgroundBrightness: Brightness; backgroundBottomColor: string; backgroundTopColor: string; canBePublic: boolean; canBeEnterprise: boolean; canBeOrg: boolean; canBePrivate: boolean; canInvite: boolean; } export interface BackgroundImageScaled { width: number; height: number; url: string; } ================================================ FILE: import/trello/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "noImplicitAny": true, "strict": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, "resolveJsonModule": true, "incremental": false, "outDir": "./dist", "moduleResolution": "node" }, "include": [ "." ], "exclude": [ ".git", "**/node_modules/*", "dist", "pack" ] } ================================================ FILE: import/trello/utils.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import * as crypto from 'crypto' class Utils { static createGuid(): string { function randomDigit() { if (crypto && crypto.randomBytes) { const rands = crypto.randomBytes(1) return (rands[0] % 16).toString(16) } return (Math.floor((Math.random() * 16))).toString(16) } return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit) } } export { Utils } ================================================ FILE: import/util/archive.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block} from '../../webapp/src/blocks/block' import {Board} from '../../webapp/src/blocks/board' interface ArchiveHeader { version: number date: number } // This schema allows the expansion of additional line types in the future interface ArchiveLine { type: string, data: unknown, } interface BlockArchiveLine extends ArchiveLine { type: 'block', data: Block } interface BoardArchiveLine extends ArchiveLine { type: 'board', data: Board } class ArchiveUtils { static buildBlockArchive(boards: readonly Board[], blocks: readonly Block[]): string { const header: ArchiveHeader = { version: 1, date: Date.now(), } const headerString = JSON.stringify(header) let content = headerString + '\n' for (const board of boards) { const line: BoardArchiveLine = { type: 'board', data: board, } const lineString = JSON.stringify(line) content += lineString content += '\n' } for (const block of blocks) { const line: BlockArchiveLine = { type: 'block', data: block, } const lineString = JSON.stringify(line) content += lineString content += '\n' } return content } static parseBlockArchive(contents: string): Block[] { const blocks: Block[] = [] const allLineStrings = contents.split('\n') if (allLineStrings.length >= 2) { const headerString = allLineStrings[0] const header = JSON.parse(headerString) as ArchiveHeader if (header.date && header.version >= 1) { const lineStrings = allLineStrings.slice(1) let lineNum = 2 for (const lineString of lineStrings) { if (!lineString) { // Ignore empty lines, e.g. last line continue } const line = JSON.parse(lineString) as ArchiveLine if (!line || !line.type || !line.data) { throw new Error(`ERROR parsing line ${lineNum}`) } switch (line.type) { case 'block': { const blockLine = line as BlockArchiveLine const block = blockLine.data blocks.push(block) break } } lineNum += 1 } } else { throw new Error('ERROR parsing header') } } return blocks } } export {ArchiveHeader, ArchiveLine, BlockArchiveLine, ArchiveUtils} ================================================ FILE: linux/Makefile ================================================ .PHONY: run run: go run -tags "json1 sqlite3" ./main.go build: mkdir -p bin go build -tags "json1 sqlite3" -o bin/focalboard-app ================================================ FILE: linux/go.mod ================================================ module github.com/mattermost/focalboard/linux go 1.21 toolchain go1.21.8 replace github.com/mattermost/focalboard/server => ../server require ( github.com/google/uuid v1.6.0 github.com/mattermost/focalboard/server v0.0.0-20230104182634-f909c2552e37 github.com/mattermost/mattermost/server/public v0.1.3 github.com/webview/webview v0.0.0-20220314230258-a2b7746141c3 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect github.com/fatih/color v1.17.0 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.6.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect github.com/mattermost/logr/v2 v2.0.21 // indirect github.com/mattermost/mattermost/server/v8 v8.0.0-20240529104128-9d30a62c9471 // indirect github.com/mattermost/morph v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minio-go/v7 v7.0.70 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oklog/run v1.1.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.53.0 // indirect github.com/prometheus/procfs v0.15.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.5.0 // indirect github.com/rudderlabs/analytics-go v3.3.3+incompatible // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/segmentio/backo-go v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.18.2 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.9.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tinylib/msgp v1.1.9 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/merror v1.0.5 // indirect github.com/wiggin77/srslog v1.0.1 // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect github.com/yuin/goldmark v1.7.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect google.golang.org/grpc v1.64.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect modernc.org/libc v1.50.9 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect modernc.org/sqlite v1.29.10 // indirect modernc.org/strutil v1.2.0 // indirect modernc.org/token v1.1.0 // indirect ) ================================================ FILE: linux/go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a h1:etIrTD8BQqzColk9nKRusM9um5+1q0iOEJLqfBMIK64= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go.mod h1:emQhSYTXqB0xxjLITTw4EaWZ+8IIQYw+kx9GqNUKdLg= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI= github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 h1:+AIlO01SKT9sfWU5CLWi0cfHc7dQwgGz3FhFRzXLoMg= github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94/go.mod h1:TcE3PIIkVWbP/HjhRAafgCjRKvDOi086iqp9VkNX/ng= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34= github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb2BTCsOdamENjjWCI6qmfHLbk6OZI= github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.21 h1:CMHsP+nrbRlEC4g7BwOk1GAnMtHkniFhlSQPXy52be4= github.com/mattermost/logr/v2 v2.0.21/go.mod h1:kZkB/zqKL9e+RY5gB3vGpsyenC+TpuiOenjMkvJJbzc= github.com/mattermost/mattermost/server/public v0.1.3 h1:A3hQ3rNCwHfKAVxe7Hk3Zd9p2pyUKRmxtRPnkWP5SFM= github.com/mattermost/mattermost/server/public v0.1.3/go.mod h1:PDPb/iqzJJ5ZvK/m70oDF55AXN/cOvVFj96Yu4e6j+Q= github.com/mattermost/mattermost/server/v8 v8.0.0-20240529104128-9d30a62c9471 h1:LxlvPGImhPoZ16qJtZHfooqfIG2UGsbcIRNiTqQ/5Is= github.com/mattermost/mattermost/server/v8 v8.0.0-20240529104128-9d30a62c9471/go.mod h1:qQjPPGKiugHw6Tunlmq3cVDkKFFbgtMxIvyNJoN+p3Y= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.70 h1:1u9NtMgfK1U42kUxcsl5v0yj6TEOPR497OAQxpJnn2g= github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI5Ek= github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rudderlabs/analytics-go v3.3.3+incompatible h1:OG0XlKoXfr539e2t1dXtTB+Gr89uFW+OUNQBVhHIIBY= github.com/rudderlabs/analytics-go v3.3.3+incompatible/go.mod h1:LF8/ty9kUX4PTY3l5c97K3nZZaX5Hwsvt+NBaRL/f30= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/segmentio/backo-go v1.1.0 h1:cJIfHQUdmLsd8t9IXqf5J8SdrOMn9vMa7cIvOavHAhc= github.com/segmentio/backo-go v1.1.0/go.mod h1:ckenwdf+v/qbyhVdNPWHnqh2YdJBED1O9cidYyM5J18= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU= github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/webview/webview v0.0.0-20220314230258-a2b7746141c3 h1:8joKgFslmiNmyA0Cvw/xgkdKZOYiXKsHlnG5OWmJEHA= github.com/webview/webview v0.0.0-20220314230258-a2b7746141c3/go.mod h1:rpXAuuHgyEJb6kXcXldlkOjU6y4x+YcASKKXJNUhh0Y= github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME= github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0= github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8= github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 h1:vpzMC/iZhYFAjJzHU0Cfuq+w1vLLsF2vLkDrPjzKYck= golang.org/x/exp v0.0.0-20240529005216-23cca8864a10/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs= modernc.org/ccgo/v4 v4.17.8/go.mod h1:buJnJ6Fn0tyAdP/dqePbrrvLyr6qslFfTbFrCuaYvtA= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/libc v1.50.9 h1:hIWf1uz55lorXQhfoEoezdUHjxzuO6ceshET/yWjSjk= modernc.org/libc v1.50.9/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= ================================================ FILE: linux/main.go ================================================ package main import ( "fmt" "log" "net" "os" "os/exec" "path" "path/filepath" "runtime" "github.com/google/uuid" "github.com/mattermost/focalboard/server/server" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/permissions/localpermissions" "github.com/webview/webview" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var sessionToken string = "su-" + uuid.New().String() func getFreePort() (int, error) { addr, err := net.ResolveTCPAddr("tcp", "localhost:0") if err != nil { return 0, err } l, err := net.ListenTCP("tcp", addr) if err != nil { return 0, err } defer l.Close() return l.Addr().(*net.TCPAddr).Port, nil } func runServer(port int) (*server.Server, error) { logger, _ := mlog.NewLogger() executable, _ := os.Executable() executableDir, _ := filepath.EvalSymlinks(filepath.Dir(executable)) config := &config.Configuration{ ServerRoot: fmt.Sprintf("http://localhost:%d", port), Port: port, DBType: "sqlite3", DBConfigString: path.Join(executableDir, "focalboard.db"), UseSSL: false, SecureCookie: true, WebPath: path.Join(executableDir, "pack"), FilesDriver: "local", FilesPath: path.Join(executableDir, "focalboard_files"), Telemetry: true, WebhookUpdate: []string{}, SessionExpireTime: 259200000000, SessionRefreshTime: 18000, LocalOnly: false, EnableLocalMode: false, LocalModeSocketLocation: "", AuthMode: "native", } singleUser := len(sessionToken) > 0 db, err := server.NewStore(config, singleUser, logger) if err != nil { fmt.Println("ERROR INITIALIZING THE SERVER STORE", err) return nil, err } permissionsService := localpermissions.New(db, logger) params := server.Params{ Cfg: config, SingleUserToken: sessionToken, DBStore: db, Logger: logger, ServerID: "", WSAdapter: nil, NotifyBackends: nil, PermissionsService: permissionsService, } server, err := server.New(params) if err != nil { fmt.Println("ERROR INITIALIZING THE SERVER", err) return nil, err } err = server.Start() if err != nil { return nil, err } return server, nil } func openBrowser(url string) { var err error switch runtime.GOOS { case "linux": err = exec.Command("xdg-open", url).Start() case "windows": err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() case "darwin": err = exec.Command("open", url).Start() default: err = fmt.Errorf("unsupported platform") } if err != nil { log.Fatal(err) } } func main() { debug := true w := webview.New(debug) defer w.Destroy() port, err := getFreePort() if err != nil { log.Println("Failed to open a free port") log.Fatal(err) } server, err := runServer(port) if err != nil { log.Println("Failed to start the server") log.Fatal(err) } w.SetTitle("Focalboard") w.SetSize(1024, 768, webview.HintNone) script := fmt.Sprintf("localStorage.setItem('focalboardSessionId', '%s');", sessionToken) w.Init(script) w.Navigate(fmt.Sprintf("http://localhost:%d", port)) w.Bind("openInNewBrowser", openBrowser) w.Init(` document.addEventListener('click', function (e) { let a = e.target.closest('a[target="_blank"]'); if (a) { openInNewBrowser(a.getAttribute('href')); } }); `) w.Run() server.Shutdown() } ================================================ FILE: mac/Focalboard/AppDelegate.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { static let serverStartedNotification = NSNotification.Name("serverStarted") private var serverProcess: Process? private weak var whatsnewWindow: NSWindow? var isServerStarted: Bool { get { return serverProcess != nil } } var serverPort = 8088 var sessionToken: String = "" func applicationDidFinishLaunching(_ aNotification: Notification) { copyResources() startServer() showWhatsNewDialogIfNeeded() NotificationCenter.default.post(name: AppDelegate.serverStartedNotification, object: nil) } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { guard flag else { openNewWindow(nil) return false } return true } func applicationWillTerminate(_ aNotification: Notification) { stopServer() } @IBAction func openNewWindow(_ sender: Any?) { let mainStoryBoard = NSStoryboard(name: "Main", bundle: nil) let windowController = mainStoryBoard.instantiateController(withIdentifier: "WindowController") as! NSWindowController windowController.showWindow(self) } private func showWhatsNewDialogIfNeeded() { if Globals.currentWhatsNewVersion > 0 && Globals.currentWhatsNewVersion < Globals.WhatsNewVersion { Globals.currentWhatsNewVersion = Globals.WhatsNewVersion showWhatsNew(self) } } @IBAction func showWhatsNew(_: AnyObject) { if let whatsnewWindow = self.whatsnewWindow { whatsnewWindow.close() self.whatsnewWindow = nil } let controller: WhatsNewViewController = NSStoryboard.main!.instantiateController(withIdentifier: "WhatsNewViewController") as! WhatsNewViewController let window = NSWindow(contentViewController: controller) self.whatsnewWindow = window window.makeKeyAndOrderFront(self) let vc = NSWindowController(window: window) vc.showWindow(self) } @IBAction func getCloudServer(_: AnyObject) { Globals.openGetCloudServerUrl() } private func webFolder() -> URL { let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return url.appendingPathComponent("Focalboard").appendingPathComponent("server") } private func copyResources() { let destBaseUrl = webFolder() do { try FileManager.default.createDirectory(atPath: destBaseUrl.path, withIntermediateDirectories: true, attributes: nil) } catch { NSLog("copyResources createDirectory ERROR") } copyResource(destBaseUrl: destBaseUrl, resourceRelativePath: "pack") copyResource(destBaseUrl: destBaseUrl, resourceRelativePath: "config.json") NSLog("copyResources OK") } private func copyResource(destBaseUrl: URL, resourceRelativePath: String, fileExtension: String = "") { let srcUrl = Bundle.main.url(forResource: "resources/" + resourceRelativePath, withExtension: fileExtension)! let destUrl = destBaseUrl.appendingPathComponent(resourceRelativePath) let fileManager = FileManager.default if fileManager.fileExists(atPath: destUrl.path) { do { try fileManager.removeItem(at: destUrl) } catch { NSLog("copyResource removeItem ERROR") } } do { try fileManager.copyItem(at: srcUrl, to: destUrl) } catch { NSLog("copyResource copyItem ERROR") return } } private func generateSessionToken() -> String { let bytesCount = 16 var randomNumber = "" var randomBytes = [UInt8](repeating: 0, count: bytesCount) let status = SecRandomCopyBytes(kSecRandomDefault, bytesCount, &randomBytes) if status != errSecSuccess { fatalError("SecRandomCopyBytes ERROR: \(status)") } randomNumber = randomBytes.map({String(format: "%02hhx", $0)}).joined(separator: "") return "su-" + randomNumber } private func getFreePort() { if PortUtils.isPortFree(in_port_t(serverPort)) { return } serverPort = Int(PortUtils.getFreePort()) } private func startServer() { getFreePort() sessionToken = generateSessionToken() let cwdUrl = webFolder() let executablePath = Bundle.main.path(forResource: "resources/bin/focalboard-server", ofType: "") let pid = ProcessInfo.processInfo.processIdentifier NSLog("pid: \(pid)") let serverProcess = Process() serverProcess.currentDirectoryPath = cwdUrl.path serverProcess.arguments = ["-monitorpid", "\(pid)", "-port", "\(serverPort)", "-single-user"] serverProcess.environment = ["FOCALBOARD_SINGLE_USER_TOKEN": sessionToken] serverProcess.launchPath = executablePath serverProcess.launch() self.serverProcess = serverProcess NSLog("startServer OK") NSLog("cwd: \(cwdUrl)") } private func stopServer() { guard let serverProcess = self.serverProcess else { return } serverProcess.terminate() self.serverProcess = nil NSLog("stopServer OK") } } ================================================ FILE: mac/Focalboard/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: mac/Focalboard/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "focalboard-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "focalboard-1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: mac/Focalboard/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: mac/Focalboard/AutoSaveWindowController.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Cocoa class AutoSaveWindowController: NSWindowController { override func windowDidLoad() { super.windowDidLoad() // Implement this method to handle any initialization after your window controller's window has been loaded from its nib file. self.windowFrameAutosaveName = NSWindow.FrameAutosaveName("AutoSaveWindow") } } ================================================ FILE: mac/Focalboard/Base.lproj/Main.storyboard ================================================ Default Left to Right Right to Left Default Left to Right Right to Left Cg Cg Thank you contributors! If you like what you see, please consider taking a moment to rate this version in the App Store. Your positive ratings make a real difference for us. Thanks so much, -- The Focalboard developers and community ================================================ FILE: mac/Focalboard/CustomWKWebView.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Foundation import WebKit class CustomWKWebView : WKWebView { override func performKeyEquivalent(with event: NSEvent) -> Bool { if (event.modifierFlags.contains(.command) && event.characters?.lowercased() != "z") || event.modifierFlags.contains(.control) || event.modifierFlags.contains(.option) { return super.performKeyEquivalent(with: event) } super.performKeyEquivalent(with: event) // Return true to prevent a "funk" error sound return true } } ================================================ FILE: mac/Focalboard/DownloadHandler.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Foundation import WebKit class DownloadHandler: NSObject, WKDownloadDelegate { func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { DispatchQueue.main.async { // Let user select location of file let savePanel = NSSavePanel() savePanel.canCreateDirectories = true savePanel.nameFieldStringValue = suggestedFilename // BUGBUG: Specifying the allowedFileTypes causes Catalina to hang / error out //savePanel.allowedFileTypes = [".boardsarchive"] savePanel.begin { (result) in if result.rawValue == NSApplication.ModalResponse.OK.rawValue, let fileUrl = savePanel.url { if (FileManager.default.fileExists(atPath: fileUrl.path)) { // HACKHACK: WKWebView doesn't appear to overwrite files, so delete exsiting files first do { try FileManager.default.removeItem(at: fileUrl) } catch { let alert = NSAlert() alert.messageText = "Unable to replace \(fileUrl.path)" alert.addButton(withTitle: "OK") alert.alertStyle = .warning alert.runModal() } } completionHandler(fileUrl) } } } } func downloadDidFinish(_ download: WKDownload) { NSLog("downloadDidFinish") } } ================================================ FILE: mac/Focalboard/Focalboard.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.files.user-selected.read-write com.apple.security.network.client com.apple.security.network.server ================================================ FILE: mac/Focalboard/Globals.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Foundation import Cocoa class Globals { static let ProductVersion = 70000 static let WhatsNewVersion = 70000 static var currentWhatsNewVersion: Int { get { return UserDefaults.standard.integer(forKey: "whatsNewVersion") } set { UserDefaults.standard.setValue(newValue, forKey: "whatsNewVersion") } } static func openGetCloudServerUrl() { let url = URL(string: "https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboardapp")! NSWorkspace.shared.open(url) } } ================================================ FILE: mac/Focalboard/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIconFile CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSApplicationCategoryType public.app-category.productivity LSFileQuarantineEnabled LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSAppTransportSecurity NSAllowsArbitraryLoads NSHumanReadableCopyright Copyright © 2021 Mattermost, Inc. All rights reserved. NSMainStoryboardFile Main NSPrincipalClass NSApplication ================================================ FILE: mac/Focalboard/Inherit.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.inherit ================================================ FILE: mac/Focalboard/PortUtils.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Foundation class PortUtils { static func isPortFree(_ port: in_port_t) -> Bool { let socketFileDescriptor = socket(AF_INET, SOCK_STREAM, 0) if socketFileDescriptor == -1 { return false } var addr = sockaddr_in() let sizeOfSockkAddr = MemoryLayout.size addr.sin_len = __uint8_t(sizeOfSockkAddr) addr.sin_family = sa_family_t(AF_INET) addr.sin_port = Int(OSHostByteOrder()) == OSLittleEndian ? _OSSwapInt16(port) : port addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) addr.sin_zero = (0, 0, 0, 0, 0, 0, 0, 0) var bind_addr = sockaddr() memcpy(&bind_addr, &addr, Int(sizeOfSockkAddr)) if Darwin.bind(socketFileDescriptor, &bind_addr, socklen_t(sizeOfSockkAddr)) == -1 { release(socket: socketFileDescriptor) return false } if listen(socketFileDescriptor, SOMAXCONN ) == -1 { release(socket: socketFileDescriptor) return false } release(socket: socketFileDescriptor) return true } private static func release(socket: Int32) { Darwin.shutdown(socket, SHUT_RDWR) close(socket) } static func getFreePort() -> in_port_t { var portNum: in_port_t = 0 for i in 50000..<65000 { let isFree = isPortFree(in_port_t(i)) if isFree { portNum = in_port_t(i) return portNum } } return in_port_t(0) } } ================================================ FILE: mac/Focalboard/ViewController.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Cocoa import WebKit class ViewController: NSViewController, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler { @IBOutlet var webView: CustomWKWebView! private var didLoad = false private var refreshWebViewOnLoad = true private let downloadHandler = DownloadHandler() override func viewDidLoad() { super.viewDidLoad() NSLog("viewDidLoad") webView.navigationDelegate = self webView.uiDelegate = self webView.isHidden = true webView.configuration.userContentController.add(self, name: "nativeApp") clearWebViewCache() // Load the home page if the server was started, otherwise wait until it has let appDelegate = NSApplication.shared.delegate as! AppDelegate if (appDelegate.isServerStarted) { updateSessionTokenAndUserSettings() loadHomepage() } // Do any additional setup after loading the view. NotificationCenter.default.addObserver(self, selector: #selector(onServerStarted), name: AppDelegate.serverStartedNotification, object: nil) } override func viewDidAppear() { super.viewDidAppear() self.view.window?.makeFirstResponder(self.webView) } override var representedObject: Any? { didSet { // Update the view, if already loaded. } } private func clearWebViewCache() { let websiteDataTypes = NSSet(array: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) let date = Date(timeIntervalSince1970: 0) WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set, modifiedSince: date, completionHandler:{ }) } @IBAction func showDiagnosticsInfo(_ sender: NSObject) { let appDelegate = NSApplication.shared.delegate as! AppDelegate let alert: NSAlert = NSAlert() alert.messageText = "Diagnostics info" alert.informativeText = "Port: \(appDelegate.serverPort)" alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } @objc func onServerStarted() { NSLog("onServerStarted") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.updateSessionTokenAndUserSettings() self.loadHomepage() } } private func updateSessionTokenAndUserSettings() { let appDelegate = NSApplication.shared.delegate as! AppDelegate let sessionTokenScript = WKUserScript( source: "localStorage.setItem('focalboardSessionId', '\(appDelegate.sessionToken)');", injectionTime: .atDocumentStart, forMainFrameOnly: true ) let blob = UserDefaults.standard.string(forKey: "localStorage") ?? "" let userSettingsScript = WKUserScript( source: "const NativeApp = { settingsBlob: \"\(blob)\" };", injectionTime: .atDocumentStart, forMainFrameOnly: true ) webView.configuration.userContentController.removeAllUserScripts() webView.configuration.userContentController.addUserScript(sessionTokenScript) webView.configuration.userContentController.addUserScript(userSettingsScript) } private func loadHomepage() { NSLog("loadHomepage") let appDelegate = NSApplication.shared.delegate as! AppDelegate let port = appDelegate.serverPort let url = URL(string: "http://localhost:\(port)/")! let request = URLRequest(url: url) refreshWebViewOnLoad = true webView.load(request) } func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) { NSLog("webView runOpenPanel") let openPanel = NSOpenPanel() openPanel.canChooseFiles = true openPanel.begin { (result) in if result == NSApplication.ModalResponse.OK { if let url = openPanel.url { completionHandler([url]) } } else if result == NSApplication.ModalResponse.cancel { completionHandler(nil) } } } // Handle downloads func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { if navigationAction.shouldPerformDownload { decisionHandler(.download, preferences) } else { decisionHandler(.allow, preferences) } } func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { if navigationResponse.canShowMIMEType { decisionHandler(.allow) } else { decisionHandler(.download) } } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { download.delegate = downloadHandler } func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { download.delegate = downloadHandler } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { NSLog("webView didFinish navigation: \(webView.url?.absoluteString ?? "")") // Disable right-click menu webView.evaluateJavaScript("document.body.setAttribute('oncontextmenu', 'event.preventDefault();');", completionHandler: nil) webView.isHidden = false didLoad = true // HACKHACK: Fix WebView initial rendering artifacts if (refreshWebViewOnLoad) { refreshWebViewOnLoad = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { self.refreshWebView() }) } } // HACKHACK: Fix WebView initial rendering artifacts private func refreshWebView() { let frame = self.webView.frame var frame2 = frame frame2.size.height += 1 self.webView.frame = frame2 self.webView.frame = frame } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { NSLog("webView didFailProvisionalNavigation, error: \(error.localizedDescription)") if (!didLoad) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.updateSessionTokenAndUserSettings() self.loadHomepage() } } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { webView.isHidden = false } func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let frame = navigationAction.targetFrame, frame.isMainFrame { return nil } // for _blank target or non-mainFrame target, open in default browser if let url = navigationAction.request.url { NSWorkspace.shared.open(url) } return nil } @IBAction func navigateToHome(_ sender: NSObject) { loadHomepage() } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [AnyHashable: Any], let type = body["type"] as? String, let blob = body["settingsBlob"] as? String else { NSLog("Received unexpected script message \(message.body)") return } NSLog("Received script message \(type)") switch type { case "didImportUserSettings": NSLog("Imported user settings keys \(body["keys"] ?? "?")") case "didNotImportUserSettings": break case "didChangeUserSettings": UserDefaults.standard.set(blob, forKey: "localStorage") NSLog("Persisted user settings after change for key \(body["key"] ?? "?")") default: NSLog("Received script message of unknown type \(type)") } if let settings = Data(base64Encoded: blob).flatMap({ try? JSONSerialization.jsonObject(with: $0, options: []) }) { NSLog("Current user settings: \(settings)") } } } ================================================ FILE: mac/Focalboard/WhatsNewViewController.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Cocoa class WhatsNewViewController: NSViewController { @IBOutlet var textView: NSTextView! @IBOutlet var rateButton: NSButton! @IBOutlet var cloudButton: NSButton! override func viewDidLoad() { super.viewDidLoad() loadText() } private func loadText() { guard let fileUrl = Bundle.main.url(forResource: "whatsnew", withExtension: "txt") else { assertionFailure("whatsnew"); return } guard let text = try? String(contentsOf: fileUrl, encoding: .utf8) else { assertionFailure("whatsnew"); return } textView.string = text textView.textStorage?.font = NSFont.systemFont(ofSize: 13) textView.textStorage?.foregroundColor = NSColor.textColor } @IBAction func rateButtonClicked(_ sender: Any) { let url = URL(string: "macappstore://itunes.apple.com/app/id1556908618?action=write-review")! NSWorkspace.shared.open(url) view.window?.close() } @IBAction func cloudButtonClicked(_ sender: Any) { Globals.openGetCloudServerUrl() } @IBAction func closeButtonClicked(_ sender: Any) { view.window?.close() } } ================================================ FILE: mac/Focalboard/whatsnew.txt ================================================ Welcome to Focalboard v7.2! Mattermost Boards is now availalbe as a Cloud service. Set up your free server via the button below to collaborate with your team. Mattermost Boards combines all the features of Focalboard with real-time collaboration, calls, and playbooks. You can export boards from Focalboard and import them into Mattermost Boards, and pick up where you left off. Thank you contributors! Recent improvements include: * Calendar view. Thanks @sbishel! If you like what you see, please consider taking a moment to rate this version in the App Store. Your positive ratings make a real difference for us. Thanks so much, -- The Focalboard developers and community ================================================ FILE: mac/Focalboard.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 50; objects = { /* Begin PBXBuildFile section */ 8014951C261598D600A51700 /* PortUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8014951B261598D600A51700 /* PortUtils.swift */; }; 804E57FC27441B6B008526F0 /* whatsnew.txt in Resources */ = {isa = PBXBuildFile; fileRef = 804E57FB27441B6B008526F0 /* whatsnew.txt */; }; 80672A8B27BAFEBA00257B8C /* DownloadHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80672A8A27BAFEBA00257B8C /* DownloadHandler.swift */; }; 80D6DEBB252E13CB00AEED9E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6DEBA252E13CB00AEED9E /* AppDelegate.swift */; }; 80D6DEBD252E13CB00AEED9E /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6DEBC252E13CB00AEED9E /* ViewController.swift */; }; 80D6DEBF252E13CD00AEED9E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 80D6DEBE252E13CD00AEED9E /* Assets.xcassets */; }; 80D6DEC2252E13CD00AEED9E /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 80D6DEC0252E13CD00AEED9E /* Main.storyboard */; }; 80D6DECE252E13CD00AEED9E /* FocalboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6DECD252E13CD00AEED9E /* FocalboardTests.swift */; }; 80D6DED9252E13CD00AEED9E /* FocalboardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6DED8252E13CD00AEED9E /* FocalboardUITests.swift */; }; 80D6DEEA252E15D100AEED9E /* resources in Resources */ = {isa = PBXBuildFile; fileRef = 80D6DEE9252E15D100AEED9E /* resources */; }; 80D6DF18252F9BDE00AEED9E /* AutoSaveWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6DF17252F9BDE00AEED9E /* AutoSaveWindowController.swift */; }; 80F174B72788C1A2000A9EEA /* CustomWKWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F174B62788C1A2000A9EEA /* CustomWKWebView.swift */; }; 80F8BF502624E1BB00FF3943 /* WhatsNewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8BF4F2624E1BB00FF3943 /* WhatsNewViewController.swift */; }; 80F8BF582624EB0C00FF3943 /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8BF572624EB0C00FF3943 /* Globals.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 80D6DECA252E13CD00AEED9E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 80D6DEAF252E13CB00AEED9E /* Project object */; proxyType = 1; remoteGlobalIDString = 80D6DEB6252E13CB00AEED9E; remoteInfo = Focalboard; }; 80D6DED5252E13CD00AEED9E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 80D6DEAF252E13CB00AEED9E /* Project object */; proxyType = 1; remoteGlobalIDString = 80D6DEB6252E13CB00AEED9E; remoteInfo = Focalboard; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ 8014951B261598D600A51700 /* PortUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortUtils.swift; sourceTree = ""; }; 804E57FB27441B6B008526F0 /* whatsnew.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = whatsnew.txt; sourceTree = ""; }; 80672A8A27BAFEBA00257B8C /* DownloadHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadHandler.swift; sourceTree = ""; }; 80D6DEB7252E13CB00AEED9E /* Focalboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Focalboard.app; sourceTree = BUILT_PRODUCTS_DIR; }; 80D6DEBA252E13CB00AEED9E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 80D6DEBC252E13CB00AEED9E /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 80D6DEBE252E13CD00AEED9E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 80D6DEC1252E13CD00AEED9E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 80D6DEC3252E13CD00AEED9E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 80D6DEC4252E13CD00AEED9E /* Focalboard.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Focalboard.entitlements; sourceTree = ""; }; 80D6DEC9252E13CD00AEED9E /* FocalboardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FocalboardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 80D6DECD252E13CD00AEED9E /* FocalboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocalboardTests.swift; sourceTree = ""; }; 80D6DECF252E13CD00AEED9E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 80D6DED4252E13CD00AEED9E /* FocalboardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FocalboardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 80D6DED8252E13CD00AEED9E /* FocalboardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocalboardUITests.swift; sourceTree = ""; }; 80D6DEDA252E13CD00AEED9E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 80D6DEE9252E15D100AEED9E /* resources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = resources; sourceTree = ""; }; 80D6DF17252F9BDE00AEED9E /* AutoSaveWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSaveWindowController.swift; sourceTree = ""; }; 80D6DF1C25324A4F00AEED9E /* Inherit.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Inherit.entitlements; sourceTree = ""; }; 80F174B62788C1A2000A9EEA /* CustomWKWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWKWebView.swift; sourceTree = ""; }; 80F8BF4F2624E1BB00FF3943 /* WhatsNewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewViewController.swift; sourceTree = ""; }; 80F8BF572624EB0C00FF3943 /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 80D6DEB4252E13CB00AEED9E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 80D6DEC6252E13CD00AEED9E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 80D6DED1252E13CD00AEED9E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 80D6DEAE252E13CB00AEED9E = { isa = PBXGroup; children = ( 80D6DEB9252E13CB00AEED9E /* Focalboard */, 80D6DECC252E13CD00AEED9E /* FocalboardTests */, 80D6DEE9252E15D100AEED9E /* resources */, 80D6DED7252E13CD00AEED9E /* FocalboardUITests */, 80D6DEB8252E13CB00AEED9E /* Products */, ); sourceTree = ""; }; 80D6DEB8252E13CB00AEED9E /* Products */ = { isa = PBXGroup; children = ( 80D6DEB7252E13CB00AEED9E /* Focalboard.app */, 80D6DEC9252E13CD00AEED9E /* FocalboardTests.xctest */, 80D6DED4252E13CD00AEED9E /* FocalboardUITests.xctest */, ); name = Products; sourceTree = ""; }; 80D6DEB9252E13CB00AEED9E /* Focalboard */ = { isa = PBXGroup; children = ( 80D6DEBA252E13CB00AEED9E /* AppDelegate.swift */, 80F8BF572624EB0C00FF3943 /* Globals.swift */, 8014951B261598D600A51700 /* PortUtils.swift */, 80D6DEBC252E13CB00AEED9E /* ViewController.swift */, 80672A8A27BAFEBA00257B8C /* DownloadHandler.swift */, 80F174B62788C1A2000A9EEA /* CustomWKWebView.swift */, 80F8BF4F2624E1BB00FF3943 /* WhatsNewViewController.swift */, 804E57FB27441B6B008526F0 /* whatsnew.txt */, 80D6DF17252F9BDE00AEED9E /* AutoSaveWindowController.swift */, 80D6DEBE252E13CD00AEED9E /* Assets.xcassets */, 80D6DEC0252E13CD00AEED9E /* Main.storyboard */, 80D6DEC3252E13CD00AEED9E /* Info.plist */, 80D6DEC4252E13CD00AEED9E /* Focalboard.entitlements */, 80D6DF1C25324A4F00AEED9E /* Inherit.entitlements */, ); path = Focalboard; sourceTree = ""; }; 80D6DECC252E13CD00AEED9E /* FocalboardTests */ = { isa = PBXGroup; children = ( 80D6DECD252E13CD00AEED9E /* FocalboardTests.swift */, 80D6DECF252E13CD00AEED9E /* Info.plist */, ); path = FocalboardTests; sourceTree = ""; }; 80D6DED7252E13CD00AEED9E /* FocalboardUITests */ = { isa = PBXGroup; children = ( 80D6DED8252E13CD00AEED9E /* FocalboardUITests.swift */, 80D6DEDA252E13CD00AEED9E /* Info.plist */, ); path = FocalboardUITests; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 80D6DEB6252E13CB00AEED9E /* Focalboard */ = { isa = PBXNativeTarget; buildConfigurationList = 80D6DEDD252E13CD00AEED9E /* Build configuration list for PBXNativeTarget "Focalboard" */; buildPhases = ( 80D6DEB3252E13CB00AEED9E /* Sources */, 80D6DEB4252E13CB00AEED9E /* Frameworks */, 80D6DEB5252E13CB00AEED9E /* Resources */, 80D6DF1D25324A8100AEED9E /* Run Script */, ); buildRules = ( ); dependencies = ( ); name = Focalboard; productName = Focalboard; productReference = 80D6DEB7252E13CB00AEED9E /* Focalboard.app */; productType = "com.apple.product-type.application"; }; 80D6DEC8252E13CD00AEED9E /* FocalboardTests */ = { isa = PBXNativeTarget; buildConfigurationList = 80D6DEE0252E13CD00AEED9E /* Build configuration list for PBXNativeTarget "FocalboardTests" */; buildPhases = ( 80D6DEC5252E13CD00AEED9E /* Sources */, 80D6DEC6252E13CD00AEED9E /* Frameworks */, 80D6DEC7252E13CD00AEED9E /* Resources */, ); buildRules = ( ); dependencies = ( 80D6DECB252E13CD00AEED9E /* PBXTargetDependency */, ); name = FocalboardTests; productName = FocalboardTests; productReference = 80D6DEC9252E13CD00AEED9E /* FocalboardTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 80D6DED3252E13CD00AEED9E /* FocalboardUITests */ = { isa = PBXNativeTarget; buildConfigurationList = 80D6DEE3252E13CD00AEED9E /* Build configuration list for PBXNativeTarget "FocalboardUITests" */; buildPhases = ( 80D6DED0252E13CD00AEED9E /* Sources */, 80D6DED1252E13CD00AEED9E /* Frameworks */, 80D6DED2252E13CD00AEED9E /* Resources */, ); buildRules = ( ); dependencies = ( 80D6DED6252E13CD00AEED9E /* PBXTargetDependency */, ); name = FocalboardUITests; productName = FocalboardUITests; productReference = 80D6DED4252E13CD00AEED9E /* FocalboardUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 80D6DEAF252E13CB00AEED9E /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1200; LastUpgradeCheck = 1200; TargetAttributes = { 80D6DEB6252E13CB00AEED9E = { CreatedOnToolsVersion = 12.0; }; 80D6DEC8252E13CD00AEED9E = { CreatedOnToolsVersion = 12.0; TestTargetID = 80D6DEB6252E13CB00AEED9E; }; 80D6DED3252E13CD00AEED9E = { CreatedOnToolsVersion = 12.0; TestTargetID = 80D6DEB6252E13CB00AEED9E; }; }; }; buildConfigurationList = 80D6DEB2252E13CB00AEED9E /* Build configuration list for PBXProject "Focalboard" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 80D6DEAE252E13CB00AEED9E; productRefGroup = 80D6DEB8252E13CB00AEED9E /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 80D6DEB6252E13CB00AEED9E /* Focalboard */, 80D6DEC8252E13CD00AEED9E /* FocalboardTests */, 80D6DED3252E13CD00AEED9E /* FocalboardUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 80D6DEB5252E13CB00AEED9E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 80D6DEBF252E13CD00AEED9E /* Assets.xcassets in Resources */, 80D6DEEA252E15D100AEED9E /* resources in Resources */, 80D6DEC2252E13CD00AEED9E /* Main.storyboard in Resources */, 804E57FC27441B6B008526F0 /* whatsnew.txt in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 80D6DEC7252E13CD00AEED9E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 80D6DED2252E13CD00AEED9E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 80D6DF1D25324A8100AEED9E /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); name = "Run Script"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "# Un-comment this to enable codesign\n# /usr/bin/codesign --force --timestamp --options runtime --sign \"$CODE_SIGN_IDENTITY\" -i \"com.mattermost.focalboardServer\" --entitlement \"$PROJECT_DIR/Focalboard/Inherit.entitlements\" \"$BUILD_DIR/$CONFIGURATION/$EXECUTABLE_FOLDER_PATH/../Resources/resources/bin/focalboard-server\"\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 80D6DEB3252E13CB00AEED9E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 8014951C261598D600A51700 /* PortUtils.swift in Sources */, 80F8BF502624E1BB00FF3943 /* WhatsNewViewController.swift in Sources */, 80F174B72788C1A2000A9EEA /* CustomWKWebView.swift in Sources */, 80F8BF582624EB0C00FF3943 /* Globals.swift in Sources */, 80672A8B27BAFEBA00257B8C /* DownloadHandler.swift in Sources */, 80D6DF18252F9BDE00AEED9E /* AutoSaveWindowController.swift in Sources */, 80D6DEBD252E13CB00AEED9E /* ViewController.swift in Sources */, 80D6DEBB252E13CB00AEED9E /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 80D6DEC5252E13CD00AEED9E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 80D6DECE252E13CD00AEED9E /* FocalboardTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 80D6DED0252E13CD00AEED9E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 80D6DED9252E13CD00AEED9E /* FocalboardUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 80D6DECB252E13CD00AEED9E /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 80D6DEB6252E13CB00AEED9E /* Focalboard */; targetProxy = 80D6DECA252E13CD00AEED9E /* PBXContainerItemProxy */; }; 80D6DED6252E13CD00AEED9E /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 80D6DEB6252E13CB00AEED9E /* Focalboard */; targetProxy = 80D6DED5252E13CD00AEED9E /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ 80D6DEC0252E13CD00AEED9E /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 80D6DEC1252E13CD00AEED9E /* Base */, ); name = Main.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 80D6DEDB252E13CD00AEED9E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 11.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 80D6DEDC252E13CD00AEED9E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 11.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; }; 80D6DEDE252E13CD00AEED9E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Focalboard/Focalboard.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = HFP57A3MYB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Focalboard/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 7.3; PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboard; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; name = Debug; }; 80D6DEDF252E13CD00AEED9E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Focalboard/Focalboard.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = HFP57A3MYB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Focalboard/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 7.3; PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboard; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; name = Release; }; 80D6DEE1252E13CD00AEED9E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = UQ8HT4Q2XM; INFOPLIST_FILE = FocalboardTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Focalboard.app/Contents/MacOS/Focalboard"; }; name = Debug; }; 80D6DEE2252E13CD00AEED9E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = UQ8HT4Q2XM; INFOPLIST_FILE = FocalboardTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Focalboard.app/Contents/MacOS/Focalboard"; }; name = Release; }; 80D6DEE4252E13CD00AEED9E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = UQ8HT4Q2XM; INFOPLIST_FILE = FocalboardUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboardUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = Focalboard; }; name = Debug; }; 80D6DEE5252E13CD00AEED9E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = UQ8HT4Q2XM; INFOPLIST_FILE = FocalboardUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboardUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = Focalboard; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 80D6DEB2252E13CB00AEED9E /* Build configuration list for PBXProject "Focalboard" */ = { isa = XCConfigurationList; buildConfigurations = ( 80D6DEDB252E13CD00AEED9E /* Debug */, 80D6DEDC252E13CD00AEED9E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 80D6DEDD252E13CD00AEED9E /* Build configuration list for PBXNativeTarget "Focalboard" */ = { isa = XCConfigurationList; buildConfigurations = ( 80D6DEDE252E13CD00AEED9E /* Debug */, 80D6DEDF252E13CD00AEED9E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 80D6DEE0252E13CD00AEED9E /* Build configuration list for PBXNativeTarget "FocalboardTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 80D6DEE1252E13CD00AEED9E /* Debug */, 80D6DEE2252E13CD00AEED9E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 80D6DEE3252E13CD00AEED9E /* Build configuration list for PBXNativeTarget "FocalboardUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( 80D6DEE4252E13CD00AEED9E /* Debug */, 80D6DEE5252E13CD00AEED9E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 80D6DEAF252E13CB00AEED9E /* Project object */; } ================================================ FILE: mac/Focalboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: mac/Focalboard.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: mac/Focalboard.xcodeproj/xcshareddata/xcschemes/Focalboard.xcscheme ================================================ ================================================ FILE: mac/Focalboard.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: mac/Focalboard.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: mac/FocalboardTests/FocalboardTests.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import XCTest @testable import Focalboard class FocalboardTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() throws { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } } ================================================ FILE: mac/FocalboardTests/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion 1 NSAppTransportSecurity NSAllowsArbitraryLoads NSExceptionDomains 127.0.0.1 NSExceptionAllowsInsecureHTTPLoads localhost NSExceptionAllowsInsecureHTTPLoads ================================================ FILE: mac/FocalboardUITests/FocalboardUITests.swift ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import XCTest class FocalboardUITests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // UI tests must launch the application that they test. let app = XCUIApplication() app.launch() // Use recording to get started writing UI tests. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } } } ================================================ FILE: mac/FocalboardUITests/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion 1 ================================================ FILE: mac/README.md ================================================ # Focalboard Mac Personal Desktop This folder contains the code for the Mac Personal Desktop. It packages a lightweight Swift Mac App with the Mac build of the server, and the webapp. The server is run in a single-user mode. ## Debugging in Xcode Open `Focalboard.xcworkspace` in Xcode to debug it. To debug the client webapp: 1. Run the Focalboard desktop app from Xcode 2. Open Safari 3. Enable Safari's [developer tools] 4. Select the Focalboard app from the develop menu, under your computer's name ### Testing the single-user server You can also run the server in single-user mode and connect to it via a browser: 1. Run `FOCALBOARD_SINGLE_USER_TOKEN=testtest make watch-single-user` * This runs the server with the `-single-user` flag * Alternatively, select `Go: Launch Single-user Server` from VSCode's run and debug options 2. Open a browser to `http://localhost:8000` 3. Open the browser developer tools to Application \ Local Storage \ localhost:8000 4. Set `focalboardSessionId` to `testtest` 5. Navigate to `http://localhost:8000` ================================================ FILE: mac/export.plist ================================================ compileBitcode method development signingStyle automatic stripSwiftSymbols thinning <none> ================================================ FILE: modd-servertest.conf ================================================ **/*.go { prep: cd server && go test -tags "$FOCALBOARD_BUILD_TAGS" -race -v ./... } ================================================ FILE: modd.conf ================================================ **/*.go !**/*_test.go { prep: cd server && go build -tags "$FOCALBOARD_BUILD_TAGS" -o ../bin/focalboard-server ./main daemon +sigterm: ./bin/focalboard-server $FOCALBOARDSERVER_ARGS } { daemon: cd webapp && npm run watchdev } ================================================ FILE: noticegen/Readme.md ================================================ # Notice.txt File Configuration We are automatically generating Notice.txt by using first-level dependencies of the project. The related pipeline uses `config.yaml` stored in this folder. ## Configuration Sample: ``` title: "Mattermost Playbooks" copyright: "©2015-present Mattermost, Inc. All Rights Reserved. See LICENSE for license information." description: "This document includes a list of open source components used in Mattermost Playbooks, including those that have been modified." search: - "go.mod" - "client/go.mod" dependencies: [] devDependencies: [] ``` | Field | Type | Purpose | | :-- | :-- | :-- | | title | string | Field content will be used as a title of the application. See first line of `NOTICE.txt` file. | | copyright | string | Field content will be used as a copyright message. See second line of `NOTICE.txt` file. | | description | string | Field content will be used as notice file description. See third line of `NOTICE.txt` file. | | dependencies | array | If any dependency name mentioned, it will be automatically added even if it is not a first-level dependency. | | devDependencies | array | If any dependency name mentioned, it will be added when it is referenced in devDependency section. | | search | array | Pipeline will search for package.json/go.mod files mentioned here. Globstar format is supported ie. `x/**/go.mod`. | ================================================ FILE: noticegen/config.yaml ================================================ --- title: "Mattermost Focalboard" copyright: "©2015-present Mattermost,Inc. All Rights Reserved. See LICENSE for license information." description: "This document includes a list of open source components used in Mattermost Focalboard, including those that have been modified." search: - "server/go.mod" - "linux/go.mod" dependencies: [] devDependencies: [] ... ================================================ FILE: pull_request_template.md ================================================ #### Summary #### Ticket Link ================================================ FILE: responsible_disclosure_policy.md ================================================ # Responsible Disclosure Policy Safety and data security are of utmost priority for the Focalboard community. If you are a security researcher and have discovered a security vulnerability in our codebase, we appreciate your help in disclosing it to us in a responsible manner. Please contact us at `chen [at] mattermost.com` to report any security vulnerabilities found in our open source codebase. Please refrain from requesting compensation for reporting vulnerabilities. We will acknowledge receipt of your vulnerability report and send you regular updates about our progress. If your report is reproducible as an exploit and results in a change to the codebase or documentation of a Focalboard product, we will–-at your option–-publicly acknowledge your responsible disclosure. After a fix is made, we ask security researchers to wait 30 days after a release before announcing the specific details of a vulnerability, and to provide Focalboard with a link to any such announcements. In releases containing security fixes, Focalboard announces an update is available, acknowledges the contributions of security researchers, and it withholds specific details until 30 days after availability to give time for the community to apply updates. You are not allowed to search for vulnerabilities on any instance of Focalboard hosted by the team, users, or customers with the exception of non-disruptive testing on the community test server mentioned above. Focalboard is open source software, you can install a copy yourself and test against that. Many thanks to the security researchers who have responsibly contributed their findings to make the Focalboard code base more secure (listed by number of contributions, then alphabetically). Security Research Hall of Fame: - [To be announced] ================================================ FILE: server/.golangci.yml ================================================ run: timeout: 5m modules-download-mode: readonly skip-dirs: - services/store/sqlstore/migrations linters-settings: gofmt: simplify: true govet: check-shadowing: true enable-all: true disable: - fieldalignment lll: line-length: 180 dupl: threshold: 200 revive: enableAllRules: true rules: - name: exported disabled: true linters: disable-all: true enable: - gofmt - goimports - ineffassign - unparam - errcheck - govet - bodyclose - durationcheck - errorlint - exhaustive - exportloopref - gosec - makezero - staticcheck - prealloc - asciicheck - dogsled - dupl - goconst - gocritic - godot - err113 - goheader - revive - nakedret - gomodguard - goprintffuncname - gosimple - lll - misspell - nolintlint - stylecheck - typecheck - unconvert - unused - whitespace - gocyclo ================================================ FILE: server/admin-scripts/reset-password.sh ================================================ #!/bin/bash if [[ $# < 2 ]] ; then echo 'reset-password.sh ' exit 1 fi curl --unix-socket /var/tmp/focalboard_local.socket http://localhost/api/v2/admin/users/$1/password -X POST -H 'Content-Type: application/json' -d '{ "password": "'$2'" }' ================================================ FILE: server/api/admin.go ================================================ package api import ( "encoding/json" "io" "net/http" "strings" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type AdminSetPasswordData struct { Password string `json:"password"` } func (a *API) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) username := vars["username"] requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var requestData AdminSetPasswordData err = json.Unmarshal(requestBody, &requestData) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "adminSetPassword", audit.Fail) defer a.audit.LogRecord(audit.LevelAuth, auditRec) auditRec.AddMeta("username", username) if !strings.Contains(requestData.Password, "") { a.errorResponse(w, r, model.NewErrBadRequest("password is required")) return } err = a.app.UpdateUserPassword(username, requestData.Password) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("AdminSetPassword, username: %s", mlog.String("username", username)) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } ================================================ FILE: server/api/api.go ================================================ package api import ( "encoding/json" "errors" "fmt" "net/http" "runtime/debug" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/app" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/focalboard/server/services/permissions" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( HeaderRequestedWith = "X-Requested-With" HeaderRequestedWithXML = "XMLHttpRequest" UploadFormFileKey = "file" True = "true" ErrorNoTeamCode = 1000 ErrorNoTeamMessage = "No team" ) var ( ErrHandlerPanic = errors.New("http handler panic") ) // ---------------------------------------------------------------------------------------------------- // REST APIs type API struct { app *app.App authService string permissions permissions.PermissionsService singleUserToken string MattermostAuth bool logger mlog.LoggerIFace audit *audit.Audit } func NewAPI( app *app.App, singleUserToken string, authService string, permissions permissions.PermissionsService, logger mlog.LoggerIFace, audit *audit.Audit, ) *API { return &API{ app: app, singleUserToken: singleUserToken, authService: authService, permissions: permissions, logger: logger, audit: audit, } } func (a *API) RegisterRoutes(r *mux.Router) { apiv2 := r.PathPrefix("/api/v2").Subrouter() apiv2.Use(a.panicHandler) apiv2.Use(a.requireCSRFToken) /* ToDo: apiv3 := r.PathPrefix("/api/v3").Subrouter() apiv3.Use(a.panicHandler) apiv3.Use(a.requireCSRFToken) */ // V2 routes (ToDo: migrate these to V3 when ready to ship V3) a.registerUsersRoutes(apiv2) a.registerAuthRoutes(apiv2) a.registerMembersRoutes(apiv2) a.registerCategoriesRoutes(apiv2) a.registerSharingRoutes(apiv2) a.registerTeamsRoutes(apiv2) a.registerAchivesRoutes(apiv2) a.registerSubscriptionsRoutes(apiv2) a.registerFilesRoutes(apiv2) a.registerOnboardingRoutes(apiv2) a.registerSearchRoutes(apiv2) a.registerConfigRoutes(apiv2) a.registerBoardsAndBlocksRoutes(apiv2) a.registerChannelsRoutes(apiv2) a.registerTemplatesRoutes(apiv2) a.registerBoardsRoutes(apiv2) a.registerBlocksRoutes(apiv2) a.registerContentBlocksRoutes(apiv2) a.registerStatisticsRoutes(apiv2) a.registerComplianceRoutes(apiv2) // V3 routes a.registerCardsRoutes(apiv2) // System routes are outside the /api/v2 path a.registerSystemRoutes(r) } func (a *API) RegisterAdminRoutes(r *mux.Router) { r.HandleFunc("/api/v2/admin/users/{username}/password", a.adminRequired(a.handleAdminSetPassword)).Methods("POST") } func getUserID(r *http.Request) string { ctx := r.Context() session, ok := ctx.Value(sessionContextKey).(*model.Session) if !ok { return "" } return session.UserID } func (a *API) panicHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if p := recover(); p != nil { a.logger.Error("Http handler panic", mlog.Any("panic", p), mlog.String("stack", string(debug.Stack())), mlog.String("uri", r.URL.Path), ) a.errorResponse(w, r, ErrHandlerPanic) } }() next.ServeHTTP(w, r) }) } func (a *API) requireCSRFToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !a.checkCSRFToken(r) { a.logger.Error("checkCSRFToken FAILED") a.errorResponse(w, r, model.NewErrBadRequest("checkCSRFToken FAILED")) return } next.ServeHTTP(w, r) }) } func (a *API) checkCSRFToken(r *http.Request) bool { token := r.Header.Get(HeaderRequestedWith) return token == HeaderRequestedWithXML } func (a *API) hasValidReadTokenForBoard(r *http.Request, boardID string) bool { query := r.URL.Query() readToken := query.Get("read_token") if len(readToken) < 1 { return false } isValid, err := a.app.IsValidReadToken(boardID, readToken) if err != nil { a.logger.Error("IsValidReadTokenForBoard ERROR", mlog.Err(err)) return false } return isValid } func (a *API) userIsGuest(userID string) (bool, error) { if a.singleUserToken != "" { return false, nil } return a.app.UserIsGuest(userID) } // Response helpers func (a *API) errorResponse(w http.ResponseWriter, r *http.Request, err error) { a.logger.Error(err.Error()) errorResponse := model.ErrorResponse{Error: err.Error()} switch { case model.IsErrBadRequest(err): errorResponse.ErrorCode = http.StatusBadRequest case model.IsErrUnauthorized(err): errorResponse.ErrorCode = http.StatusUnauthorized case model.IsErrForbidden(err): errorResponse.ErrorCode = http.StatusForbidden case model.IsErrNotFound(err): errorResponse.ErrorCode = http.StatusNotFound case model.IsErrRequestEntityTooLarge(err): errorResponse.ErrorCode = http.StatusRequestEntityTooLarge case model.IsErrNotImplemented(err): errorResponse.ErrorCode = http.StatusNotImplemented default: a.logger.Error("API ERROR", mlog.Int("code", http.StatusInternalServerError), mlog.Err(err), mlog.String("api", r.URL.Path), ) errorResponse.Error = "internal server error" errorResponse.ErrorCode = http.StatusInternalServerError } setResponseHeader(w, "Content-Type", "application/json") data, err := json.Marshal(errorResponse) if err != nil { data = []byte("{}") } w.WriteHeader(errorResponse.ErrorCode) _, _ = w.Write(data) } func stringResponse(w http.ResponseWriter, message string) { setResponseHeader(w, "Content-Type", "text/plain") _, _ = fmt.Fprint(w, message) } func jsonStringResponse(w http.ResponseWriter, code int, message string) { //nolint:unparam setResponseHeader(w, "Content-Type", "application/json") w.WriteHeader(code) fmt.Fprint(w, message) } func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) { //nolint:unparam setResponseHeader(w, "Content-Type", "application/json") w.WriteHeader(code) _, _ = w.Write(json) } func setResponseHeader(w http.ResponseWriter, key string, value string) { //nolint:unparam header := w.Header() if header == nil { return } header.Set(key, value) } ================================================ FILE: server/api/api_test.go ================================================ package api import ( "database/sql" "fmt" "io" "net/http" "net/http/httptest" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/stretchr/testify/require" ) func TestErrorResponse(t *testing.T) { testAPI := API{logger: mlog.CreateConsoleTestLogger(t)} testCases := []struct { Name string Error error ResponseCode int ResponseBody string }{ // bad request {"ErrBadRequest", model.NewErrBadRequest("bad field"), http.StatusBadRequest, "bad field"}, {"ErrViewsLimitReached", model.ErrViewsLimitReached, http.StatusBadRequest, "limit reached"}, {"ErrAuthParam", model.NewErrAuthParam("password is required"), http.StatusBadRequest, "password is required"}, {"ErrInvalidCategory", model.NewErrInvalidCategory("open"), http.StatusBadRequest, "open"}, {"ErrBoardMemberIsLastAdmin", model.ErrBoardMemberIsLastAdmin, http.StatusBadRequest, "no admins"}, {"ErrBoardIDMismatch", model.ErrBoardIDMismatch, http.StatusBadRequest, "Board IDs do not match"}, {"ErrBlockTitleSizeLimitExceeded", model.ErrBlockTitleSizeLimitExceeded, http.StatusBadRequest, "block title size limit exceeded"}, {"ErrBlockFieldsSizeLimitExceeded", model.ErrBlockFieldsSizeLimitExceeded, http.StatusBadRequest, "block fields size limit exceeded"}, // unauthorized {"ErrUnauthorized", model.NewErrUnauthorized("not enough permissions"), http.StatusUnauthorized, "not enough permissions"}, // forbidden {"ErrForbidden", model.NewErrForbidden("not enough permissions"), http.StatusForbidden, "not enough permissions"}, {"ErrPermission", model.NewErrPermission("not enough permissions"), http.StatusForbidden, "not enough permissions"}, {"ErrPatchUpdatesLimitedCards", model.ErrPatchUpdatesLimitedCards, http.StatusForbidden, "cards that are limited"}, {"ErrCategoryPermissionDenied", model.ErrCategoryPermissionDenied, http.StatusForbidden, "doesn't belong to user"}, // not found {"ErrNotFound", model.NewErrNotFound("board"), http.StatusNotFound, "board"}, {"ErrNotAllFound", model.NewErrNotAllFound("block", []string{"1", "2"}), http.StatusNotFound, "not all instances of {block} in {1, 2} found"}, {"sql.ErrNoRows", sql.ErrNoRows, http.StatusNotFound, "rows"}, {"ErrNotFound", model.ErrCategoryDeleted, http.StatusNotFound, "category is deleted"}, // request entity too large {"ErrRequestEntityTooLarge", model.ErrRequestEntityTooLarge, http.StatusRequestEntityTooLarge, "entity too large"}, // not implemented {"ErrNotFound", model.ErrInsufficientLicense, http.StatusNotImplemented, "appropriate license required"}, {"ErrNotImplemented", model.NewErrNotImplemented("not implemented in plugin mode"), http.StatusNotImplemented, "plugin mode"}, // internal server error {"Any other error", ErrHandlerPanic, http.StatusInternalServerError, "internal server error"}, } for _, tc := range testCases { t.Run(fmt.Sprintf("%s should be a %d code", tc.Name, tc.ResponseCode), func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() testAPI.errorResponse(w, r, tc.Error) res := w.Result() require.Equal(t, tc.ResponseCode, res.StatusCode) require.Equal(t, "application/json", res.Header.Get("Content-Type")) b, rErr := io.ReadAll(res.Body) require.NoError(t, rErr) res.Body.Close() require.Contains(t, string(b), tc.ResponseBody) }) } } ================================================ FILE: server/api/archive.go ================================================ package api import ( "fmt" "net/http" "time" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( archiveExtension = ".boardarchive" ) func (a *API) registerAchivesRoutes(r *mux.Router) { // Archive APIs r.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET") r.HandleFunc("/teams/{teamID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST") r.HandleFunc("/teams/{teamID}/archive/export", a.sessionRequired(a.handleArchiveExportTeam)).Methods("GET") } func (a *API) handleArchiveExportBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID}/archive/export archiveExportBoard // // Exports an archive of all blocks for one boards. // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Id of board to export // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // content: // application-octet-stream: // type: string // format: binary // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) boardID := vars["boardID"] userID := getUserID(r) // check user has permission to board if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { // if this user has `manage_system` permission and there is a license with the compliance // feature enabled, then we will allow the export. license := a.app.GetLicense() if !a.permissions.HasPermissionTo(userID, mmModel.PermissionManageSystem) || license == nil || !(*license.Features.Compliance) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } auditRec := a.makeAuditRecord(r, "archiveExportBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("BoardID", boardID) board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } opts := model.ExportArchiveOptions{ TeamID: board.TeamID, BoardIDs: []string{board.ID}, } filename := fmt.Sprintf("archive-%s%s", time.Now().Format("2006-01-02"), archiveExtension) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", "attachment; filename="+filename) w.Header().Set("Content-Transfer-Encoding", "binary") if err := a.app.ExportArchive(w, opts); err != nil { a.errorResponse(w, r, err) } auditRec.Success() } func (a *API) handleArchiveImport(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/archive/import archiveImport // // Import an archive of boards. // // --- // produces: // - application/json // consumes: // - multipart/form-data // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: file // in: formData // description: archive file to import // required: true // type: file // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session, _ := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID vars := mux.Vars(r) teamID := vars["teamID"] if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to create board")) return } isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("access denied to create board")) return } file, handle, err := r.FormFile(UploadFormFileKey) if err != nil { fmt.Fprintf(w, "%v", err) return } defer file.Close() auditRec := a.makeAuditRecord(r, "import", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("filename", handle.Filename) auditRec.AddMeta("size", handle.Size) opt := model.ImportArchiveOptions{ TeamID: teamID, ModifiedBy: userID, } if err := a.app.ImportArchive(file, opt); err != nil { a.logger.Debug("Error importing archive", mlog.String("team_id", teamID), mlog.Err(err), ) a.errorResponse(w, r, err) return } jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleArchiveExportTeam(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/archive/export archiveExportTeam // // Exports an archive of all blocks for all the boards in a team. // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Id of team // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // content: // application-octet-stream: // type: string // format: binary // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode")) return } vars := mux.Vars(r) teamID := vars["teamID"] ctx := r.Context() session, _ := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID auditRec := a.makeAuditRecord(r, "archiveExportTeam", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("TeamID", teamID) isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest) if err != nil { a.errorResponse(w, r, err) return } ids := []string{} for _, board := range boards { ids = append(ids, board.ID) } opts := model.ExportArchiveOptions{ TeamID: teamID, BoardIDs: ids, } filename := fmt.Sprintf("archive-%s%s", time.Now().Format("2006-01-02"), archiveExtension) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", "attachment; filename="+filename) w.Header().Set("Content-Transfer-Encoding", "binary") if err := a.app.ExportArchive(w, opts); err != nil { a.errorResponse(w, r, err) } auditRec.Success() } ================================================ FILE: server/api/audit.go ================================================ package api import ( "net/http" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" ) // makeAuditRecord creates an audit record pre-populated with data from the request. func (a *API) makeAuditRecord(r *http.Request, event string, initialStatus string) *audit.Record { //nolint:unparam ctx := r.Context() var sessionID string var userID string if session, ok := ctx.Value(sessionContextKey).(*model.Session); ok { sessionID = session.ID userID = session.UserID } teamID := "unknown" rec := &audit.Record{ APIPath: r.URL.Path, Event: event, Status: initialStatus, UserID: userID, SessionID: sessionID, Client: r.UserAgent(), IPAddress: r.RemoteAddr, Meta: []audit.Meta{{K: audit.KeyTeamID, V: teamID}}, } return rec } ================================================ FILE: server/api/auth.go ================================================ package api import ( "context" "encoding/json" "io" "net" "net/http" "strings" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/focalboard/server/services/auth" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerAuthRoutes(r *mux.Router) { // personal-server specific routes. These are not needed in plugin mode. r.HandleFunc("/login", a.handleLogin).Methods("POST") r.HandleFunc("/logout", a.sessionRequired(a.handleLogout)).Methods("POST") r.HandleFunc("/register", a.handleRegister).Methods("POST") r.HandleFunc("/teams/{teamID}/regenerate_signup_token", a.sessionRequired(a.handlePostTeamRegenerateSignupToken)).Methods("POST") r.HandleFunc("/users/{userID}/changepassword", a.sessionRequired(a.handleChangePassword)).Methods("POST") } func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /login login // // Login user // // --- // produces: // - application/json // parameters: // - name: body // in: body // description: Login request // required: true // schema: // "$ref": "#/definitions/LoginRequest" // responses: // '200': // description: success // schema: // "$ref": "#/definitions/LoginResponse" // '401': // description: invalid login // schema: // "$ref": "#/definitions/ErrorResponse" // '500': // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode")) return } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode")) return } requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var loginData model.LoginRequest err = json.Unmarshal(requestBody, &loginData) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "login", audit.Fail) defer a.audit.LogRecord(audit.LevelAuth, auditRec) auditRec.AddMeta("username", loginData.Username) auditRec.AddMeta("type", loginData.Type) if loginData.Type == "normal" { token, err := a.app.Login(loginData.Username, loginData.Email, loginData.Password, loginData.MfaToken) if err != nil { a.errorResponse(w, r, model.NewErrUnauthorized("incorrect login")) return } json, err := json.Marshal(model.LoginResponse{Token: token}) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, json) auditRec.Success() return } a.errorResponse(w, r, model.NewErrBadRequest("invalid login type")) } func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /logout logout // // Logout user // // --- // produces: // - application/json // security: // - BearerAuth: [] // responses: // '200': // description: success // '500': // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode")) return } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode")) return } ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) auditRec := a.makeAuditRecord(r, "logout", audit.Fail) defer a.audit.LogRecord(audit.LevelAuth, auditRec) auditRec.AddMeta("userID", session.UserID) if err := a.app.Logout(session.ID); err != nil { a.errorResponse(w, r, model.NewErrUnauthorized("incorrect logout")) return } auditRec.AddMeta("sessionID", session.ID) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /register register // // Register new user // // --- // produces: // - application/json // parameters: // - name: body // in: body // description: Register request // required: true // schema: // "$ref": "#/definitions/RegisterRequest" // responses: // '200': // description: success // '401': // description: invalid registration token // '500': // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode")) return } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode")) return } requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var registerData model.RegisterRequest err = json.Unmarshal(requestBody, ®isterData) if err != nil { a.errorResponse(w, r, err) return } registerData.Email = strings.TrimSpace(registerData.Email) registerData.Username = strings.TrimSpace(registerData.Username) // Validate token if len(registerData.Token) > 0 { team, err2 := a.app.GetRootTeam() if err2 != nil { a.errorResponse(w, r, err2) return } if registerData.Token != team.SignupToken { a.errorResponse(w, r, model.NewErrUnauthorized("invalid token")) return } } else { // No signup token, check if no active users userCount, err2 := a.app.GetRegisteredUserCount() if err2 != nil { a.errorResponse(w, r, err2) return } if userCount > 0 { a.errorResponse(w, r, model.NewErrUnauthorized("no sign-up token and user(s) already exist")) return } } if err = registerData.IsValid(); err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "register", audit.Fail) defer a.audit.LogRecord(audit.LevelAuth, auditRec) auditRec.AddMeta("username", registerData.Username) err = a.app.RegisterUser(registerData.Username, registerData.Email, registerData.Password) if err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /users/{userID}/changepassword changePassword // // Change a user's password // // --- // produces: // - application/json // parameters: // - name: userID // in: path // description: User ID // required: true // type: string // - name: body // in: body // description: Change password request // required: true // schema: // "$ref": "#/definitions/ChangePasswordRequest" // security: // - BearerAuth: [] // responses: // '200': // description: success // '400': // description: invalid request // schema: // "$ref": "#/definitions/ErrorResponse" // '500': // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode")) return } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode")) return } vars := mux.Vars(r) userID := vars["userID"] requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var requestData model.ChangePasswordRequest if err = json.Unmarshal(requestBody, &requestData); err != nil { a.errorResponse(w, r, err) return } if err = requestData.IsValid(); err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "changePassword", audit.Fail) defer a.audit.LogRecord(audit.LevelAuth, auditRec) if err = a.app.ChangePassword(userID, requestData.OldPassword, requestData.NewPassword); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) sessionRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { return a.attachSession(handler, true) } func (a *API) attachSession(handler func(w http.ResponseWriter, r *http.Request), required bool) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { token, _ := auth.ParseAuthTokenFromRequest(r) a.logger.Debug(`attachSession`, mlog.Bool("single_user", len(a.singleUserToken) > 0)) if len(a.singleUserToken) > 0 { if required && (token != a.singleUserToken) { a.errorResponse(w, r, model.NewErrUnauthorized("invalid single user token")) return } now := utils.GetMillis() session := &model.Session{ ID: model.SingleUser, Token: token, UserID: model.SingleUser, AuthService: a.authService, Props: map[string]interface{}{}, CreateAt: now, UpdateAt: now, } ctx := context.WithValue(r.Context(), sessionContextKey, session) handler(w, r.WithContext(ctx)) return } if a.MattermostAuth && r.Header.Get("Mattermost-User-Id") != "" { userID := r.Header.Get("Mattermost-User-Id") now := utils.GetMillis() session := &model.Session{ ID: userID, Token: userID, UserID: userID, AuthService: a.authService, Props: map[string]interface{}{}, CreateAt: now, UpdateAt: now, } ctx := context.WithValue(r.Context(), sessionContextKey, session) handler(w, r.WithContext(ctx)) return } session, err := a.app.GetSession(token) if err != nil { if required { a.errorResponse(w, r, model.NewErrUnauthorized(err.Error())) return } handler(w, r) return } authService := session.AuthService if authService != a.authService { msg := `Session authService mismatch` a.logger.Error(msg, mlog.String("sessionID", session.ID), mlog.String("want", a.authService), mlog.String("got", authService), ) a.errorResponse(w, r, model.NewErrUnauthorized(msg)) return } ctx := context.WithValue(r.Context(), sessionContextKey, session) handler(w, r.WithContext(ctx)) } } func (a *API) adminRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { // Currently, admin APIs require local unix connections conn := GetContextConn(r) if _, isUnix := conn.(*net.UnixConn); !isUnix { a.errorResponse(w, r, model.NewErrUnauthorized("not a local unix connection")) return } handler(w, r) } } ================================================ FILE: server/api/blocks.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "strconv" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerBlocksRoutes(r *mux.Router) { // Blocks APIs r.HandleFunc("/boards/{boardID}/blocks", a.attachSession(a.handleGetBlocks, false)).Methods("GET") r.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePostBlocks)).Methods("POST") r.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePatchBlocks)).Methods("PATCH") r.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handleDeleteBlock)).Methods("DELETE") r.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handlePatchBlock)).Methods("PATCH") r.HandleFunc("/boards/{boardID}/blocks/{blockID}/undelete", a.sessionRequired(a.handleUndeleteBlock)).Methods("POST") r.HandleFunc("/boards/{boardID}/blocks/{blockID}/duplicate", a.sessionRequired(a.handleDuplicateBlock)).Methods("POST") } func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID}/blocks getBlocks // // Returns blocks // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: parent_id // in: query // description: ID of parent block, omit to specify all blocks // required: false // type: string // - name: type // in: query // description: Type of blocks to return, omit to specify all types // required: false // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Block" // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" query := r.URL.Query() parentID := query.Get("parent_id") blockType := query.Get("type") all := query.Get("all") blockID := query.Get("block_id") boardID := mux.Vars(r)["boardID"] userID := getUserID(r) hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) if userID == "" && !hasValidReadToken { a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) return } board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } if !hasValidReadToken { if board.IsTemplate && board.Type == model.BoardTypeOpen { if board.TeamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to board template")) return } } else { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } if board.IsTemplate { var isGuest bool isGuest, err = a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("guest are not allowed to get board templates")) return } } } auditRec := a.makeAuditRecord(r, "getBlocks", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("parentID", parentID) auditRec.AddMeta("blockType", blockType) auditRec.AddMeta("all", all) auditRec.AddMeta("blockID", blockID) var blocks []*model.Block var block *model.Block switch { case all != "": blocks, err = a.app.GetBlocksForBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } case blockID != "": block, err = a.app.GetBlockByID(blockID) if err != nil { a.errorResponse(w, r, err) return } if block.BoardID != boardID { message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, boardID) a.errorResponse(w, r, model.NewErrNotFound(message)) return } blocks = append(blocks, block) default: blocks, err = a.app.GetBlocks(boardID, parentID, blockType) if err != nil { a.errorResponse(w, r, err) return } } a.logger.Debug("GetBlocks", mlog.String("boardID", boardID), mlog.String("parentID", parentID), mlog.String("blockType", blockType), mlog.String("blockID", blockID), mlog.Int("block_count", len(blocks)), ) json, err := json.Marshal(blocks) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, json) auditRec.AddMeta("blockCount", len(blocks)) auditRec.Success() } func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/blocks updateBlocks // // Insert blocks. The specified IDs will only be used to link // blocks with existing ones, the rest will be replaced by server // generated IDs // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: disable_notify // in: query // description: Disables notifications (for bulk inserting) // required: false // type: bool // - name: Body // in: body // description: array of blocks to insert or update // required: true // schema: // type: array // items: // "$ref": "#/definitions/Block" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // items: // $ref: '#/definitions/Block' // type: array // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) val := r.URL.Query().Get("disable_notify") disableNotify := val == True requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var blocks []*model.Block err = json.Unmarshal(requestBody, &blocks) if err != nil { a.errorResponse(w, r, err) return } hasComments := false hasContents := false for _, block := range blocks { // Error checking if len(block.Type) < 1 { message := fmt.Sprintf("missing type for block id %s", block.ID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if block.Type == model.TypeComment { hasComments = true } else { hasContents = true } if block.CreateAt < 1 { message := fmt.Sprintf("invalid createAt for block id %s", block.ID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if block.UpdateAt < 1 { message := fmt.Sprintf("invalid UpdateAt for block id %s", block.ID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if block.BoardID != boardID { message := fmt.Sprintf("invalid BoardID for block id %s", block.ID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } } if hasContents { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes")) return } } if hasComments { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionCommentBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to post card comments")) return } } blocks = model.GenerateBlockIDs(blocks, a.logger) auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("disable_notify", disableNotify) ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) model.StampModificationMetadata(userID, blocks, auditRec) // this query param exists when creating template from board, or board from template sourceBoardID := r.URL.Query().Get("sourceBoardID") if sourceBoardID != "" { if updateFileIDsErr := a.app.CopyAndUpdateCardFiles(sourceBoardID, userID, blocks, false); updateFileIDsErr != nil { a.errorResponse(w, r, updateFileIDsErr) return } } newBlocks, err := a.app.InsertBlocksAndNotify(blocks, session.UserID, disableNotify) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("POST Blocks", mlog.Int("block_count", len(blocks)), mlog.Bool("disable_notify", disableNotify), ) json, err := json.Marshal(newBlocks) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, json) auditRec.AddMeta("blockCount", len(blocks)) auditRec.Success() } func (a *API) handleDeleteBlock(w http.ResponseWriter, r *http.Request) { // swagger:operation DELETE /boards/{boardID}/blocks/{blockID} deleteBlock // // Deletes a block // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: blockID // in: path // description: ID of block to delete // required: true // type: string // - name: disable_notify // in: query // description: Disables notifications (for bulk deletion) // required: false // type: bool // security: // - BearerAuth: [] // responses: // '200': // description: success // '404': // description: block not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) vars := mux.Vars(r) boardID := vars["boardID"] blockID := vars["blockID"] val := r.URL.Query().Get("disable_notify") disableNotify := val == True if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes")) return } block, err := a.app.GetBlockByID(blockID) if err != nil { a.errorResponse(w, r, err) return } if block.BoardID != boardID { message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, boardID) a.errorResponse(w, r, model.NewErrNotFound(message)) return } auditRec := a.makeAuditRecord(r, "deleteBlock", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("blockID", blockID) err = a.app.DeleteBlockAndNotify(blockID, userID, disableNotify) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("DELETE Block", mlog.String("boardID", boardID), mlog.String("blockID", blockID)) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleUndeleteBlock(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/blocks/{blockID}/undelete undeleteBlock // // Undeletes a block // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: blockID // in: path // description: ID of block to undelete // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/BlockPatch" // '404': // description: block not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID vars := mux.Vars(r) blockID := vars["blockID"] boardID := vars["boardID"] board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } block, err := a.app.GetLastBlockHistoryEntry(blockID) if err != nil { a.errorResponse(w, r, err) return } if board.ID != block.BoardID { message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, board.ID) a.errorResponse(w, r, model.NewErrNotFound(message)) return } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members")) return } auditRec := a.makeAuditRecord(r, "undeleteBlock", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("blockID", blockID) undeletedBlock, err := a.app.UndeleteBlock(blockID, userID) if err != nil { a.errorResponse(w, r, err) return } undeletedBlockData, err := json.Marshal(undeletedBlock) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("UNDELETE Block", mlog.String("blockID", blockID)) jsonBytesResponse(w, http.StatusOK, undeletedBlockData) auditRec.Success() } func (a *API) handlePatchBlock(w http.ResponseWriter, r *http.Request) { // swagger:operation PATCH /boards/{boardID}/blocks/{blockID} patchBlock // // Partially updates a block // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: blockID // in: path // description: ID of block to patch // required: true // type: string // - name: disable_notify // in: query // description: Disables notifications (for bulk patching) // required: false // type: bool // - name: Body // in: body // description: block patch to apply // required: true // schema: // "$ref": "#/definitions/BlockPatch" // security: // - BearerAuth: [] // responses: // '200': // description: success // '404': // description: block not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) vars := mux.Vars(r) boardID := vars["boardID"] blockID := vars["blockID"] val := r.URL.Query().Get("disable_notify") disableNotify := val == True if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes")) return } block, err := a.app.GetBlockByID(blockID) if err != nil { a.errorResponse(w, r, err) return } if block.BoardID != boardID { message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, boardID) a.errorResponse(w, r, model.NewErrNotFound(message)) return } requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var patch *model.BlockPatch err = json.Unmarshal(requestBody, &patch) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "patchBlock", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("blockID", blockID) if _, err = a.app.PatchBlockAndNotify(blockID, patch, userID, disableNotify); err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("PATCH Block", mlog.String("boardID", boardID), mlog.String("blockID", blockID)) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) { // swagger:operation PATCH /boards/{boardID}/blocks/ patchBlocks // // Partially updates batch of blocks // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Workspace ID // required: true // type: string // - name: disable_notify // in: query // description: Disables notifications (for bulk patching) // required: false // type: bool // - name: Body // in: body // description: block Ids and block patches to apply // required: true // schema: // "$ref": "#/definitions/BlockPatchBatch" // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID vars := mux.Vars(r) teamID := vars["teamID"] val := r.URL.Query().Get("disable_notify") disableNotify := val == True requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var patches *model.BlockPatchBatch err = json.Unmarshal(requestBody, &patches) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "patchBlocks", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) for i := range patches.BlockIDs { auditRec.AddMeta("block_"+strconv.FormatInt(int64(i), 10), patches.BlockIDs[i]) } for _, blockID := range patches.BlockIDs { var block *model.Block block, err = a.app.GetBlockByID(blockID) if err != nil { a.errorResponse(w, r, model.NewErrForbidden("access denied to make board changes")) return } if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to make board changesa")) return } } err = a.app.PatchBlocksAndNotify(teamID, patches, userID, disableNotify) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("PATCH Blocks", mlog.String("patches", strconv.Itoa(len(patches.BlockIDs)))) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleDuplicateBlock(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/blocks/{blockID}/duplicate duplicateBlock // // Returns the new created blocks // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: blockID // in: path // description: Block ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Block" // '404': // description: board or block not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] blockID := mux.Vars(r)["blockID"] userID := getUserID(r) query := r.URL.Query() asTemplate := query.Get("asTemplate") board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } if userID == "" { a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) return } block, err := a.app.GetBlockByID(blockID) if err != nil { a.errorResponse(w, r, err) return } if board.ID != block.BoardID { message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, board.ID) a.errorResponse(w, r, model.NewErrNotFound(message)) return } if block.Type == model.TypeComment { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionCommentBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to comment on board cards")) return } } else { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to modify board cards")) return } } auditRec := a.makeAuditRecord(r, "duplicateBlock", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("blockID", blockID) a.logger.Debug("DuplicateBlock", mlog.String("boardID", boardID), mlog.String("blockID", blockID), ) blocks, err := a.app.DuplicateBlock(boardID, blockID, userID, asTemplate == True) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(blocks) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } ================================================ FILE: server/api/boards.go ================================================ package api import ( "encoding/json" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerBoardsRoutes(r *mux.Router) { r.HandleFunc("/teams/{teamID}/boards", a.sessionRequired(a.handleGetBoards)).Methods("GET") r.HandleFunc("/boards", a.sessionRequired(a.handleCreateBoard)).Methods("POST") r.HandleFunc("/boards/{boardID}", a.attachSession(a.handleGetBoard, false)).Methods("GET") r.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH") r.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE") r.HandleFunc("/boards/{boardID}/duplicate", a.sessionRequired(a.handleDuplicateBoard)).Methods("POST") r.HandleFunc("/boards/{boardID}/undelete", a.sessionRequired(a.handleUndeleteBoard)).Methods("POST") r.HandleFunc("/boards/{boardID}/metadata", a.sessionRequired(a.handleGetBoardMetadata)).Methods("GET") } func (a *API) handleGetBoards(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/boards getBoards // // Returns team boards // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Board" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" teamID := mux.Vars(r)["teamID"] userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } auditRec := a.makeAuditRecord(r, "getBoards", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamID", teamID) isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } // retrieve boards list boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetBoards", mlog.String("teamID", teamID), mlog.Int("boardsCount", len(boards)), ) data, err := json.Marshal(boards) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("boardsCount", len(boards)) auditRec.Success() } func (a *API) handleCreateBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards createBoard // // Creates a new board // // --- // produces: // - application/json // parameters: // - name: Body // in: body // description: the board to create // required: true // schema: // "$ref": "#/definitions/Board" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/Board' // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var newBoard *model.Board if err = json.Unmarshal(requestBody, &newBoard); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } if newBoard.Type == model.BoardTypeOpen { if !a.permissions.HasPermissionToTeam(userID, newBoard.TeamID, model.PermissionCreatePublicChannel) { a.errorResponse(w, r, model.NewErrPermission("access denied to create public boards")) return } } else { if !a.permissions.HasPermissionToTeam(userID, newBoard.TeamID, model.PermissionCreatePrivateChannel) { a.errorResponse(w, r, model.NewErrPermission("access denied to create private boards")) return } } isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("access denied to create board")) return } if err = newBoard.IsValid(); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } auditRec := a.makeAuditRecord(r, "createBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("teamID", newBoard.TeamID) auditRec.AddMeta("boardType", newBoard.Type) // create board board, err := a.app.CreateBoard(newBoard, userID, true) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("CreateBoard", mlog.String("teamID", board.TeamID), mlog.String("boardID", board.ID), mlog.String("boardType", string(board.Type)), mlog.String("userID", userID), ) data, err := json.Marshal(board) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleGetBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID} getBoard // // Returns a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Board" // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) if userID == "" && !hasValidReadToken { a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) return } board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } if !hasValidReadToken { if board.Type == model.BoardTypePrivate { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } else { var isGuest bool isGuest, err = a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } } auditRec := a.makeAuditRecord(r, "getBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) a.logger.Debug("GetBoard", mlog.String("boardID", boardID), ) data, err := json.Marshal(board) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handlePatchBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation PATCH /boards/{boardID} patchBoard // // Partially updates a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: Body // in: body // description: board patch to apply // required: true // schema: // "$ref": "#/definitions/BoardPatch" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/Board' // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] if _, err := a.app.GetBoard(boardID); err != nil { a.errorResponse(w, r, err) return } userID := getUserID(r) requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var patch *model.BoardPatch if err = json.Unmarshal(requestBody, &patch); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } if err = patch.IsValid(); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardProperties) { a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board properties")) return } if patch.Type != nil || patch.MinimumRole != nil { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) { a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board type")) return } } if patch.ChannelID != nil { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) { a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board access")) return } } auditRec := a.makeAuditRecord(r, "patchBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("userID", userID) // patch board updatedBoard, err := a.app.PatchBoard(patch, boardID, userID) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("PatchBoard", mlog.String("boardID", boardID), mlog.String("userID", userID), ) data, err := json.Marshal(updatedBoard) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleDeleteBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation DELETE /boards/{boardID} deleteBoard // // Removes a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) // Check if board exists if _, err := a.app.GetBoard(boardID); err != nil { a.errorResponse(w, r, err) return } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionDeleteBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to delete board")) return } auditRec := a.makeAuditRecord(r, "deleteBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) if err := a.app.DeleteBoard(boardID, userID); err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("DELETE Board", mlog.String("boardID", boardID)) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/duplicate duplicateBoard // // Returns the new created board and all the blocks // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/BoardsAndBlocks' // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) query := r.URL.Query() asTemplate := query.Get("asTemplate") toTeam := query.Get("toTeam") if userID == "" { a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) return } board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } if toTeam == "" { toTeam = board.TeamID } if toTeam == "" && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } if toTeam != "" && !a.permissions.HasPermissionToTeam(userID, toTeam, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } if board.IsTemplate && board.Type == model.BoardTypeOpen { if board.TeamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } else { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("access denied to create board")) return } auditRec := a.makeAuditRecord(r, "duplicateBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) a.logger.Debug("DuplicateBoard", mlog.String("boardID", boardID), ) boardsAndBlocks, _, err := a.app.DuplicateBoard(boardID, userID, toTeam, asTemplate == True) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(boardsAndBlocks) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleUndeleteBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/undelete undeleteBoard // // Undeletes a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: ID of board to undelete // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID vars := mux.Vars(r) boardID := vars["boardID"] auditRec := a.makeAuditRecord(r, "undeleteBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionDeleteBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to undelete board")) return } err := a.app.UndeleteBoard(boardID, userID) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("UNDELETE Board", mlog.String("boardID", boardID)) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleGetBoardMetadata(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID}/metadata getBoardMetadata // // Returns a board's metadata // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/BoardMetadata" // '404': // description: board not found // '501': // description: required license not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) board, boardMetadata, err := a.app.GetBoardMetadata(boardID) if err != nil { a.errorResponse(w, r, err) return } if board == nil || boardMetadata == nil { a.errorResponse(w, r, model.NewErrNotFound("board metadata BoardID="+boardID)) return } if board.Type == model.BoardTypePrivate { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } else { if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } } auditRec := a.makeAuditRecord(r, "getBoardMetadata", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) data, err := json.Marshal(boardMetadata) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } ================================================ FILE: server/api/boards_and_blocks.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerBoardsAndBlocksRoutes(r *mux.Router) { // BoardsAndBlocks APIs r.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handleCreateBoardsAndBlocks)).Methods("POST") r.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handlePatchBoardsAndBlocks)).Methods("PATCH") r.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handleDeleteBoardsAndBlocks)).Methods("DELETE") } func (a *API) handleCreateBoardsAndBlocks(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards-and-blocks insertBoardsAndBlocks // // Creates new boards and blocks // // --- // produces: // - application/json // parameters: // - name: Body // in: body // description: the boards and blocks to create // required: true // schema: // "$ref": "#/definitions/BoardsAndBlocks" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/BoardsAndBlocks' // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var newBab *model.BoardsAndBlocks if err = json.Unmarshal(requestBody, &newBab); err != nil { a.errorResponse(w, r, err) return } if len(newBab.Boards) == 0 { a.errorResponse(w, r, model.NewErrBadRequest("at least one board is required")) return } teamID := "" boardIDs := map[string]bool{} for _, board := range newBab.Boards { boardIDs[board.ID] = true if teamID == "" { teamID = board.TeamID continue } if teamID != board.TeamID { a.errorResponse(w, r, model.NewErrBadRequest("cannot create boards for multiple teams")) return } if board.ID == "" { a.errorResponse(w, r, model.NewErrBadRequest("boards need an ID to be referenced from the blocks")) return } } if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to board template")) return } isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("access denied to create board")) return } for _, block := range newBab.Blocks { // Error checking if len(block.Type) < 1 { message := fmt.Sprintf("missing type for block id %s", block.ID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if block.CreateAt < 1 { message := fmt.Sprintf("invalid createAt for block id %s", block.ID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if block.UpdateAt < 1 { message := fmt.Sprintf("invalid UpdateAt for block id %s", block.ID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if !boardIDs[block.BoardID] { message := fmt.Sprintf("invalid BoardID %s (not exists in the created boards)", block.BoardID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } } // IDs of boards and blocks are used to confirm that they're // linked and then regenerated by the server newBab, err = model.GenerateBoardsAndBlocksIDs(newBab, a.logger) if err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } auditRec := a.makeAuditRecord(r, "createBoardsAndBlocks", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("userID", userID) auditRec.AddMeta("boardsCount", len(newBab.Boards)) auditRec.AddMeta("blocksCount", len(newBab.Blocks)) // create boards and blocks bab, err := a.app.CreateBoardsAndBlocks(newBab, userID, true) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("CreateBoardsAndBlocks", mlog.String("teamID", teamID), mlog.String("userID", userID), mlog.Int("boardCount", len(bab.Boards)), mlog.Int("blockCount", len(bab.Blocks)), ) data, err := json.Marshal(bab) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request) { // swagger:operation PATCH /boards-and-blocks patchBoardsAndBlocks // // Patches a set of related boards and blocks // // --- // produces: // - application/json // parameters: // - name: Body // in: body // description: the patches for the boards and blocks // required: true // schema: // "$ref": "#/definitions/PatchBoardsAndBlocks" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/BoardsAndBlocks' // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var pbab *model.PatchBoardsAndBlocks if err = json.Unmarshal(requestBody, &pbab); err != nil { a.errorResponse(w, r, err) return } if err = pbab.IsValid(); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } teamID := "" boardIDMap := map[string]bool{} for i, boardID := range pbab.BoardIDs { boardIDMap[boardID] = true patch := pbab.BoardPatches[i] if err = patch.IsValid(); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardProperties) { a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board properties")) return } if patch.Type != nil || patch.MinimumRole != nil { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) { a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board type")) return } } board, err2 := a.app.GetBoard(boardID) if err2 != nil { a.errorResponse(w, r, err2) return } if teamID == "" { teamID = board.TeamID } if teamID != board.TeamID { a.errorResponse(w, r, model.NewErrBadRequest("mismatched team ID")) return } } for _, blockID := range pbab.BlockIDs { block, err2 := a.app.GetBlockByID(blockID) if err2 != nil { a.errorResponse(w, r, err2) return } if _, ok := boardIDMap[block.BoardID]; !ok { a.errorResponse(w, r, model.NewErrBadRequest("missing BoardID="+block.BoardID)) return } if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to modifying cards")) return } } auditRec := a.makeAuditRecord(r, "patchBoardsAndBlocks", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardsCount", len(pbab.BoardIDs)) auditRec.AddMeta("blocksCount", len(pbab.BlockIDs)) bab, err := a.app.PatchBoardsAndBlocks(pbab, userID) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("PATCH BoardsAndBlocks", mlog.Int("boardsCount", len(pbab.BoardIDs)), mlog.Int("blocksCount", len(pbab.BlockIDs)), ) data, err := json.Marshal(bab) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleDeleteBoardsAndBlocks(w http.ResponseWriter, r *http.Request) { // swagger:operation DELETE /boards-and-blocks deleteBoardsAndBlocks // // Deletes boards and blocks // // --- // produces: // - application/json // parameters: // - name: Body // in: body // description: the boards and blocks to delete // required: true // schema: // "$ref": "#/definitions/DeleteBoardsAndBlocks" // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var dbab *model.DeleteBoardsAndBlocks if err = json.Unmarshal(requestBody, &dbab); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } // user must have permission to delete all the boards, and that // would include the permission to manage their blocks teamID := "" boardIDMap := map[string]bool{} for _, boardID := range dbab.Boards { boardIDMap[boardID] = true // all boards in the request should belong to the same team board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } if teamID == "" { teamID = board.TeamID } if teamID != board.TeamID { a.errorResponse(w, r, model.NewErrBadRequest("all boards should belong to the same team")) return } // permission check if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionDeleteBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to delete board")) return } } for _, blockID := range dbab.Blocks { block, err2 := a.app.GetBlockByID(blockID) if err2 != nil { a.errorResponse(w, r, err2) return } if _, ok := boardIDMap[block.BoardID]; !ok { a.errorResponse(w, r, model.NewErrBadRequest("missing BoardID="+block.BoardID)) return } if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to modifying cards")) return } } if err := dbab.IsValid(); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } auditRec := a.makeAuditRecord(r, "deleteBoardsAndBlocks", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardsCount", len(dbab.Boards)) auditRec.AddMeta("blocksCount", len(dbab.Blocks)) if err := a.app.DeleteBoardsAndBlocks(dbab, userID); err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("DELETE BoardsAndBlocks", mlog.Int("boardsCount", len(dbab.Boards)), mlog.Int("blocksCount", len(dbab.Blocks)), ) // response jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } ================================================ FILE: server/api/cards.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "strconv" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( defaultPage = "0" defaultPerPage = "100" ) func (a *API) registerCardsRoutes(r *mux.Router) { // Cards APIs r.HandleFunc("/boards/{boardID}/cards", a.sessionRequired(a.handleCreateCard)).Methods("POST") r.HandleFunc("/boards/{boardID}/cards", a.sessionRequired(a.handleGetCards)).Methods("GET") r.HandleFunc("/cards/{cardID}", a.sessionRequired(a.handlePatchCard)).Methods("PATCH") r.HandleFunc("/cards/{cardID}", a.sessionRequired(a.handleGetCard)).Methods("GET") } func (a *API) handleCreateCard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/cards createCard // // Creates a new card for the specified board. // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: Body // in: body // description: the card to create // required: true // schema: // "$ref": "#/definitions/Card" // - name: disable_notify // in: query // description: Disables notifications (for bulk data inserting) // required: false // type: bool // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/Card' // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) boardID := mux.Vars(r)["boardID"] val := r.URL.Query().Get("disable_notify") disableNotify := val == True requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var newCard *model.Card if err = json.Unmarshal(requestBody, &newCard); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to create card")) return } if newCard.BoardID != "" && newCard.BoardID != boardID { a.errorResponse(w, r, model.ErrBoardIDMismatch) return } newCard.PopulateWithBoardID(boardID) if err = newCard.CheckValid(); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } auditRec := a.makeAuditRecord(r, "createCard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) // create card card, err := a.app.CreateCard(newCard, boardID, userID, disableNotify) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("CreateCard", mlog.String("boardID", boardID), mlog.String("cardID", card.ID), mlog.String("userID", userID), ) data, err := json.Marshal(card) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleGetCards(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID}/cards getCards // // Fetches cards for the specified board. // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: page // in: query // description: The page to select (default=0) // required: false // type: integer // - name: per_page // in: query // description: Number of cards to return per page(default=100) // required: false // type: integer // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Card" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) boardID := mux.Vars(r)["boardID"] query := r.URL.Query() strPage := query.Get("page") strPerPage := query.Get("per_page") if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to fetch cards")) return } if strPage == "" { strPage = defaultPage } if strPerPage == "" { strPerPage = defaultPerPage } page, err := strconv.Atoi(strPage) if err != nil { message := fmt.Sprintf("invalid `page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) } perPage, err := strconv.Atoi(strPerPage) if err != nil { message := fmt.Sprintf("invalid `per_page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) } auditRec := a.makeAuditRecord(r, "getCards", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("page", page) auditRec.AddMeta("per_page", perPage) cards, err := a.app.GetCardsForBoard(boardID, page, perPage) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetCards", mlog.String("boardID", boardID), mlog.String("userID", userID), mlog.Int("page", page), mlog.Int("per_page", perPage), mlog.Int("count", len(cards)), ) data, err := json.Marshal(cards) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handlePatchCard(w http.ResponseWriter, r *http.Request) { // swagger:operation PATCH /cards/{cardID}/cards patchCard // // Patches the specified card. // // --- // produces: // - application/json // parameters: // - name: cardID // in: path // description: Card ID // required: true // type: string // - name: Body // in: body // description: the card patch // required: true // schema: // "$ref": "#/definitions/CardPatch" // - name: disable_notify // in: query // description: Disables notifications (for bulk data patching) // required: false // type: bool // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/Card' // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) cardID := mux.Vars(r)["cardID"] val := r.URL.Query().Get("disable_notify") disableNotify := val == True requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } card, err := a.app.GetCardByID(cardID) if err != nil { message := fmt.Sprintf("could not fetch card %s: %s", cardID, err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if !a.permissions.HasPermissionToBoard(userID, card.BoardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to patch card")) return } var patch *model.CardPatch if err = json.Unmarshal(requestBody, &patch); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } auditRec := a.makeAuditRecord(r, "patchCard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", card.BoardID) auditRec.AddMeta("cardID", card.ID) // patch card cardPatched, err := a.app.PatchCard(patch, card.ID, userID, disableNotify) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("PatchCard", mlog.String("boardID", cardPatched.BoardID), mlog.String("cardID", cardPatched.ID), mlog.String("userID", userID), ) data, err := json.Marshal(cardPatched) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleGetCard(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /cards/{cardID} getCard // // Fetches the specified card. // // --- // produces: // - application/json // parameters: // - name: cardID // in: path // description: Card ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/Card' // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) cardID := mux.Vars(r)["cardID"] card, err := a.app.GetCardByID(cardID) if err != nil { message := fmt.Sprintf("could not fetch card %s: %s", cardID, err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } if !a.permissions.HasPermissionToBoard(userID, card.BoardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to fetch card")) return } auditRec := a.makeAuditRecord(r, "getCard", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", card.BoardID) auditRec.AddMeta("cardID", card.ID) a.logger.Debug("GetCard", mlog.String("boardID", card.BoardID), mlog.String("cardID", card.ID), mlog.String("userID", userID), ) data, err := json.Marshal(card) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } ================================================ FILE: server/api/categories.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" ) func (a *API) registerCategoriesRoutes(r *mux.Router) { // Category APIs r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleCreateCategory)).Methods(http.MethodPost) r.HandleFunc("/teams/{teamID}/categories/reorder", a.sessionRequired(a.handleReorderCategories)).Methods(http.MethodPut) r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleUpdateCategory)).Methods(http.MethodPut) r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleDeleteCategory)).Methods(http.MethodDelete) r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleGetUserCategoryBoards)).Methods(http.MethodGet) r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/reorder", a.sessionRequired(a.handleReorderCategoryBoards)).Methods(http.MethodPut) r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}", a.sessionRequired(a.handleUpdateCategoryBoard)).Methods(http.MethodPost) r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide", a.sessionRequired(a.handleHideBoard)).Methods(http.MethodPut) r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}/unhide", a.sessionRequired(a.handleUnhideBoard)).Methods(http.MethodPut) } func (a *API) handleCreateCategory(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/categories createCategory // // Create a category for boards // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: Body // in: body // description: category to create // required: true // schema: // "$ref": "#/definitions/Category" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Category" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var category model.Category err = json.Unmarshal(requestBody, &category) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "createCategory", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) // user can only create category for themselves if category.UserID != session.UserID { message := fmt.Sprintf("userID %s and category userID %s mismatch", session.UserID, category.UserID) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } vars := mux.Vars(r) teamID := vars["teamID"] if category.TeamID != teamID { a.errorResponse(w, r, model.NewErrBadRequest("teamID mismatch")) return } if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } createdCategory, err := a.app.CreateCategory(&category) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(createdCategory) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("categoryID", createdCategory.ID) auditRec.Success() } func (a *API) handleUpdateCategory(w http.ResponseWriter, r *http.Request) { // swagger:operation PUT /teams/{teamID}/categories/{categoryID} updateCategory // // Create a category for boards // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: categoryID // in: path // description: Category ID // required: true // type: string // - name: Body // in: body // description: category to update // required: true // schema: // "$ref": "#/definitions/Category" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Category" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) categoryID := vars["categoryID"] requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var category model.Category err = json.Unmarshal(requestBody, &category) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "updateCategory", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) if categoryID != category.ID { a.errorResponse(w, r, model.NewErrBadRequest("categoryID mismatch in patch and body")) return } ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) // user can only update category for themselves if category.UserID != session.UserID { a.errorResponse(w, r, model.NewErrBadRequest("user ID mismatch in session and category")) return } teamID := vars["teamID"] if category.TeamID != teamID { a.errorResponse(w, r, model.NewErrBadRequest("teamID mismatch")) return } if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } updatedCategory, err := a.app.UpdateCategory(&category) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(updatedCategory) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleDeleteCategory(w http.ResponseWriter, r *http.Request) { // swagger:operation DELETE /teams/{teamID}/categories/{categoryID} deleteCategory // // Delete a category // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: categoryID // in: path // description: Category ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) vars := mux.Vars(r) userID := session.UserID teamID := vars["teamID"] categoryID := vars["categoryID"] auditRec := a.makeAuditRecord(r, "deleteCategory", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } deletedCategory, err := a.app.DeleteCategory(categoryID, userID, teamID) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(deletedCategory) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleGetUserCategoryBoards(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/categories getUserCategoryBoards // // Gets the user's board categories // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // items: // "$ref": "#/definitions/CategoryBoards" // type: array // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID vars := mux.Vars(r) teamID := vars["teamID"] auditRec := a.makeAuditRecord(r, "getUserCategoryBoards", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } categoryBlocks, err := a.app.GetUserCategoryBoards(userID, teamID) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(categoryBlocks) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleUpdateCategoryBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID} updateCategoryBoard // // Set the category of a board // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: categoryID // in: path // description: Category ID // required: true // type: string // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" auditRec := a.makeAuditRecord(r, "updateCategoryBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) vars := mux.Vars(r) categoryID := vars["categoryID"] boardID := vars["boardID"] teamID := vars["teamID"] ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } // TODO: Check the category and the team matches err := a.app.AddUpdateUserCategoryBoard(teamID, userID, categoryID, []string{boardID}) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, []byte("ok")) auditRec.Success() } func (a *API) handleReorderCategories(w http.ResponseWriter, r *http.Request) { // swagger:operation PUT /teams/{teamID}/categories/reorder handleReorderCategories // // Updated sidebar category order // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) teamID := vars["teamID"] ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to category")) return } requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var newCategoryOrder []string err = json.Unmarshal(requestBody, &newCategoryOrder) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "reorderCategories", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("TeamID", teamID) auditRec.AddMeta("CategoryCount", len(newCategoryOrder)) updatedCategoryOrder, err := a.app.ReorderCategories(userID, teamID, newCategoryOrder) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(updatedCategoryOrder) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleReorderCategoryBoards(w http.ResponseWriter, r *http.Request) { // swagger:operation PUT /teams/{teamID}/categories/{categoryID}/boards/reorder handleReorderCategoryBoards // // Updates order of boards inside a sidebar category // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: categoryID // in: path // description: Category ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) teamID := vars["teamID"] categoryID := vars["categoryID"] ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) userID := session.UserID if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to category")) return } category, err := a.app.GetCategory(categoryID) if err != nil { a.errorResponse(w, r, err) return } if category.UserID != userID { a.errorResponse(w, r, model.NewErrPermission("access denied to category")) return } requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var newBoardsOrder []string err = json.Unmarshal(requestBody, &newBoardsOrder) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "reorderCategoryBoards", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) updatedBoardsOrder, err := a.app.ReorderCategoryBoards(userID, teamID, categoryID, newBoardsOrder) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(updatedBoardsOrder) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleHideBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide hideBoard // // Hide the specified board for the user // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: categoryID // in: path // description: Category ID to which the board to be hidden belongs to // required: true // type: string // - name: boardID // in: path // description: ID of board to be hidden // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Category" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) vars := mux.Vars(r) teamID := vars["teamID"] boardID := vars["boardID"] categoryID := vars["categoryID"] if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to category")) return } auditRec := a.makeAuditRecord(r, "hideBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("board_id", boardID) auditRec.AddMeta("team_id", teamID) auditRec.AddMeta("category_id", categoryID) if err := a.app.SetBoardVisibility(teamID, userID, categoryID, boardID, false); err != nil { a.errorResponse(w, r, err) return } jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleUnhideBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide unhideBoard // // Unhides the specified board for the user // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: categoryID // in: path // description: Category ID to which the board to be unhidden belongs to // required: true // type: string // - name: boardID // in: path // description: ID of board to be unhidden // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Category" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) vars := mux.Vars(r) teamID := vars["teamID"] boardID := vars["boardID"] categoryID := vars["categoryID"] if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to category")) return } auditRec := a.makeAuditRecord(r, "unhideBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) if err := a.app.SetBoardVisibility(teamID, userID, categoryID, boardID, true); err != nil { a.errorResponse(w, r, err) return } jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } ================================================ FILE: server/api/channels.go ================================================ package api import ( "encoding/json" "fmt" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" mm_model "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerChannelsRoutes(r *mux.Router) { r.HandleFunc("/teams/{teamID}/channels/{channelID}", a.sessionRequired(a.handleGetChannel)).Methods("GET") } func (a *API) handleGetChannel(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/channels/{channelID} getChannel // // Returns the requested channel // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: channelID // in: path // description: Channel ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Channel" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if !a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode")) return } teamID := mux.Vars(r)["teamID"] channelID := mux.Vars(r)["channelID"] userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } if !a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) { a.errorResponse(w, r, model.NewErrPermission("access denied to channel")) return } auditRec := a.makeAuditRecord(r, "getChannel", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("channelID", teamID) channel, err := a.app.GetChannel(teamID, channelID) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetChannel", mlog.String("teamID", teamID), mlog.String("channelID", channelID), ) if channel.TeamId != teamID { if channel.Type != mm_model.ChannelTypeDirect && channel.Type != mm_model.ChannelTypeGroup { message := fmt.Sprintf("channel ID=%s on TeamID=%s", channel.Id, teamID) a.errorResponse(w, r, model.NewErrNotFound(message)) return } } data, err := json.Marshal(channel) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } ================================================ FILE: server/api/compliance.go ================================================ package api import ( "encoding/json" "fmt" "net/http" "strconv" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( complianceDefaultPage = "0" complianceDefaultPerPage = "60" ) func (a *API) registerComplianceRoutes(r *mux.Router) { // Compliance APIs r.HandleFunc("/admin/boards", a.sessionRequired(a.handleGetBoardsForCompliance)).Methods("GET") r.HandleFunc("/admin/boards_history", a.sessionRequired(a.handleGetBoardsComplianceHistory)).Methods("GET") r.HandleFunc("/admin/blocks_history", a.sessionRequired(a.handleGetBlocksComplianceHistory)).Methods("GET") } func (a *API) handleGetBoardsForCompliance(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /admin/boards getBoardsForCompliance // // Returns boards for a specific team, or all teams. // // Requires a license that includes Compliance feature. Caller must have `manage_system` permissions. // // --- // produces: // - application/json // parameters: // - name: team_id // in: query // description: Team ID. If empty then boards across all teams are included. // required: false // type: string // - name: page // in: query // description: The page to select (default=0) // required: false // type: integer // - name: per_page // in: query // description: Number of boards to return per page(default=60) // required: false // type: integer // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: object // items: // "$ref": "#/definitions/BoardsComplianceResponse" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" query := r.URL.Query() teamID := query.Get("team_id") strPage := query.Get("page") strPerPage := query.Get("per_page") // check for permission `manage_system` userID := getUserID(r) if !a.permissions.HasPermissionTo(userID, mm_model.PermissionManageSystem) { a.errorResponse(w, r, model.NewErrUnauthorized("access denied Compliance Export getAllBoards")) return } // check for valid license feature: compliance license := a.app.GetLicense() if license == nil || !(*license.Features.Compliance) { a.errorResponse(w, r, model.NewErrNotImplemented("insufficient license Compliance Export getAllBoards")) return } // check for valid team if specified if teamID != "" { _, err := a.app.GetTeam(teamID) if err != nil { a.errorResponse(w, r, model.NewErrBadRequest("invalid team id: "+teamID)) return } } if strPage == "" { strPage = complianceDefaultPage } if strPerPage == "" { strPerPage = complianceDefaultPerPage } page, err := strconv.Atoi(strPage) if err != nil { message := fmt.Sprintf("invalid `page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } perPage, err := strconv.Atoi(strPerPage) if err != nil { message := fmt.Sprintf("invalid `per_page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } opts := model.QueryBoardsForComplianceOptions{ TeamID: teamID, Page: page, PerPage: perPage, } boards, more, err := a.app.GetBoardsForCompliance(opts) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetBoardsForCompliance", mlog.String("teamID", teamID), mlog.Int("boardsCount", len(boards)), mlog.Bool("hasNext", more), ) response := model.BoardsComplianceResponse{ HasNext: more, Results: boards, } data, err := json.Marshal(response) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) } func (a *API) handleGetBoardsComplianceHistory(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /admin/boards_history getBoardsComplianceHistory // // Returns boards histories for a specific team, or all teams. // // Requires a license that includes Compliance feature. Caller must have `manage_system` permissions. // // --- // produces: // - application/json // parameters: // - name: modified_since // in: query // description: Filters for boards modified since timestamp; Unix time in milliseconds // required: true // type: integer // - name: include_deleted // in: query // description: When true then deleted boards are included. Default=false // required: false // type: boolean // - name: team_id // in: query // description: Team ID. If empty then board histories across all teams are included // required: false // type: string // - name: page // in: query // description: The page to select (default=0) // required: false // type: integer // - name: per_page // in: query // description: Number of board histories to return per page (default=60) // required: false // type: integer // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: object // items: // "$ref": "#/definitions/BoardsComplianceHistoryResponse" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" query := r.URL.Query() strModifiedSince := query.Get("modified_since") // required, everything else optional includeDeleted := query.Get("include_deleted") == "true" strPage := query.Get("page") strPerPage := query.Get("per_page") teamID := query.Get("team_id") if strModifiedSince == "" { a.errorResponse(w, r, model.NewErrBadRequest("`modified_since` parameter required")) return } // check for permission `manage_system` userID := getUserID(r) if !a.permissions.HasPermissionTo(userID, mm_model.PermissionManageSystem) { a.errorResponse(w, r, model.NewErrUnauthorized("access denied Compliance Export getBoardsHistory")) return } // check for valid license feature: compliance license := a.app.GetLicense() if license == nil || !(*license.Features.Compliance) { a.errorResponse(w, r, model.NewErrNotImplemented("insufficient license Compliance Export getBoardsHistory")) return } // check for valid team if specified if teamID != "" { _, err := a.app.GetTeam(teamID) if err != nil { a.errorResponse(w, r, model.NewErrBadRequest("invalid team id: "+teamID)) return } } if strPage == "" { strPage = complianceDefaultPage } if strPerPage == "" { strPerPage = complianceDefaultPerPage } page, err := strconv.Atoi(strPage) if err != nil { message := fmt.Sprintf("invalid `page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } perPage, err := strconv.Atoi(strPerPage) if err != nil { message := fmt.Sprintf("invalid `per_page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } modifiedSince, err := strconv.ParseInt(strModifiedSince, 10, 64) if err != nil { message := fmt.Sprintf("invalid `modified_since` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } opts := model.QueryBoardsComplianceHistoryOptions{ ModifiedSince: modifiedSince, IncludeDeleted: includeDeleted, TeamID: teamID, Page: page, PerPage: perPage, } boards, more, err := a.app.GetBoardsComplianceHistory(opts) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetBoardsComplianceHistory", mlog.String("teamID", teamID), mlog.Int("boardsCount", len(boards)), mlog.Bool("hasNext", more), ) response := model.BoardsComplianceHistoryResponse{ HasNext: more, Results: boards, } data, err := json.Marshal(response) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) } func (a *API) handleGetBlocksComplianceHistory(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /admin/blocks_history getBlocksComplianceHistory // // Returns block histories for a specific team, specific board, or all teams and boards. // // Requires a license that includes Compliance feature. Caller must have `manage_system` permissions. // // --- // produces: // - application/json // parameters: // - name: modified_since // in: query // description: Filters for boards modified since timestamp; Unix time in milliseconds // required: true // type: integer // - name: include_deleted // in: query // description: When true then deleted boards are included. Default=false // required: false // type: boolean // - name: team_id // in: query // description: Team ID. If empty then block histories across all teams are included // required: false // type: string // - name: board_id // in: query // description: Board ID. If empty then block histories for all boards are included // required: false // type: string // - name: page // in: query // description: The page to select (default=0) // required: false // type: integer // - name: per_page // in: query // description: Number of block histories to return per page (default=60) // required: false // type: integer // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: object // items: // "$ref": "#/definitions/BlocksComplianceHistoryResponse" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" query := r.URL.Query() strModifiedSince := query.Get("modified_since") // required, everything else optional includeDeleted := query.Get("include_deleted") == "true" strPage := query.Get("page") strPerPage := query.Get("per_page") teamID := query.Get("team_id") boardID := query.Get("board_id") if strModifiedSince == "" { a.errorResponse(w, r, model.NewErrBadRequest("`modified_since` parameter required")) return } // check for permission `manage_system` userID := getUserID(r) if !a.permissions.HasPermissionTo(userID, mm_model.PermissionManageSystem) { a.errorResponse(w, r, model.NewErrUnauthorized("access denied Compliance Export getBlocksHistory")) return } // check for valid license feature: compliance license := a.app.GetLicense() if license == nil || !(*license.Features.Compliance) { a.errorResponse(w, r, model.NewErrNotImplemented("insufficient license Compliance Export getBlocksHistory")) return } // check for valid team if specified if teamID != "" { _, err := a.app.GetTeam(teamID) if err != nil { a.errorResponse(w, r, model.NewErrBadRequest("invalid team id: "+teamID)) return } } // check for valid board if specified if boardID != "" { _, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, model.NewErrBadRequest("invalid board id: "+boardID)) return } } if strPage == "" { strPage = complianceDefaultPage } if strPerPage == "" { strPerPage = complianceDefaultPerPage } page, err := strconv.Atoi(strPage) if err != nil { message := fmt.Sprintf("invalid `page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } perPage, err := strconv.Atoi(strPerPage) if err != nil { message := fmt.Sprintf("invalid `per_page` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } modifiedSince, err := strconv.ParseInt(strModifiedSince, 10, 64) if err != nil { message := fmt.Sprintf("invalid `modified_since` parameter: %s", err) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } opts := model.QueryBlocksComplianceHistoryOptions{ ModifiedSince: modifiedSince, IncludeDeleted: includeDeleted, TeamID: teamID, BoardID: boardID, Page: page, PerPage: perPage, } blocks, more, err := a.app.GetBlocksComplianceHistory(opts) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetBlocksComplianceHistory", mlog.String("teamID", teamID), mlog.String("boardID", boardID), mlog.Int("blocksCount", len(blocks)), mlog.Bool("hasNext", more), ) response := model.BlocksComplianceHistoryResponse{ HasNext: more, Results: blocks, } data, err := json.Marshal(response) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) } ================================================ FILE: server/api/config.go ================================================ package api import ( "encoding/json" "net/http" "github.com/gorilla/mux" ) func (a *API) registerConfigRoutes(r *mux.Router) { // Config APIs r.HandleFunc("/clientConfig", a.getClientConfig).Methods("GET") } func (a *API) getClientConfig(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /clientConfig getClientConfig // // Returns the client configuration // // --- // produces: // - application/json // responses: // '200': // description: success // schema: // "$ref": "#/definitions/ClientConfig" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" clientConfig := a.app.GetClientConfig() configData, err := json.Marshal(clientConfig) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, configData) } ================================================ FILE: server/api/content_blocks.go ================================================ package api import ( "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" ) func (a *API) registerContentBlocksRoutes(r *mux.Router) { // Blocks APIs r.HandleFunc("/content-blocks/{blockID}/moveto/{where}/{dstBlockID}", a.sessionRequired(a.handleMoveBlockTo)).Methods("POST") } func (a *API) handleMoveBlockTo(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /content-blocks/{blockID}/move/{where}/{dstBlockID} moveBlockTo // // Move a block after another block in the parent card // // --- // produces: // - application/json // parameters: // - name: blockID // in: path // description: Block ID // required: true // type: string // - name: where // in: path // description: Relative location respect destination block (after or before) // required: true // type: string // - name: dstBlockID // in: path // description: Destination Block ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Block" // '404': // description: board or block not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" blockID := mux.Vars(r)["blockID"] dstBlockID := mux.Vars(r)["dstBlockID"] where := mux.Vars(r)["where"] userID := getUserID(r) block, err := a.app.GetBlockByID(blockID) if err != nil { a.errorResponse(w, r, err) return } dstBlock, err := a.app.GetBlockByID(dstBlockID) if err != nil { a.errorResponse(w, r, err) return } if where != "after" && where != "before" { a.errorResponse(w, r, model.NewErrBadRequest("invalid where parameter, use before or after")) return } if userID == "" { a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) return } if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to modify board cards")) return } auditRec := a.makeAuditRecord(r, "moveBlockTo", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("blockID", blockID) auditRec.AddMeta("dstBlockID", dstBlockID) err = a.app.MoveContentBlock(block, dstBlock, where, userID) if err != nil { a.errorResponse(w, r, err) return } // response jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } ================================================ FILE: server/api/context.go ================================================ package api import ( "context" "net" "net/http" ) type contextKey int const ( httpConnContextKey contextKey = iota sessionContextKey ) // SetContextConn stores the connection in the request context. func SetContextConn(ctx context.Context, c net.Conn) context.Context { return context.WithValue(ctx, httpConnContextKey, c) } // GetContextConn gets the stored connection from the request context. func GetContextConn(r *http.Request) net.Conn { value := r.Context().Value(httpConnContextKey) if value == nil { return nil } return value.(net.Conn) } ================================================ FILE: server/api/files.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package api import ( "encoding/json" "errors" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/mattermost/focalboard/server/app" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var UnsafeContentTypes = [...]string{ "application/javascript", "application/ecmascript", "text/javascript", "text/ecmascript", "application/x-javascript", "text/html", } var MediaContentTypes = [...]string{ "image/jpeg", "image/png", "image/bmp", "image/gif", "image/tiff", "video/avi", "video/mpeg", "video/mp4", "audio/mpeg", "audio/wav", } // FileUploadResponse is the response to a file upload // swagger:model type FileUploadResponse struct { // The FileID to retrieve the uploaded file // required: true FileID string `json:"fileId"` } func FileUploadResponseFromJSON(data io.Reader) (*FileUploadResponse, error) { var fileUploadResponse FileUploadResponse if err := json.NewDecoder(data).Decode(&fileUploadResponse); err != nil { return nil, err } return &fileUploadResponse, nil } func FileInfoResponseFromJSON(data io.Reader) (*mmModel.FileInfo, error) { var fileInfo mmModel.FileInfo if err := json.NewDecoder(data).Decode(&fileInfo); err != nil { return nil, err } return &fileInfo, nil } func (a *API) registerFilesRoutes(r *mux.Router) { // Files API r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}", a.attachSession(a.handleServeFile, false)).Methods("GET") r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}/info", a.attachSession(a.getFileInfo, false)).Methods("GET") r.HandleFunc("/teams/{teamID}/{boardID}/files", a.sessionRequired(a.handleUploadFile)).Methods("POST") } func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /files/teams/{teamID}/{boardID}/{filename} getFile // // Returns the contents of an uploaded file // // --- // produces: // - application/json // - image/jpg // - image/png // - image/gif // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: filename // in: path // description: name of the file // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // '404': // description: file not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) boardID := vars["boardID"] filename := vars["filename"] userID := getUserID(r) hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) if userID == "" && !hasValidReadToken { a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) return } if !hasValidReadToken && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "getFile", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("teamID", board.TeamID) auditRec.AddMeta("filename", filename) fileInfo, fileReader, err := a.app.GetFile(board.TeamID, boardID, filename) if err != nil && !model.IsErrNotFound(err) { a.errorResponse(w, r, err) return } if errors.Is(err, app.ErrFileNotFound) && board.ChannelID != "" { // prior to moving from workspaces to teams, the filepath was constructed from // workspaceID, which is the channel ID in plugin mode. // If a file is not found from team ID as we tried above, try looking for it via // channel ID. fileReader, err = a.app.GetFileReader(board.ChannelID, boardID, filename) if err != nil { a.errorResponse(w, r, err) return } // move file to team location // nothing to do if there is an error _ = a.app.MoveFile(board.ChannelID, board.TeamID, boardID, filename) } if err != nil { // if err is still not nil then it is an error other than `not found` so we must // return the error to the requestor. fileReader and Fileinfo are nil in this case. a.errorResponse(w, r, err) } defer fileReader.Close() mimeType := "" var fileSize int64 if fileInfo != nil { mimeType = fileInfo.MimeType fileSize = fileInfo.Size } writeFileResponse(filename, mimeType, fileSize, time.Now(), "", fileReader, false, w, r) auditRec.Success() } func writeFileResponse(filename string, contentType string, contentSize int64, lastModification time.Time, webserverMode string, fileReader io.ReadSeeker, forceDownload bool, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "private, no-cache") w.Header().Set("X-Content-Type-Options", "nosniff") if contentSize > 0 { contentSizeStr := strconv.Itoa(int(contentSize)) if webserverMode == "gzip" { w.Header().Set("X-Uncompressed-Content-Length", contentSizeStr) } else { w.Header().Set("Content-Length", contentSizeStr) } } if contentType == "" { contentType = "application/octet-stream" } else { for _, unsafeContentType := range UnsafeContentTypes { if strings.HasPrefix(contentType, unsafeContentType) { contentType = "text/plain" break } } } w.Header().Set("Content-Type", contentType) var toDownload bool if forceDownload { toDownload = true } else { isMediaType := false for _, mediaContentType := range MediaContentTypes { if strings.HasPrefix(contentType, mediaContentType) { isMediaType = true break } } toDownload = !isMediaType } filename = url.PathEscape(filename) if toDownload { w.Header().Set("Content-Disposition", "attachment;filename=\""+filename+"\"; filename*=UTF-8''"+filename) } else { w.Header().Set("Content-Disposition", "inline;filename=\""+filename+"\"; filename*=UTF-8''"+filename) } // prevent file links from being embedded in iframes w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Content-Security-Policy", "Frame-ancestors 'none'") http.ServeContent(w, r, filename, lastModification, fileReader) } func (a *API) getFileInfo(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /files/teams/{teamID}/{boardID}/{filename}/info getFile // // Returns the metadata of an uploaded file // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: filename // in: path // description: name of the file // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // '404': // description: file not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) boardID := vars["boardID"] teamID := vars["teamID"] filename := vars["filename"] userID := getUserID(r) hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) if userID == "" && !hasValidReadToken { a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) return } if !hasValidReadToken && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } auditRec := a.makeAuditRecord(r, "getFile", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("filename", filename) fileInfo, err := a.app.GetFileInfo(filename) if err != nil && !model.IsErrNotFound(err) { a.errorResponse(w, r, err) return } data, err := json.Marshal(fileInfo) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) } func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/boards/{boardID}/files uploadFile // // Upload a binary file, attached to a root block // // --- // consumes: // - multipart/form-data // produces: // - application/json // parameters: // - name: teamID // in: path // description: ID of the team // required: true // type: string // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: uploaded file // in: formData // type: file // description: The file to upload // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/FileUploadResponse" // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) boardID := vars["boardID"] userID := getUserID(r) if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes")) return } board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } if a.app.GetConfig().MaxFileSize > 0 { r.Body = http.MaxBytesReader(w, r.Body, a.app.GetConfig().MaxFileSize) } file, handle, err := r.FormFile(UploadFormFileKey) if err != nil { if strings.HasSuffix(err.Error(), "http: request body too large") { a.errorResponse(w, r, model.ErrRequestEntityTooLarge) return } a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } defer file.Close() auditRec := a.makeAuditRecord(r, "uploadFile", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("teamID", board.TeamID) auditRec.AddMeta("filename", handle.Filename) fileID, err := a.app.SaveFile(file, board.TeamID, boardID, handle.Filename, board.IsTemplate) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("uploadFile", mlog.String("filename", handle.Filename), mlog.String("fileID", fileID), ) data, err := json.Marshal(FileUploadResponse{FileID: fileID}) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("fileID", fileID) auditRec.Success() } ================================================ FILE: server/api/members.go ================================================ package api import ( "encoding/json" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerMembersRoutes(r *mux.Router) { // Member APIs r.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleGetMembersForBoard)).Methods("GET") r.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleAddMember)).Methods("POST") r.HandleFunc("/boards/{boardID}/members/{userID}", a.sessionRequired(a.handleUpdateMember)).Methods("PUT") r.HandleFunc("/boards/{boardID}/members/{userID}", a.sessionRequired(a.handleDeleteMember)).Methods("DELETE") r.HandleFunc("/boards/{boardID}/join", a.sessionRequired(a.handleJoinBoard)).Methods("POST") r.HandleFunc("/boards/{boardID}/leave", a.sessionRequired(a.handleLeaveBoard)).Methods("POST") } func (a *API) handleGetMembersForBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID}/members getMembersForBoard // // Returns the members of the board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/BoardMember" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board members")) return } auditRec := a.makeAuditRecord(r, "getMembersForBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) members, err := a.app.GetMembersForBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetMembersForBoard", mlog.String("boardID", boardID), mlog.Int("membersCount", len(members)), ) data, err := json.Marshal(members) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleAddMember(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/members addMember // // Adds a new member to a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: Body // in: body // description: membership to replace the current one with // required: true // schema: // "$ref": "#/definitions/BoardMember" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/BoardMember' // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) && !(board.Type == model.BoardTypeOpen && a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardProperties)) { a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members")) return } requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var reqBoardMember *model.BoardMember if err = json.Unmarshal(requestBody, &reqBoardMember); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } if reqBoardMember.UserID == "" { a.errorResponse(w, r, model.NewErrBadRequest("empty userID")) return } if !a.permissions.HasPermissionToTeam(reqBoardMember.UserID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } newBoardMember := &model.BoardMember{ UserID: reqBoardMember.UserID, BoardID: boardID, SchemeEditor: reqBoardMember.SchemeEditor, SchemeAdmin: reqBoardMember.SchemeAdmin, SchemeViewer: reqBoardMember.SchemeViewer, SchemeCommenter: reqBoardMember.SchemeCommenter, } auditRec := a.makeAuditRecord(r, "addMember", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("addedUserID", reqBoardMember.UserID) member, err := a.app.AddMemberToBoard(newBoardMember) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("AddMember", mlog.String("boardID", board.ID), mlog.String("addedUserID", reqBoardMember.UserID), ) data, err := json.Marshal(member) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/join joinBoard // // Become a member of a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: allow_admin // in: path // description: allows admin users to join private boards // required: false // type: boolean // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/BoardMember' // '404': // description: board not found // '403': // description: access denied // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" query := r.URL.Query() allowAdmin := query.Has("allow_admin") userID := getUserID(r) if userID == "" { a.errorResponse(w, r, model.NewErrBadRequest("missing user ID")) return } boardID := mux.Vars(r)["boardID"] board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } isAdmin := false if board.Type != model.BoardTypeOpen { if !allowAdmin || !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionManageTeam) { a.errorResponse(w, r, model.NewErrPermission("cannot join a non Open board")) return } isAdmin = true } if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("guests not allowed to join boards")) return } newBoardMember := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeAdmin: board.MinimumRole == model.BoardRoleAdmin || isAdmin, SchemeEditor: board.MinimumRole == model.BoardRoleNone || board.MinimumRole == model.BoardRoleEditor, SchemeCommenter: board.MinimumRole == model.BoardRoleCommenter, SchemeViewer: board.MinimumRole == model.BoardRoleViewer, } auditRec := a.makeAuditRecord(r, "joinBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("addedUserID", userID) member, err := a.app.AddMemberToBoard(newBoardMember) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("JoinBoard", mlog.String("boardID", board.ID), mlog.String("addedUserID", userID), ) data, err := json.Marshal(member) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleLeaveBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/leave leaveBoard // // Remove your own membership from a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // '404': // description: board not found // '403': // description: access denied // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) if userID == "" { a.errorResponse(w, r, model.NewErrBadRequest("invalid session")) return } boardID := mux.Vars(r)["boardID"] if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to board")) return } board, err := a.app.GetBoard(boardID) if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "leaveBoard", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("addedUserID", userID) err = a.app.DeleteBoardMember(boardID, userID) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("LeaveBoard", mlog.String("boardID", board.ID), mlog.String("addedUserID", userID), ) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleUpdateMember(w http.ResponseWriter, r *http.Request) { // swagger:operation PUT /boards/{boardID}/members/{userID} updateMember // // Updates a board member // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: userID // in: path // description: User ID // required: true // type: string // - name: Body // in: body // description: membership to replace the current one with // required: true // schema: // "$ref": "#/definitions/BoardMember" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // $ref: '#/definitions/BoardMember' // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] paramsUserID := mux.Vars(r)["userID"] userID := getUserID(r) requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var reqBoardMember *model.BoardMember if err = json.Unmarshal(requestBody, &reqBoardMember); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } newBoardMember := &model.BoardMember{ UserID: paramsUserID, BoardID: boardID, SchemeAdmin: reqBoardMember.SchemeAdmin, SchemeEditor: reqBoardMember.SchemeEditor, SchemeCommenter: reqBoardMember.SchemeCommenter, SchemeViewer: reqBoardMember.SchemeViewer, } isGuest, err := a.userIsGuest(paramsUserID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { newBoardMember.SchemeAdmin = false } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) { a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members")) return } auditRec := a.makeAuditRecord(r, "patchMember", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("patchedUserID", paramsUserID) member, err := a.app.UpdateBoardMember(newBoardMember) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("PatchMember", mlog.String("boardID", boardID), mlog.String("patchedUserID", paramsUserID), ) data, err := json.Marshal(member) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleDeleteMember(w http.ResponseWriter, r *http.Request) { // swagger:operation DELETE /boards/{boardID}/members/{userID} deleteMember // // Deletes a member from a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: userID // in: path // description: User ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] paramsUserID := mux.Vars(r)["userID"] userID := getUserID(r) if _, err := a.app.GetBoard(boardID); err != nil { a.errorResponse(w, r, err) return } if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) { a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members")) return } auditRec := a.makeAuditRecord(r, "deleteMember", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("boardID", boardID) auditRec.AddMeta("addedUserID", paramsUserID) deleteErr := a.app.DeleteBoardMember(boardID, paramsUserID) if deleteErr != nil { a.errorResponse(w, r, deleteErr) return } a.logger.Debug("DeleteMember", mlog.String("boardID", boardID), mlog.String("addedUserID", paramsUserID), ) // response jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } ================================================ FILE: server/api/onboarding.go ================================================ package api import ( "encoding/json" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" ) func (a *API) registerOnboardingRoutes(r *mux.Router) { // Onboarding tour endpoints APIs r.HandleFunc("/teams/{teamID}/onboard", a.sessionRequired(a.handleOnboard)).Methods(http.MethodPost) } func (a *API) handleOnboard(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /team/{teamID}/onboard onboard // // Onboards a user on Boards. // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: object // properties: // teamID: // type: string // description: Team ID // boardID: // type: string // description: Board ID // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" teamID := mux.Vars(r)["teamID"] userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to create board")) return } isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("access denied to create board")) return } teamID, boardID, err := a.app.PrepareOnboardingTour(userID, teamID) if err != nil { a.errorResponse(w, r, err) return } response := map[string]string{ "teamID": teamID, "boardID": boardID, } data, err := json.Marshal(response) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) } ================================================ FILE: server/api/search.go ================================================ package api import ( "encoding/json" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerSearchRoutes(r *mux.Router) { r.HandleFunc("/teams/{teamID}/channels", a.sessionRequired(a.handleSearchMyChannels)).Methods("GET") r.HandleFunc("/teams/{teamID}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET") r.HandleFunc("/teams/{teamID}/boards/search/linkable", a.sessionRequired(a.handleSearchLinkableBoards)).Methods("GET") r.HandleFunc("/boards/search", a.sessionRequired(a.handleSearchAllBoards)).Methods("GET") } func (a *API) handleSearchMyChannels(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/channels searchMyChannels // // Returns the user available channels // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: search // in: query // description: string to filter channels list // required: false // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Channel" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if !a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode")) return } query := r.URL.Query() searchQuery := query.Get("search") teamID := mux.Vars(r)["teamID"] userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } auditRec := a.makeAuditRecord(r, "searchMyChannels", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamID", teamID) channels, err := a.app.SearchUserChannels(teamID, userID, searchQuery) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GetUserChannels", mlog.String("teamID", teamID), mlog.Int("channelsCount", len(channels)), ) data, err := json.Marshal(channels) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("channelsCount", len(channels)) auditRec.Success() } func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/boards/search searchBoards // // Returns the boards that match with a search term in the team // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: q // in: query // description: The search term. Must have at least one character // required: true // type: string // - name: field // in: query // description: The field to search on for search term. Can be `title`, `property_name`. Defaults to `title` // required: false // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Board" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" var err error teamID := mux.Vars(r)["teamID"] term := r.URL.Query().Get("q") searchFieldText := r.URL.Query().Get("field") searchField := model.BoardSearchFieldTitle if searchFieldText != "" { searchField, err = model.BoardSearchFieldFromString(searchFieldText) if err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } } userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } if len(term) == 0 { jsonStringResponse(w, http.StatusOK, "[]") return } auditRec := a.makeAuditRecord(r, "searchBoards", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamID", teamID) isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } // retrieve boards list boards, err := a.app.SearchBoardsForUser(term, searchField, userID, !isGuest) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("SearchBoards", mlog.String("teamID", teamID), mlog.Int("boardsCount", len(boards)), ) data, err := json.Marshal(boards) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("boardsCount", len(boards)) auditRec.Success() } func (a *API) handleSearchLinkableBoards(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/boards/search/linkable searchLinkableBoards // // Returns the boards that match with a search term in the team and the // user has permission to manage members // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: q // in: query // description: The search term. Must have at least one character // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Board" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if !a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode")) return } teamID := mux.Vars(r)["teamID"] term := r.URL.Query().Get("q") userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } if len(term) == 0 { jsonStringResponse(w, http.StatusOK, "[]") return } auditRec := a.makeAuditRecord(r, "searchLinkableBoards", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamID", teamID) // retrieve boards list boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID) if err != nil { a.errorResponse(w, r, err) return } linkableBoards := []*model.Board{} for _, board := range boards { if a.permissions.HasPermissionToBoard(userID, board.ID, model.PermissionManageBoardRoles) { linkableBoards = append(linkableBoards, board) } } a.logger.Debug("SearchLinkableBoards", mlog.String("teamID", teamID), mlog.Int("boardsCount", len(linkableBoards)), ) data, err := json.Marshal(linkableBoards) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("boardsCount", len(linkableBoards)) auditRec.Success() } func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/search searchAllBoards // // Returns the boards that match with a search term // // --- // produces: // - application/json // parameters: // - name: q // in: query // description: The search term. Must have at least one character // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Board" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" term := r.URL.Query().Get("q") userID := getUserID(r) if len(term) == 0 { jsonStringResponse(w, http.StatusOK, "[]") return } auditRec := a.makeAuditRecord(r, "searchAllBoards", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } // retrieve boards list boards, err := a.app.SearchBoardsForUser(term, model.BoardSearchFieldTitle, userID, !isGuest) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("SearchAllBoards", mlog.Int("boardsCount", len(boards)), ) data, err := json.Marshal(boards) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("boardsCount", len(boards)) auditRec.Success() } ================================================ FILE: server/api/sharing.go ================================================ package api import ( "encoding/json" "errors" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var ErrTurningOnSharing = errors.New("turning on sharing for board failed, see log for details") func (a *API) registerSharingRoutes(r *mux.Router) { // Sharing APIs r.HandleFunc("/boards/{boardID}/sharing", a.sessionRequired(a.handlePostSharing)).Methods("POST") r.HandleFunc("/boards/{boardID}/sharing", a.sessionRequired(a.handleGetSharing)).Methods("GET") } func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID}/sharing getSharing // // Returns sharing information for a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Sharing" // '404': // description: board not found // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) boardID := vars["boardID"] userID := getUserID(r) if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionShareBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to sharing the board")) return } auditRec := a.makeAuditRecord(r, "getSharing", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("boardID", boardID) sharing, err := a.app.GetSharing(boardID) if err != nil { a.errorResponse(w, r, err) return } sharingData, err := json.Marshal(sharing) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, sharingData) a.logger.Debug("GET sharing", mlog.String("boardID", boardID), mlog.String("shareID", sharing.ID), mlog.Bool("enabled", sharing.Enabled), ) auditRec.AddMeta("shareID", sharing.ID) auditRec.AddMeta("enabled", sharing.Enabled) auditRec.Success() } func (a *API) handlePostSharing(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /boards/{boardID}/sharing postSharing // // Sets sharing information for a board // // --- // produces: // - application/json // parameters: // - name: boardID // in: path // description: Board ID // required: true // type: string // - name: Body // in: body // description: sharing information for a root block // required: true // schema: // "$ref": "#/definitions/Sharing" // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" boardID := mux.Vars(r)["boardID"] userID := getUserID(r) if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionShareBoard) { a.errorResponse(w, r, model.NewErrPermission("access denied to sharing the board")) return } requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var sharing model.Sharing err = json.Unmarshal(requestBody, &sharing) if err != nil { a.errorResponse(w, r, err) return } // Stamp boardID from the URL sharing.ID = boardID auditRec := a.makeAuditRecord(r, "postSharing", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("shareID", sharing.ID) auditRec.AddMeta("enabled", sharing.Enabled) // Stamp ModifiedBy modifiedBy := userID if userID == model.SingleUser { modifiedBy = "" } sharing.ModifiedBy = modifiedBy if userID == model.SingleUser { userID = "" } if !a.app.GetClientConfig().EnablePublicSharedBoards { a.logger.Warn( "Attempt to turn on sharing for board via API failed, sharing off in configuration.", mlog.String("boardID", sharing.ID), mlog.String("userID", userID)) a.errorResponse(w, r, ErrTurningOnSharing) return } sharing.ModifiedBy = userID err = a.app.UpsertSharing(sharing) if err != nil { a.errorResponse(w, r, err) return } jsonStringResponse(w, http.StatusOK, "{}") a.logger.Debug("POST sharing", mlog.String("sharingID", sharing.ID)) auditRec.Success() } ================================================ FILE: server/api/statistics.go ================================================ package api import ( "encoding/json" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" ) func (a *API) registerStatisticsRoutes(r *mux.Router) { // statistics r.HandleFunc("/statistics", a.sessionRequired(a.handleStatistics)).Methods("GET") } func (a *API) handleStatistics(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /statistics handleStatistics // // Fetches the statistic of the server. // // --- // produces: // - application/json // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/BoardStatistics" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if !a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode")) return } // user must have right to access analytics userID := getUserID(r) if !a.permissions.HasPermissionTo(userID, mmModel.PermissionGetAnalytics) { a.errorResponse(w, r, model.NewErrPermission("access denied System Statistics")) return } boardCount, err := a.app.GetBoardCount() if err != nil { a.errorResponse(w, r, err) return } cardCount, err := a.app.GetUsedCardsCount() if err != nil { a.errorResponse(w, r, err) return } stats := model.BoardsStatistics{ Boards: int(boardCount), Cards: cardCount, } data, err := json.Marshal(stats) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) } ================================================ FILE: server/api/subscriptions.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerSubscriptionsRoutes(r *mux.Router) { // Subscription APIs r.HandleFunc("/subscriptions", a.sessionRequired(a.handleCreateSubscription)).Methods("POST") r.HandleFunc("/subscriptions/{blockID}/{subscriberID}", a.sessionRequired(a.handleDeleteSubscription)).Methods("DELETE") r.HandleFunc("/subscriptions/{subscriberID}", a.sessionRequired(a.handleGetSubscriptions)).Methods("GET") } // subscriptions func (a *API) handleCreateSubscription(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /subscriptions createSubscription // // Creates a subscription to a block for a user. The user will receive change notifications for the block. // // --- // produces: // - application/json // parameters: // - name: Body // in: body // description: subscription definition // required: true // schema: // "$ref": "#/definitions/Subscription" // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/User" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var sub model.Subscription if err = json.Unmarshal(requestBody, &sub); err != nil { a.errorResponse(w, r, err) return } if err = sub.IsValid(); err != nil { a.errorResponse(w, r, model.NewErrBadRequest(err.Error())) return } ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) auditRec := a.makeAuditRecord(r, "createSubscription", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("subscriber_id", sub.SubscriberID) auditRec.AddMeta("block_id", sub.BlockID) // User can only create subscriptions for themselves (for now) if session.UserID != sub.SubscriberID { a.errorResponse(w, r, model.NewErrBadRequest("userID and subscriberID mismatch")) return } // check for valid block _, bErr := a.app.GetBlockByID(sub.BlockID) if bErr != nil { message := fmt.Sprintf("invalid blockID: %s", bErr) a.errorResponse(w, r, model.NewErrBadRequest(message)) return } subNew, err := a.app.CreateSubscription(&sub) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("CREATE subscription", mlog.String("subscriber_id", subNew.SubscriberID), mlog.String("block_id", subNew.BlockID), ) json, err := json.Marshal(subNew) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, json) auditRec.Success() } func (a *API) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) { // swagger:operation DELETE /subscriptions/{blockID}/{subscriberID} deleteSubscription // // Deletes a subscription a user has for a a block. The user will no longer receive change notifications for the block. // // --- // produces: // - application/json // parameters: // - name: blockID // in: path // description: Block ID // required: true // type: string // - name: subscriberID // in: path // description: Subscriber ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) vars := mux.Vars(r) blockID := vars["blockID"] subscriberID := vars["subscriberID"] auditRec := a.makeAuditRecord(r, "deleteSubscription", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) auditRec.AddMeta("block_id", blockID) auditRec.AddMeta("subscriber_id", subscriberID) // User can only delete subscriptions for themselves if session.UserID != subscriberID { a.errorResponse(w, r, model.NewErrPermission("access denied")) return } if _, err := a.app.DeleteSubscription(blockID, subscriberID); err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("DELETE subscription", mlog.String("blockID", blockID), mlog.String("subscriberID", subscriberID), ) jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleGetSubscriptions(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /subscriptions/{subscriberID} getSubscriptions // // Gets subscriptions for a user. // // --- // produces: // - application/json // parameters: // - name: subscriberID // in: path // description: Subscriber ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/User" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) vars := mux.Vars(r) subscriberID := vars["subscriberID"] auditRec := a.makeAuditRecord(r, "getSubscriptions", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("subscriber_id", subscriberID) // User can only get subscriptions for themselves (for now) if session.UserID != subscriberID { a.errorResponse(w, r, model.NewErrPermission("access denied")) return } subs, err := a.app.GetSubscriptions(subscriberID) if err != nil { a.errorResponse(w, r, err) return } a.logger.Debug("GET subscriptions", mlog.String("subscriberID", subscriberID), mlog.Int("count", len(subs)), ) json, err := json.Marshal(subs) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, json) auditRec.AddMeta("subscription_count", len(subs)) auditRec.Success() } ================================================ FILE: server/api/system.go ================================================ package api import ( "encoding/json" "net/http" "github.com/gorilla/mux" ) func (a *API) registerSystemRoutes(r *mux.Router) { // System APIs r.HandleFunc("/hello", a.handleHello).Methods("GET") r.HandleFunc("/ping", a.handlePing).Methods("GET") } func (a *API) handleHello(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /hello hello // // Responds with `Hello` if the web service is running. // // --- // produces: // - text/plain // responses: // '200': // description: success stringResponse(w, "Hello") } func (a *API) handlePing(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /ping ping // // Responds with server metadata if the web service is running. // // --- // produces: // - application/json // responses: // '200': // description: success serverMetadata := a.app.GetServerMetadata() if a.singleUserToken != "" { serverMetadata.SKU = "personal_desktop" } if serverMetadata.Edition == "plugin" { serverMetadata.SKU = "suite" } bytes, err := json.Marshal(serverMetadata) if err != nil { a.errorResponse(w, r, err) } jsonStringResponse(w, 200, string(bytes)) } ================================================ FILE: server/api/system_test.go ================================================ package api import ( "encoding/json" "net/http" "net/http/httptest" "runtime" "testing" "github.com/mattermost/focalboard/server/app" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func TestHello(t *testing.T) { testAPI := API{logger: mlog.CreateConsoleTestLogger(t)} t.Run("Returns 'Hello' on success", func(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "/hello", nil) response := httptest.NewRecorder() testAPI.handleHello(response, request) got := response.Body.String() want := "Hello" if got != want { t.Errorf("got %q want %q", got, want) } if response.Code != http.StatusOK { t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK) } }) } func TestPing(t *testing.T) { testAPI := API{logger: mlog.CreateConsoleTestLogger(t)} t.Run("Returns metadata on success", func(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "/ping", nil) response := httptest.NewRecorder() testAPI.handlePing(response, request) var got app.ServerMetadata err := json.NewDecoder(response.Body).Decode(&got) if err != nil { t.Fatalf("Unable to JSON decode response body %q", response.Body) } want := app.ServerMetadata{ Version: model.CurrentVersion, BuildNumber: model.BuildNumber, BuildDate: model.BuildDate, Commit: model.BuildHash, Edition: model.Edition, DBType: "", DBVersion: "", OSType: runtime.GOOS, OSArch: runtime.GOARCH, SKU: "personal_server", } if got != want { t.Errorf("got %q want %q", got, want) } if response.Code != http.StatusOK { t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK) } }) t.Run("Sets SKU to 'personal_desktop' when in single-user mode", func(t *testing.T) { testAPI.singleUserToken = "abc-123-xyz-456" request, _ := http.NewRequest(http.MethodGet, "/ping", nil) response := httptest.NewRecorder() testAPI.handlePing(response, request) var got app.ServerMetadata err := json.NewDecoder(response.Body).Decode(&got) if err != nil { t.Fatalf("Unable to JSON decode response body %q", response.Body) } want := app.ServerMetadata{ Version: model.CurrentVersion, BuildNumber: model.BuildNumber, BuildDate: model.BuildDate, Commit: model.BuildHash, Edition: model.Edition, DBType: "", DBVersion: "", OSType: runtime.GOOS, OSArch: runtime.GOARCH, SKU: "personal_desktop", } if got != want { t.Errorf("got %q want %q", got, want) } if response.Code != http.StatusOK { t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK) } }) t.Run("Sets SKU to 'suite' when in plugin mode", func(t *testing.T) { model.Edition = "plugin" request, _ := http.NewRequest(http.MethodGet, "/ping", nil) response := httptest.NewRecorder() testAPI.handlePing(response, request) var got app.ServerMetadata err := json.NewDecoder(response.Body).Decode(&got) if err != nil { t.Fatalf("Unable to JSON decode response body %q", response.Body) } want := app.ServerMetadata{ Version: model.CurrentVersion, BuildNumber: model.BuildNumber, BuildDate: model.BuildDate, Commit: model.BuildHash, Edition: "plugin", DBType: "", DBVersion: "", OSType: runtime.GOOS, OSArch: runtime.GOARCH, SKU: "suite", } if got != want { t.Errorf("got %q want %q", got, want) } if response.Code != http.StatusOK { t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK) } }) } ================================================ FILE: server/api/teams.go ================================================ package api import ( "encoding/json" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/focalboard/server/utils" ) func (a *API) registerTeamsRoutes(r *mux.Router) { // Team APIs r.HandleFunc("/teams", a.sessionRequired(a.handleGetTeams)).Methods("GET") r.HandleFunc("/teams/{teamID}", a.sessionRequired(a.handleGetTeam)).Methods("GET") r.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsers)).Methods("GET") r.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsersByID)).Methods("POST") r.HandleFunc("/teams/{teamID}/archive/export", a.sessionRequired(a.handleArchiveExportTeam)).Methods("GET") } func (a *API) handleGetTeams(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams getTeams // // Returns information of all the teams // // --- // produces: // - application/json // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Team" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) teams, err := a.app.GetTeamsForUser(userID) if err != nil { a.errorResponse(w, r, err) } auditRec := a.makeAuditRecord(r, "getTeams", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamCount", len(teams)) data, err := json.Marshal(teams) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleGetTeam(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID} getTeam // // Returns information of the root team // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Team" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) teamID := vars["teamID"] userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } var team *model.Team var err error if a.MattermostAuth { team, err = a.app.GetTeam(teamID) if model.IsErrNotFound(err) { a.errorResponse(w, r, model.NewErrUnauthorized("invalid team")) } if err != nil { a.errorResponse(w, r, err) } } else { team, err = a.app.GetRootTeam() if err != nil { a.errorResponse(w, r, err) return } } auditRec := a.makeAuditRecord(r, "getTeam", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("resultTeamID", team.ID) data, err := json.Marshal(team) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/regenerate_signup_token regenerateSignupToken // // Regenerates the signup token for the root team // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" if a.MattermostAuth { a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode")) return } team, err := a.app.GetRootTeam() if err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "regenerateSignupToken", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) team.SignupToken = utils.NewID(utils.IDTypeToken) if err = a.app.UpsertTeamSignupToken(*team); err != nil { a.errorResponse(w, r, err) return } jsonStringResponse(w, http.StatusOK, "{}") auditRec.Success() } func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/users getTeamUsers // // Returns team users // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: search // in: query // description: string to filter users list // required: false // type: string // - name: exclude_bots // in: query // description: exclude bot users // required: false // type: boolean // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/User" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) teamID := vars["teamID"] userID := getUserID(r) query := r.URL.Query() searchQuery := query.Get("search") excludeBots := r.URL.Query().Get("exclude_bots") == True if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } auditRec := a.makeAuditRecord(r, "getUsers", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } asGuestUser := "" if isGuest { asGuestUser = userID } users, err := a.app.SearchTeamUsers(teamID, searchQuery, asGuestUser, excludeBots) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(users) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("userCount", len(users)) auditRec.Success() } func (a *API) handleGetTeamUsersByID(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/users getTeamUsersByID // // Returns a user[] // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // - name: Body // in: body // description: []UserIDs to return // required: true // type: []string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/User" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var userIDs []string if err = json.Unmarshal(requestBody, &userIDs); err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "getTeamUsersByID", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) vars := mux.Vars(r) teamID := vars["teamID"] userID := getUserID(r) if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } var users []*model.User var error error if len(userIDs) == 0 { a.errorResponse(w, r, model.NewErrBadRequest("User IDs are empty")) return } if userIDs[0] == model.SingleUser { ws, _ := a.app.GetRootTeam() now := utils.GetMillis() user := &model.User{ ID: model.SingleUser, Username: model.SingleUser, Email: model.SingleUser, CreateAt: ws.UpdateAt, UpdateAt: now, } users = append(users, user) } else { users, error = a.app.GetUsersList(userIDs) if error != nil { a.errorResponse(w, r, error) return } for i, u := range users { if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) { users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id) } if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) { users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id) } } } usersList, err := json.Marshal(users) if err != nil { a.errorResponse(w, r, err) return } jsonStringResponse(w, http.StatusOK, string(usersList)) auditRec.Success() } ================================================ FILE: server/api/templates.go ================================================ package api import ( "encoding/json" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *API) registerTemplatesRoutes(r *mux.Router) { r.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).Methods("GET") } func (a *API) handleGetTemplates(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/templates getTemplates // // Returns team templates // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/Board" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" teamID := mux.Vars(r)["teamID"] userID := getUserID(r) if teamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { a.errorResponse(w, r, model.NewErrPermission("access denied to team")) return } isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } if isGuest { a.errorResponse(w, r, model.NewErrPermission("access denied to templates")) return } auditRec := a.makeAuditRecord(r, "getTemplates", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamID", teamID) // retrieve boards list boards, err := a.app.GetTemplateBoards(teamID, userID) if err != nil { a.errorResponse(w, r, err) return } results := []*model.Board{} for _, board := range boards { if board.Type == model.BoardTypeOpen { results = append(results, board) } else if a.permissions.HasPermissionToBoard(userID, board.ID, model.PermissionViewBoard) { results = append(results, board) } } a.logger.Debug("GetTemplates", mlog.String("teamID", teamID), mlog.Int("boardsCount", len(results)), ) data, err := json.Marshal(results) if err != nil { a.errorResponse(w, r, err) return } // response jsonBytesResponse(w, http.StatusOK, data) auditRec.AddMeta("templatesCount", len(results)) auditRec.Success() } ================================================ FILE: server/api/users.go ================================================ package api import ( "encoding/json" "io" "net/http" "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/focalboard/server/utils" ) func (a *API) registerUsersRoutes(r *mux.Router) { // Users APIs r.HandleFunc("/users", a.sessionRequired(a.handleGetUsersList)).Methods("POST") r.HandleFunc("/users/me", a.sessionRequired(a.handleGetMe)).Methods("GET") r.HandleFunc("/users/me/memberships", a.sessionRequired(a.handleGetMyMemberships)).Methods("GET") r.HandleFunc("/users/{userID}", a.sessionRequired(a.handleGetUser)).Methods("GET") r.HandleFunc("/users/{userID}/config", a.sessionRequired(a.handleUpdateUserConfig)).Methods(http.MethodPut) r.HandleFunc("/users/me/config", a.sessionRequired(a.handleGetUserPreferences)).Methods(http.MethodGet) } func (a *API) handleGetUsersList(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /users getUsersList // // Returns a user[] // // --- // produces: // - application/json // parameters: // - name: userID // in: path // description: User ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/User" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var userIDs []string if err = json.Unmarshal(requestBody, &userIDs); err != nil { a.errorResponse(w, r, err) return } auditRec := a.makeAuditRecord(r, "getUsersList", audit.Fail) defer a.audit.LogRecord(audit.LevelAuth, auditRec) var users []*model.User var error error if len(userIDs) == 0 { a.errorResponse(w, r, model.NewErrBadRequest("User IDs are empty")) return } if userIDs[0] == model.SingleUser { ws, _ := a.app.GetRootTeam() now := utils.GetMillis() user := &model.User{ ID: model.SingleUser, Username: model.SingleUser, Email: model.SingleUser, CreateAt: ws.UpdateAt, UpdateAt: now, } users = append(users, user) } else { users, error = a.app.GetUsersList(userIDs) if error != nil { a.errorResponse(w, r, error) return } } ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) isSystemAdmin := a.permissions.HasPermissionTo(session.UserID, model.PermissionManageSystem) sanitizedUsers := make([]*model.User, 0) for _, user := range users { canSeeUser, err2 := a.app.CanSeeUser(session.UserID, user.ID) if err2 != nil { a.errorResponse(w, r, err2) return } if !canSeeUser { continue } if user.ID == session.UserID { user.Sanitize(map[string]bool{}) } else { a.app.SanitizeProfile(user, isSystemAdmin) } sanitizedUsers = append(sanitizedUsers, user) } usersList, err := json.Marshal(sanitizedUsers) if err != nil { a.errorResponse(w, r, err) return } jsonStringResponse(w, http.StatusOK, string(usersList)) auditRec.Success() } func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /users/me getMe // // Returns the currently logged-in user // // --- // produces: // - application/json // parameters: // - name: teamID // in: path // description: Team ID // required: false // type: string // - name: channelID // in: path // description: Channel ID // required: false // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/User" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" query := r.URL.Query() teamID := query.Get("teamID") channelID := query.Get("channelID") userID := getUserID(r) var user *model.User var err error auditRec := a.makeAuditRecord(r, "getMe", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) if userID == model.SingleUser { ws, _ := a.app.GetRootTeam() now := utils.GetMillis() user = &model.User{ ID: model.SingleUser, Username: model.SingleUser, Email: model.SingleUser, CreateAt: ws.UpdateAt, UpdateAt: now, } } else { user, err = a.app.GetUser(userID) if err != nil { // ToDo: wrap with an invalid token error a.errorResponse(w, r, err) return } } if teamID != "" && a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionManageTeam) { user.Permissions = append(user.Permissions, model.PermissionManageTeam.Id) } if a.permissions.HasPermissionTo(userID, model.PermissionManageSystem) { user.Permissions = append(user.Permissions, model.PermissionManageSystem.Id) } if channelID != "" && a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionCreatePost) { user.Permissions = append(user.Permissions, model.PermissionCreatePost.Id) } user.Sanitize(map[string]bool{}) userData, err := json.Marshal(user) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, userData) auditRec.AddMeta("userID", user.ID) auditRec.Success() } func (a *API) handleGetMyMemberships(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /users/me/memberships getMyMemberships // // Returns the currently users board memberships // // --- // produces: // - application/json // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // type: array // items: // "$ref": "#/definitions/BoardMember" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) auditRec := a.makeAuditRecord(r, "getMyBoardMemberships", audit.Fail) auditRec.AddMeta("userID", userID) defer a.audit.LogRecord(audit.LevelRead, auditRec) members, err := a.app.GetMembersForUser(userID) if err != nil { a.errorResponse(w, r, err) return } membersData, err := json.Marshal(members) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, membersData) auditRec.Success() } func (a *API) handleGetUser(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /users/{userID} getUser // // Returns a user // // --- // produces: // - application/json // parameters: // - name: userID // in: path // description: User ID // required: true // type: string // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/User" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" vars := mux.Vars(r) userID := vars["userID"] auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("userID", userID) user, err := a.app.GetUser(userID) if err != nil { a.errorResponse(w, r, err) return } ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) canSeeUser, err := a.app.CanSeeUser(session.UserID, userID) if err != nil { a.errorResponse(w, r, err) return } if !canSeeUser { a.errorResponse(w, r, model.NewErrNotFound("user ID="+userID)) return } if userID == session.UserID { user.Sanitize(map[string]bool{}) } else { a.app.SanitizeProfile(user, a.permissions.HasPermissionTo(session.UserID, model.PermissionManageSystem)) } userData, err := json.Marshal(user) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, userData) auditRec.Success() } func (a *API) handleUpdateUserConfig(w http.ResponseWriter, r *http.Request) { // swagger:operation PATCH /users/{userID}/config updateUserConfig // // Updates user config // // --- // produces: // - application/json // parameters: // - name: userID // in: path // description: User ID // required: true // type: string // - name: Body // in: body // description: User config patch to apply // required: true // schema: // "$ref": "#/definitions/UserPreferencesPatch" // security: // - BearerAuth: [] // responses: // '200': // description: success // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" requestBody, err := io.ReadAll(r.Body) if err != nil { a.errorResponse(w, r, err) return } var patch *model.UserPreferencesPatch err = json.Unmarshal(requestBody, &patch) if err != nil { a.errorResponse(w, r, err) return } vars := mux.Vars(r) userID := vars["userID"] ctx := r.Context() session := ctx.Value(sessionContextKey).(*model.Session) auditRec := a.makeAuditRecord(r, "updateUserConfig", audit.Fail) defer a.audit.LogRecord(audit.LevelModify, auditRec) // a user can update only own config if userID != session.UserID { a.errorResponse(w, r, model.NewErrForbidden("")) return } updatedConfig, err := a.app.UpdateUserConfig(userID, *patch) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(updatedConfig) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } func (a *API) handleGetUserPreferences(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /users/me/config getUserConfig // // Returns an array of user preferences // // --- // produces: // - application/json // security: // - BearerAuth: [] // responses: // '200': // description: success // schema: // "$ref": "#/definitions/Preferences" // default: // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" userID := getUserID(r) auditRec := a.makeAuditRecord(r, "getUserConfig", audit.Fail) defer a.audit.LogRecord(audit.LevelRead, auditRec) preferences, err := a.app.GetUserPreferences(userID) if err != nil { a.errorResponse(w, r, err) return } data, err := json.Marshal(preferences) if err != nil { a.errorResponse(w, r, err) return } jsonBytesResponse(w, http.StatusOK, data) auditRec.Success() } ================================================ FILE: server/app/app.go ================================================ package app import ( "io" "sync" "time" "github.com/mattermost/focalboard/server/auth" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/metrics" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/focalboard/server/services/permissions" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/services/webhook" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/ws" mm_model "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" ) const ( blockChangeNotifierQueueSize = 1000 blockChangeNotifierPoolSize = 10 blockChangeNotifierShutdownTimeout = time.Second * 10 ) type servicesAPI interface { GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error) } type ReadCloseSeeker = filestore.ReadCloseSeeker type fileBackend interface { Reader(path string) (ReadCloseSeeker, error) FileExists(path string) (bool, error) CopyFile(oldPath, newPath string) error MoveFile(oldPath, newPath string) error WriteFile(fr io.Reader, path string) (int64, error) RemoveFile(path string) error } type Services struct { Auth *auth.Auth Store store.Store FilesBackend fileBackend Webhook *webhook.Client Metrics *metrics.Metrics Notifications *notify.Service Logger mlog.LoggerIFace Permissions permissions.PermissionsService SkipTemplateInit bool ServicesAPI servicesAPI } type App struct { config *config.Configuration store store.Store auth *auth.Auth wsAdapter ws.Adapter filesBackend fileBackend webhook *webhook.Client metrics *metrics.Metrics notifications *notify.Service logger mlog.LoggerIFace permissions permissions.PermissionsService blockChangeNotifier *utils.CallbackQueue servicesAPI servicesAPI cardLimitMux sync.RWMutex cardLimit int } func (a *App) SetConfig(config *config.Configuration) { a.config = config } func (a *App) GetConfig() *config.Configuration { return a.config } func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App { app := &App{ config: config, store: services.Store, auth: services.Auth, wsAdapter: wsAdapter, filesBackend: services.FilesBackend, webhook: services.Webhook, metrics: services.Metrics, notifications: services.Notifications, logger: services.Logger, permissions: services.Permissions, blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger), servicesAPI: services.ServicesAPI, } app.initialize(services.SkipTemplateInit) return app } func (a *App) CardLimit() int { a.cardLimitMux.RLock() defer a.cardLimitMux.RUnlock() return a.cardLimit } func (a *App) SetCardLimit(cardLimit int) { a.cardLimitMux.Lock() defer a.cardLimitMux.Unlock() a.cardLimit = cardLimit } func (a *App) GetLicense() *mm_model.License { return a.store.GetLicense() } ================================================ FILE: server/app/app_test.go ================================================ package app import ( "testing" "github.com/mattermost/focalboard/server/services/config" "github.com/stretchr/testify/require" ) func TestSetConfig(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("Test Update Config", func(t *testing.T) { require.False(t, th.App.config.EnablePublicSharedBoards) newConfiguration := config.Configuration{} newConfiguration.EnablePublicSharedBoards = true th.App.SetConfig(&newConfiguration) require.True(t, th.App.config.EnablePublicSharedBoards) }) } ================================================ FILE: server/app/auth.go ================================================ package app import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/auth" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/pkg/errors" ) const ( DaysPerMonth = 30 DaysPerWeek = 7 HoursPerDay = 24 MinutesPerHour = 60 SecondsPerMinute = 60 ) // GetSession Get a user active session and refresh the session if is needed. func (a *App) GetSession(token string) (*model.Session, error) { return a.auth.GetSession(token) } // IsValidReadToken validates the read token for a block. func (a *App) IsValidReadToken(boardID string, readToken string) (bool, error) { return a.auth.IsValidReadToken(boardID, readToken) } // GetRegisteredUserCount returns the number of registered users. func (a *App) GetRegisteredUserCount() (int, error) { return a.store.GetRegisteredUserCount() } // GetDailyActiveUsers returns the number of daily active users. func (a *App) GetDailyActiveUsers() (int, error) { secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay) return a.store.GetActiveUserCount(secondsAgo) } // GetWeeklyActiveUsers returns the number of weekly active users. func (a *App) GetWeeklyActiveUsers() (int, error) { secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerWeek) return a.store.GetActiveUserCount(secondsAgo) } // GetMonthlyActiveUsers returns the number of monthly active users. func (a *App) GetMonthlyActiveUsers() (int, error) { secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerMonth) return a.store.GetActiveUserCount(secondsAgo) } // GetUser gets an existing active user by id. func (a *App) GetUser(id string) (*model.User, error) { if len(id) < 1 { return nil, errors.New("no user ID") } user, err := a.store.GetUserByID(id) if err != nil { return nil, errors.Wrap(err, "unable to find user") } return user, nil } func (a *App) GetUsersList(userIDs []string) ([]*model.User, error) { if len(userIDs) == 0 { return nil, errors.New("No User IDs") } users, err := a.store.GetUsersList(userIDs, a.config.ShowEmailAddress, a.config.ShowFullName) if err != nil { return nil, errors.Wrap(err, "unable to find users") } return users, nil } // Login create a new user session if the authentication data is valid. func (a *App) Login(username, email, password, mfaToken string) (string, error) { var user *model.User if username != "" { var err error user, err = a.store.GetUserByUsername(username) if err != nil && !model.IsErrNotFound(err) { a.metrics.IncrementLoginFailCount(1) return "", errors.Wrap(err, "invalid username or password") } } if user == nil && email != "" { var err error user, err = a.store.GetUserByEmail(email) if err != nil && model.IsErrNotFound(err) { a.metrics.IncrementLoginFailCount(1) return "", errors.Wrap(err, "invalid username or password") } } if user == nil { a.metrics.IncrementLoginFailCount(1) return "", errors.New("invalid username or password") } if !auth.ComparePassword(user.Password, password) { a.metrics.IncrementLoginFailCount(1) a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID)) return "", errors.New("invalid username or password") } authService := user.AuthService if authService == "" { authService = "native" } session := model.Session{ ID: utils.NewID(utils.IDTypeSession), Token: utils.NewID(utils.IDTypeToken), UserID: user.ID, AuthService: authService, Props: map[string]interface{}{}, } err := a.store.CreateSession(&session) if err != nil { return "", errors.Wrap(err, "unable to create session") } a.metrics.IncrementLoginCount(1) // TODO: MFA verification return session.Token, nil } // Logout invalidates the user session. func (a *App) Logout(sessionID string) error { err := a.store.DeleteSession(sessionID) if err != nil { return errors.Wrap(err, "unable to delete the session") } a.metrics.IncrementLogoutCount(1) return nil } // RegisterUser creates a new user if the provided data is valid. func (a *App) RegisterUser(username, email, password string) error { var user *model.User if username != "" { var err error user, err = a.store.GetUserByUsername(username) if err != nil && !model.IsErrNotFound(err) { return err } if user != nil { return errors.New("The username already exists") } } if user == nil && email != "" { var err error user, err = a.store.GetUserByEmail(email) if err != nil && !model.IsErrNotFound(err) { return err } if user != nil { return errors.New("The email already exists") } } // TODO: Move this into the config passwordSettings := auth.PasswordSettings{ MinimumLength: 6, } err := auth.IsPasswordValid(password, passwordSettings) if err != nil { return errors.Wrap(err, "Invalid password") } _, err = a.store.CreateUser(&model.User{ ID: utils.NewID(utils.IDTypeUser), Username: username, Email: email, Password: auth.HashPassword(password), MfaSecret: "", AuthService: a.config.AuthMode, AuthData: "", }) if err != nil { return errors.Wrap(err, "Unable to create the new user") } return nil } func (a *App) UpdateUserPassword(username, password string) error { err := a.store.UpdateUserPassword(username, auth.HashPassword(password)) if err != nil { return err } return nil } func (a *App) ChangePassword(userID, oldPassword, newPassword string) error { var user *model.User if userID != "" { var err error user, err = a.store.GetUserByID(userID) if err != nil { return errors.Wrap(err, "invalid username or password") } } if user == nil { return errors.New("invalid username or password") } if !auth.ComparePassword(user.Password, oldPassword) { a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID)) return errors.New("invalid username or password") } err := a.store.UpdateUserPasswordByID(userID, auth.HashPassword(newPassword)) if err != nil { return errors.Wrap(err, "unable to update password") } return nil } ================================================ FILE: server/app/auth_test.go ================================================ package app import ( "testing" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/auth" "github.com/mattermost/focalboard/server/utils" "github.com/pkg/errors" "github.com/stretchr/testify/require" ) var mockUser = &model.User{ ID: utils.NewID(utils.IDTypeUser), Username: "testUsername", Email: "testEmail", Password: auth.HashPassword("testPassword"), } func TestLogin(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() testcases := []struct { title string userName string email string password string mfa string isError bool }{ {"fail, missing login information", "", "", "", "", true}, {"fail, invalid username", "badUsername", "", "", "", true}, {"fail, invalid email", "", "badEmail", "", "", true}, {"fail, invalid password", "testUsername", "", "badPassword", "", true}, {"success, using username", "testUsername", "", "testPassword", "", false}, {"success, using email", "", "testEmail", "testPassword", "", false}, } th.Store.EXPECT().GetUserByUsername("badUsername").Return(nil, errors.New("Bad Username")) th.Store.EXPECT().GetUserByEmail("badEmail").Return(nil, errors.New("Bad Email")) th.Store.EXPECT().GetUserByUsername("testUsername").Return(mockUser, nil).Times(2) th.Store.EXPECT().GetUserByEmail("testEmail").Return(mockUser, nil) th.Store.EXPECT().CreateSession(gomock.Any()).Return(nil).Times(2) for _, test := range testcases { t.Run(test.title, func(t *testing.T) { token, err := th.App.Login(test.userName, test.email, test.password, test.mfa) if test.isError { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, token) } }) } } func TestGetUser(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() testcases := []struct { title string id string isError bool }{ {"fail, missing id", "", true}, {"fail, invalid id", "badID", true}, {"success", "goodID", false}, } th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("Bad Id")) th.Store.EXPECT().GetUserByID("goodID").Return(mockUser, nil) for _, test := range testcases { t.Run(test.title, func(t *testing.T) { token, err := th.App.GetUser(test.id) if test.isError { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, token) } }) } } func TestRegisterUser(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() testcases := []struct { title string userName string email string password string isError bool }{ {"fail, missing login information", "", "", "", true}, {"fail, username exists", "existingUsername", "", "", true}, {"fail, email exists", "", "existingEmail", "", true}, {"fail, invalid password", "newUsername", "", "test", true}, {"success, using email", "", "newEmail", "testPassword", false}, } th.Store.EXPECT().GetUserByUsername("existingUsername").Return(mockUser, nil) th.Store.EXPECT().GetUserByUsername("newUsername").Return(mockUser, errors.New("user not found")) th.Store.EXPECT().GetUserByEmail("existingEmail").Return(mockUser, nil) th.Store.EXPECT().GetUserByEmail("newEmail").Return(nil, model.NewErrNotFound("user")) th.Store.EXPECT().CreateUser(gomock.Any()).Return(nil, nil) for _, test := range testcases { t.Run(test.title, func(t *testing.T) { err := th.App.RegisterUser(test.userName, test.email, test.password) if test.isError { require.Error(t, err) } else { require.NoError(t, err) } }) } } func TestUpdateUserPassword(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() testcases := []struct { title string userName string password string isError bool }{ {"fail, missing login information", "", "", true}, {"fail, invalid username", "badUsername", "", true}, {"success, username", "testUsername", "testPassword", false}, } th.Store.EXPECT().UpdateUserPassword("", gomock.Any()).Return(errors.New("user not found")) th.Store.EXPECT().UpdateUserPassword("badUsername", gomock.Any()).Return(errors.New("user not found")) th.Store.EXPECT().UpdateUserPassword("testUsername", gomock.Any()).Return(nil) for _, test := range testcases { t.Run(test.title, func(t *testing.T) { err := th.App.UpdateUserPassword(test.userName, test.password) if test.isError { require.Error(t, err) } else { require.NoError(t, err) } }) } } func TestChangePassword(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() testcases := []struct { title string userName string oldPassword string password string isError bool }{ {"fail, missing login information", "", "", "", true}, {"fail, invalid userId", "badID", "", "", true}, {"fail, invalid password", mockUser.ID, "wrongPassword", "newPassword", true}, {"success, using username", mockUser.ID, "testPassword", "newPassword", false}, } th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("userID not found")) th.Store.EXPECT().GetUserByID(mockUser.ID).Return(mockUser, nil).Times(2) th.Store.EXPECT().UpdateUserPasswordByID(mockUser.ID, gomock.Any()).Return(nil) for _, test := range testcases { t.Run(test.title, func(t *testing.T) { err := th.App.ChangePassword(test.userName, test.oldPassword, test.password) if test.isError { require.Error(t, err) } else { require.NoError(t, err) } }) } } ================================================ FILE: server/app/blocks.go ================================================ package app import ( "errors" "fmt" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards") func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]*model.Block, error) { if boardID == "" { return []*model.Block{}, nil } if blockType != "" && parentID != "" { return a.store.GetBlocksWithParentAndType(boardID, parentID, blockType) } if blockType != "" { return a.store.GetBlocksWithType(boardID, blockType) } return a.store.GetBlocksWithParent(boardID, parentID) } func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTemplate bool) ([]*model.Block, error) { board, err := a.GetBoard(boardID) if err != nil { return nil, err } if board == nil { return nil, fmt.Errorf("cannot fetch board %s for DuplicateBlock: %w", boardID, err) } blocks, err := a.store.DuplicateBlock(boardID, blockID, userID, asTemplate) if err != nil { return nil, err } err = a.CopyAndUpdateCardFiles(boardID, userID, blocks, asTemplate) if err != nil { return nil, err } a.blockChangeNotifier.Enqueue(func() error { for _, block := range blocks { a.wsAdapter.BroadcastBlockChange(board.TeamID, block) } return nil }) return blocks, err } func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) (*model.Block, error) { return a.PatchBlockAndNotify(blockID, blockPatch, modifiedByID, false) } func (a *App) PatchBlockAndNotify(blockID string, blockPatch *model.BlockPatch, modifiedByID string, disableNotify bool) (*model.Block, error) { oldBlock, err := a.store.GetBlock(blockID) if err != nil { return nil, err } board, err := a.store.GetBoard(oldBlock.BoardID) if err != nil { return nil, err } err = a.store.PatchBlock(blockID, blockPatch, modifiedByID) if err != nil { return nil, err } a.metrics.IncrementBlocksPatched(1) block, err := a.store.GetBlock(blockID) if err != nil { return nil, err } a.blockChangeNotifier.Enqueue(func() error { // broadcast on websocket a.wsAdapter.BroadcastBlockChange(board.TeamID, block) // broadcast on webhooks a.webhook.NotifyUpdate(block) // send notifications if !disableNotify { a.notifyBlockChanged(notify.Update, block, oldBlock, modifiedByID) } return nil }) return block, nil } func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error { return a.PatchBlocksAndNotify(teamID, blockPatches, modifiedByID, false) } func (a *App) PatchBlocksAndNotify(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string, disableNotify bool) error { oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs) if err != nil { return err } if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil { return err } a.blockChangeNotifier.Enqueue(func() error { a.metrics.IncrementBlocksPatched(len(oldBlocks)) for i, blockID := range blockPatches.BlockIDs { newBlock, err := a.store.GetBlock(blockID) if err != nil { return err } a.wsAdapter.BroadcastBlockChange(teamID, newBlock) a.webhook.NotifyUpdate(newBlock) if !disableNotify { a.notifyBlockChanged(notify.Update, newBlock, oldBlocks[i], modifiedByID) } } return nil }) return nil } func (a *App) InsertBlock(block *model.Block, modifiedByID string) error { return a.InsertBlockAndNotify(block, modifiedByID, false) } func (a *App) InsertBlockAndNotify(block *model.Block, modifiedByID string, disableNotify bool) error { board, bErr := a.store.GetBoard(block.BoardID) if bErr != nil { return bErr } err := a.store.InsertBlock(block, modifiedByID) if err == nil { a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastBlockChange(board.TeamID, block) a.metrics.IncrementBlocksInserted(1) a.webhook.NotifyUpdate(block) if !disableNotify { a.notifyBlockChanged(notify.Add, block, nil, modifiedByID) } return nil }) } return err } func (a *App) InsertBlocks(blocks []*model.Block, modifiedByID string) ([]*model.Block, error) { return a.InsertBlocksAndNotify(blocks, modifiedByID, false) } func (a *App) InsertBlocksAndNotify(blocks []*model.Block, modifiedByID string, disableNotify bool) ([]*model.Block, error) { if len(blocks) == 0 { return []*model.Block{}, nil } // all blocks must belong to the same board boardID := blocks[0].BoardID for _, block := range blocks { if block.BoardID != boardID { return nil, ErrBlocksFromMultipleBoards } } board, err := a.store.GetBoard(boardID) if err != nil { return nil, err } needsNotify := make([]*model.Block, 0, len(blocks)) for i := range blocks { err := a.store.InsertBlock(blocks[i], modifiedByID) if err != nil { return nil, err } needsNotify = append(needsNotify, blocks[i]) a.wsAdapter.BroadcastBlockChange(board.TeamID, blocks[i]) a.metrics.IncrementBlocksInserted(1) } a.blockChangeNotifier.Enqueue(func() error { for _, b := range needsNotify { block := b a.webhook.NotifyUpdate(block) if !disableNotify { a.notifyBlockChanged(notify.Add, block, nil, modifiedByID) } } return nil }) return blocks, nil } func (a *App) GetBlockByID(blockID string) (*model.Block, error) { return a.store.GetBlock(blockID) } func (a *App) DeleteBlock(blockID string, modifiedBy string) error { return a.DeleteBlockAndNotify(blockID, modifiedBy, false) } func (a *App) DeleteBlockAndNotify(blockID string, modifiedBy string, disableNotify bool) error { block, err := a.store.GetBlock(blockID) if err != nil { return err } board, err := a.store.GetBoard(block.BoardID) if err != nil { return err } if block == nil { // deleting non-existing block not considered an error return nil } err = a.store.DeleteBlock(blockID, modifiedBy) if err != nil { return err } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID) a.metrics.IncrementBlocksDeleted(1) if !disableNotify { a.notifyBlockChanged(notify.Delete, block, block, modifiedBy) } return nil }) return nil } func (a *App) GetLastBlockHistoryEntry(blockID string) (*model.Block, error) { blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true}) if err != nil { return nil, err } if len(blocks) == 0 { return nil, nil } return blocks[0], nil } func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, error) { blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true}) if err != nil { return nil, err } if len(blocks) == 0 { // undeleting non-existing block not considered an error return nil, nil } err = a.store.UndeleteBlock(blockID, modifiedBy) if err != nil { return nil, err } block, err := a.store.GetBlock(blockID) if model.IsErrNotFound(err) { a.logger.Error("Error loading the block after a successful undelete, not propagating through websockets or notifications", mlog.String("blockID", blockID)) return nil, err } if err != nil { return nil, err } board, err := a.store.GetBoard(block.BoardID) if err != nil { return nil, err } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastBlockChange(board.TeamID, block) a.metrics.IncrementBlocksInserted(1) a.webhook.NotifyUpdate(block) a.notifyBlockChanged(notify.Add, block, nil, modifiedBy) return nil }) return block, nil } func (a *App) GetBlockCountsByType() (map[string]int64, error) { return a.store.GetBlockCountsByType() } func (a *App) GetBlocksForBoard(boardID string) ([]*model.Block, error) { return a.store.GetBlocksForBoard(boardID) } func (a *App) notifyBlockChanged(action notify.Action, block *model.Block, oldBlock *model.Block, modifiedByID string) { // don't notify if notifications service disabled, or block change is generated via system user. if a.notifications == nil || modifiedByID == model.SystemUserID { return } // find card and board for the changed block. board, card, err := a.getBoardAndCard(block) if err != nil { a.logger.Error("Error notifying for block change; cannot determine board or card", mlog.Err(err)) return } boardMember, _ := a.GetMemberForBoard(board.ID, modifiedByID) if boardMember == nil { // create temporary guest board member boardMember = &model.BoardMember{ BoardID: board.ID, UserID: modifiedByID, } } evt := notify.BlockChangeEvent{ Action: action, TeamID: board.TeamID, Board: board, Card: card, BlockChanged: block, BlockOld: oldBlock, ModifiedBy: boardMember, } a.notifications.BlockChanged(evt) } const ( maxSearchDepth = 50 ) // getBoardAndCard returns the first parent of type `card` its board for the specified block. // `board` and/or `card` may return nil without error if the block does not belong to a board or card. func (a *App) getBoardAndCard(block *model.Block) (board *model.Board, card *model.Block, err error) { board, err = a.store.GetBoard(block.BoardID) if err != nil { return board, card, err } var count int // don't let invalid blocks hierarchy cause infinite loop. iter := block for { count++ if card == nil && iter.Type == model.TypeCard { card = iter } if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth { break } iter, err = a.store.GetBlock(iter.ParentID) if model.IsErrNotFound(err) { return board, card, nil } if err != nil { return board, card, err } } return board, card, nil } ================================================ FILE: server/app/blocks_test.go ================================================ package app import ( "database/sql" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/focalboard/server/model" ) type blockError struct { msg string } func (be blockError) Error() string { return be.msg } func TestInsertBlock(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("success scenario", func(t *testing.T) { boardID := testBoardID block := &model.Block{BoardID: boardID} board := &model.Board{ID: boardID} th.Store.EXPECT().GetBoard(boardID).Return(board, nil) th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil) th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) err := th.App.InsertBlock(block, "user-id-1") require.NoError(t, err) }) t.Run("error scenario", func(t *testing.T) { boardID := testBoardID block := &model.Block{BoardID: boardID} board := &model.Board{ID: boardID} th.Store.EXPECT().GetBoard(boardID).Return(board, nil) th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"}) err := th.App.InsertBlock(block, "user-id-1") require.Error(t, err, "error") }) } func TestPatchBlocks(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("patchBlocks success scenario", func(t *testing.T) { blockPatches := model.BlockPatchBatch{ BlockIDs: []string{"block1"}, BlockPatches: []model.BlockPatch{ {Title: mmModel.NewString("new title")}, }, } block1 := &model.Block{ID: "block1"} th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil) th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil) th.Store.EXPECT().GetBlock("block1").Return(block1, nil) // this call comes from the WS server notification th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1) err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1") require.NoError(t, err) }) t.Run("patchBlocks error scenario", func(t *testing.T) { blockPatches := model.BlockPatchBatch{BlockIDs: []string{}} th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows) err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1") require.ErrorIs(t, err, sql.ErrNoRows) }) t.Run("cloud limit error scenario", func(t *testing.T) { t.Skipf("The Cloud Limits feature has been disabled") th.App.SetCardLimit(5) fakeLicense := &mmModel.License{ Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, } blockPatches := model.BlockPatchBatch{ BlockIDs: []string{"block1"}, BlockPatches: []model.BlockPatch{ {Title: mmModel.NewString("new title")}, }, } block1 := &model.Block{ ID: "block1", Type: model.TypeCard, ParentID: "board-id", BoardID: "board-id", UpdateAt: 100, } board1 := &model.Board{ ID: "board-id", Type: model.BoardTypeOpen, } th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil) th.Store.EXPECT().GetBoard("board-id").Return(board1, nil) th.Store.EXPECT().GetLicense().Return(fakeLicense) th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil) err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1") require.ErrorIs(t, err, model.ErrPatchUpdatesLimitedCards) }) } func TestDeleteBlock(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("success scenario", func(t *testing.T) { boardID := testBoardID board := &model.Board{ID: boardID} block := &model.Block{ ID: "block-id", BoardID: board.ID, } th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil) th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil) th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil) th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) err := th.App.DeleteBlock("block-id", "user-id-1") require.NoError(t, err) }) t.Run("error scenario", func(t *testing.T) { boardID := testBoardID board := &model.Board{ID: boardID} block := &model.Block{ ID: "block-id", BoardID: board.ID, } th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil) th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"}) th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil) err := th.App.DeleteBlock("block-id", "user-id-1") require.Error(t, err, "error") }) } func TestUndeleteBlock(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("success scenario", func(t *testing.T) { boardID := testBoardID board := &model.Board{ID: boardID} block := &model.Block{ ID: "block-id", BoardID: board.ID, } th.Store.EXPECT().GetBlockHistory( gomock.Eq("block-id"), gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}), ).Return([]*model.Block{block}, nil) th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil) th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil) th.Store.EXPECT().GetBoard(boardID).Return(board, nil) th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) _, err := th.App.UndeleteBlock("block-id", "user-id-1") require.NoError(t, err) }) t.Run("error scenario", func(t *testing.T) { block := &model.Block{ ID: "block-id", } th.Store.EXPECT().GetBlockHistory( gomock.Eq("block-id"), gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}), ).Return([]*model.Block{block}, nil) th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"}) _, err := th.App.UndeleteBlock("block-id", "user-id-1") require.Error(t, err, "error") }) } func TestInsertBlocks(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("success scenario", func(t *testing.T) { boardID := testBoardID block := &model.Block{BoardID: boardID} board := &model.Board{ID: boardID} th.Store.EXPECT().GetBoard(boardID).Return(board, nil) th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil) th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) _, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1") require.NoError(t, err) }) t.Run("error scenario", func(t *testing.T) { boardID := testBoardID block := &model.Block{BoardID: boardID} board := &model.Board{ID: boardID} th.Store.EXPECT().GetBoard(boardID).Return(board, nil) th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"}) _, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1") require.Error(t, err, "error") }) t.Run("create view within limits", func(t *testing.T) { t.Skipf("The Cloud Limits feature has been disabled") boardID := testBoardID block := &model.Block{ Type: model.TypeView, ParentID: "parent_id", BoardID: boardID, } board := &model.Board{ID: boardID} th.Store.EXPECT().GetBoard(boardID).Return(board, nil) th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil) th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) // setting up mocks for limits fakeLicense := &mmModel.License{ Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, } th.Store.EXPECT().GetLicense().Return(fakeLicense) th.Store.EXPECT().GetUsedCardsCount().Return(1, nil) th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil) th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil) _, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1") require.NoError(t, err) }) t.Run("create view exceeding limits", func(t *testing.T) { t.Skipf("The Cloud Limits feature has been disabled") boardID := testBoardID block := &model.Block{ Type: model.TypeView, ParentID: "parent_id", BoardID: boardID, } board := &model.Board{ID: boardID} th.Store.EXPECT().GetBoard(boardID).Return(board, nil) // setting up mocks for limits fakeLicense := &mmModel.License{ Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, } th.Store.EXPECT().GetLicense().Return(fakeLicense) th.Store.EXPECT().GetUsedCardsCount().Return(1, nil) th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil) th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}, {}}, nil) _, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1") require.Error(t, err) }) t.Run("creating multiple views, reaching limit in the process", func(t *testing.T) { t.Skipf("Will be fixed soon") boardID := testBoardID view1 := &model.Block{ Type: model.TypeView, ParentID: "parent_id", BoardID: boardID, } view2 := &model.Block{ Type: model.TypeView, ParentID: "parent_id", BoardID: boardID, } board := &model.Board{ID: boardID} th.Store.EXPECT().GetBoard(boardID).Return(board, nil) th.Store.EXPECT().InsertBlock(view1, "user-id-1").Return(nil).Times(2) th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2) // setting up mocks for limits fakeLicense := &mmModel.License{ Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, } th.Store.EXPECT().GetLicense().Return(fakeLicense).Times(2) th.Store.EXPECT().GetUsedCardsCount().Return(1, nil).Times(2) th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil).Times(2) th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil).Times(2) _, err := th.App.InsertBlocks([]*model.Block{view1, view2}, "user-id-1") require.Error(t, err) }) } ================================================ FILE: server/app/boards.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app import ( "errors" "fmt" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var ( ErrNewBoardCannotHaveID = errors.New("new board cannot have an ID") ) const linkBoardMessage = "@%s linked the board [%s](%s) with this channel" const unlinkBoardMessage = "@%s unlinked the board [%s](%s) with this channel" var errNoDefaultCategoryFound = errors.New("no default category found for user") func (a *App) GetBoard(boardID string) (*model.Board, error) { board, err := a.store.GetBoard(boardID) if err != nil { return nil, err } return board, nil } func (a *App) GetBoardCount() (int64, error) { return a.store.GetBoardCount() } func (a *App) GetBoardMetadata(boardID string) (*model.Board, *model.BoardMetadata, error) { license := a.store.GetLicense() if license == nil || !(*license.Features.Compliance) { return nil, nil, model.ErrInsufficientLicense } board, err := a.GetBoard(boardID) if model.IsErrNotFound(err) { // Board may have been deleted, retrieve most recent history instead board, err = a.getBoardHistory(boardID, true) if err != nil { return nil, nil, err } } if err != nil { return nil, nil, err } earliestTime, _, err := a.getBoardDescendantModifiedInfo(boardID, false) if err != nil { return nil, nil, err } latestTime, lastModifiedBy, err := a.getBoardDescendantModifiedInfo(boardID, true) if err != nil { return nil, nil, err } boardMetadata := model.BoardMetadata{ BoardID: boardID, DescendantFirstUpdateAt: earliestTime, DescendantLastUpdateAt: latestTime, CreatedBy: board.CreatedBy, LastModifiedBy: lastModifiedBy, } return board, &boardMetadata, nil } // getBoardForBlock returns the board that owns the specified block. func (a *App) getBoardForBlock(blockID string) (*model.Board, error) { block, err := a.GetBlockByID(blockID) if err != nil { return nil, fmt.Errorf("cannot get block %s: %w", blockID, err) } board, err := a.GetBoard(block.BoardID) if err != nil { return nil, fmt.Errorf("cannot get board %s: %w", block.BoardID, err) } return board, nil } func (a *App) getBoardHistory(boardID string, latest bool) (*model.Board, error) { opts := model.QueryBoardHistoryOptions{ Limit: 1, Descending: latest, } boards, err := a.store.GetBoardHistory(boardID, opts) if err != nil { return nil, fmt.Errorf("could not get history for board: %w", err) } if len(boards) == 0 { return nil, nil } return boards[0], nil } func (a *App) getBoardDescendantModifiedInfo(boardID string, latest bool) (int64, string, error) { board, err := a.getBoardHistory(boardID, latest) if err != nil { return 0, "", err } if board == nil { return 0, "", fmt.Errorf("history not found for board: %w", err) } var timestamp int64 modifiedBy := board.ModifiedBy if latest { timestamp = board.UpdateAt } else { timestamp = board.CreateAt } // use block_history to fetch blocks in case they were deleted and no longer exist in blocks table. opts := model.QueryBlockHistoryOptions{ Limit: 1, Descending: latest, } blocks, err := a.store.GetBlockHistoryDescendants(boardID, opts) if err != nil { return 0, "", fmt.Errorf("could not get blocks history descendants for board: %w", err) } if len(blocks) > 0 { // Compare the board history info with the descendant block info, if it exists block := blocks[0] if latest && block.UpdateAt > timestamp { timestamp = block.UpdateAt modifiedBy = block.ModifiedBy } else if !latest && block.CreateAt < timestamp { timestamp = block.CreateAt modifiedBy = block.ModifiedBy } } return timestamp, modifiedBy, nil } func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, userID, teamID string, asTemplate bool) error { // find source board's category ID for the user userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID) if err != nil { return err } var destinationCategoryID string for _, categoryBoard := range userCategoryBoards { for _, metadata := range categoryBoard.BoardMetadata { if metadata.BoardID == sourceBoardID { // category found! destinationCategoryID = categoryBoard.ID break } } } if destinationCategoryID == "" { // if source board is not mapped to a category for this user, // then move new board to default category if !asTemplate { return a.addBoardsToDefaultCategory(userID, teamID, []*model.Board{{ID: destinationBoardID}}) } else { return nil } } // now that we have source board's category, // we send destination board to the same category return a.AddUpdateUserCategoryBoard(teamID, userID, destinationCategoryID, []string{destinationBoardID}) } func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) { bab, members, err := a.store.DuplicateBoard(boardID, userID, toTeam, asTemplate) if err != nil { return nil, nil, err } // copy any file attachments from the duplicated blocks. err = a.CopyAndUpdateCardFiles(boardID, userID, bab.Blocks, asTemplate) if err != nil { dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab) if dErr := a.store.DeleteBoardsAndBlocks(dbab, userID); dErr != nil { a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(dErr)) } return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err) } if !asTemplate { for _, board := range bab.Boards { if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil { return nil, nil, categoryErr } } } a.blockChangeNotifier.Enqueue(func() error { teamID := "" for _, board := range bab.Boards { teamID = board.TeamID a.wsAdapter.BroadcastBoardChange(teamID, board) } for _, block := range bab.Blocks { blk := block a.wsAdapter.BroadcastBlockChange(teamID, blk) a.notifyBlockChanged(notify.Add, blk, nil, userID) } for _, member := range members { a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member) } return nil }) return bab, members, err } func (a *App) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) { return a.store.GetBoardsForUserAndTeam(userID, teamID, includePublicBoards) } func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) { return a.store.GetTemplateBoards(teamID, userID) } func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) { if board.ID != "" { return nil, ErrNewBoardCannotHaveID } board.ID = utils.NewID(utils.IDTypeBoard) var newBoard *model.Board var member *model.BoardMember var err error if addMember { newBoard, member, err = a.store.InsertBoardWithAdmin(board, userID) } else { newBoard, err = a.store.InsertBoard(board, userID) } if err != nil { return nil, err } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastBoardChange(newBoard.TeamID, newBoard) if newBoard.ChannelID != "" { members, err := a.GetMembersForBoard(board.ID) if err != nil { a.logger.Error("Unable to get the board members", mlog.Err(err)) } for _, member := range members { a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, member.BoardID, member) } } else if addMember { a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, newBoard.ID, member) } return nil }) if !board.IsTemplate { if err := a.addBoardsToDefaultCategory(userID, newBoard.TeamID, []*model.Board{newBoard}); err != nil { return nil, err } } return newBoard, nil } func (a *App) addBoardsToDefaultCategory(userID, teamID string, boards []*model.Board) error { userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID) if err != nil { return err } defaultCategoryID := "" for _, categoryBoard := range userCategoryBoards { if categoryBoard.Name == defaultCategoryBoards { defaultCategoryID = categoryBoard.ID break } } if defaultCategoryID == "" { return fmt.Errorf("%w userID: %s", errNoDefaultCategoryFound, userID) } boardIDs := make([]string, len(boards)) for i := range boards { boardIDs[i] = boards[i].ID } if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil { return err } return nil } func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*model.Board, error) { var oldChannelID string var isTemplate bool var oldMembers []*model.BoardMember if patch.Type != nil || patch.ChannelID != nil { testChannel := "" if patch.ChannelID != nil && *patch.ChannelID == "" { var err error oldMembers, err = a.GetMembersForBoard(boardID) if err != nil { a.logger.Error("Unable to get the board members", mlog.Err(err)) } } else if patch.ChannelID != nil && *patch.ChannelID != "" { testChannel = *patch.ChannelID } board, err := a.store.GetBoard(boardID) if model.IsErrNotFound(err) { return nil, model.NewErrNotFound("board ID=" + boardID) } if err != nil { return nil, err } oldChannelID = board.ChannelID isTemplate = board.IsTemplate if testChannel == "" { testChannel = oldChannelID } if testChannel != "" { if !a.permissions.HasPermissionToChannel(userID, testChannel, model.PermissionCreatePost) { return nil, model.NewErrPermission("access denied to channel") } } } updatedBoard, err := a.store.PatchBoard(boardID, patch, userID) if err != nil { return nil, err } // Post message to channel if linked/unlinked if patch.ChannelID != nil { var username string user, err := a.store.GetUserByID(userID) if err != nil { a.logger.Error("Unable to get the board updater", mlog.Err(err)) username = "unknown" } else { username = user.Username } boardLink := utils.MakeBoardLink(a.config.ServerRoot, updatedBoard.TeamID, updatedBoard.ID) title := updatedBoard.Title if title == "" { title = "Untitled board" // todo: localize this when server has i18n } if *patch.ChannelID != "" { a.postChannelMessage(fmt.Sprintf(linkBoardMessage, username, title, boardLink), updatedBoard.ChannelID) } else if *patch.ChannelID == "" { a.postChannelMessage(fmt.Sprintf(unlinkBoardMessage, username, title, boardLink), oldChannelID) } } // Broadcast Messages to affected users a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastBoardChange(updatedBoard.TeamID, updatedBoard) if patch.ChannelID != nil { if *patch.ChannelID != "" { members, err := a.GetMembersForBoard(updatedBoard.ID) if err != nil { a.logger.Error("Unable to get the board members", mlog.Err(err)) } for _, member := range members { if member.Synthetic { a.wsAdapter.BroadcastMemberChange(updatedBoard.TeamID, member.BoardID, member) } } } else { for _, oldMember := range oldMembers { if oldMember.Synthetic { a.wsAdapter.BroadcastMemberDelete(updatedBoard.TeamID, boardID, oldMember.UserID) } } } } if patch.Type != nil && isTemplate { members, err := a.GetMembersForBoard(updatedBoard.ID) if err != nil { a.logger.Error("Unable to get the board members", mlog.Err(err)) } a.broadcastTeamUsers(updatedBoard.TeamID, updatedBoard.ID, *patch.Type, members) } return nil }) return updatedBoard, nil } func (a *App) postChannelMessage(message, channelID string) { err := a.store.PostMessage(message, "", channelID) if err != nil { a.logger.Error("Unable to post the link message to channel", mlog.Err(err)) } } // broadcastTeamUsers notifies the members of a team when a template changes its type // from public to private or viceversa. func (a *App) broadcastTeamUsers(teamID, boardID string, boardType model.BoardType, members []*model.BoardMember) { users, err := a.GetTeamUsers(teamID, "") if err != nil { a.logger.Error("Unable to get the team users", mlog.Err(err)) } for _, user := range users { isMember := false for _, member := range members { if member.UserID == user.ID { isMember = true break } } if !isMember { if boardType == model.BoardTypePrivate { a.wsAdapter.BroadcastMemberDelete(teamID, boardID, user.ID) } else if boardType == model.BoardTypeOpen { a.wsAdapter.BroadcastMemberChange(teamID, boardID, &model.BoardMember{UserID: user.ID, BoardID: boardID, SchemeViewer: true, Synthetic: true}) } } } } func (a *App) DeleteBoard(boardID, userID string) error { board, err := a.store.GetBoard(boardID) if model.IsErrNotFound(err) { return nil } if err != nil { return err } if err := a.store.DeleteBoard(boardID, userID); err != nil { return err } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastBoardDelete(board.TeamID, boardID) return nil }) return nil } func (a *App) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) { members, err := a.store.GetMembersForBoard(boardID) if err != nil { return nil, err } board, err := a.store.GetBoard(boardID) if err != nil && !model.IsErrNotFound(err) { return nil, err } if board != nil { for i, m := range members { if !m.SchemeAdmin { if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) { members[i].SchemeAdmin = true } } } } return members, nil } func (a *App) GetMembersForUser(userID string) ([]*model.BoardMember, error) { members, err := a.store.GetMembersForUser(userID) if err != nil { return nil, err } for i, m := range members { if !m.SchemeAdmin { board, err := a.store.GetBoard(m.BoardID) if err != nil && !model.IsErrNotFound(err) { return nil, err } if board != nil { if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) { // if system/team admin members[i].SchemeAdmin = true } } } } return members, nil } func (a *App) GetMemberForBoard(boardID string, userID string) (*model.BoardMember, error) { return a.store.GetMemberForBoard(boardID, userID) } func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) { board, err := a.store.GetBoard(member.BoardID) if model.IsErrNotFound(err) { return nil, nil } if err != nil { return nil, err } existingMembership, err := a.store.GetMemberForBoard(member.BoardID, member.UserID) if err != nil && !model.IsErrNotFound(err) { return nil, err } if existingMembership != nil && !existingMembership.Synthetic { return existingMembership, nil } newMember, err := a.store.SaveMember(member) if err != nil { return nil, err } if !newMember.SchemeAdmin { if board != nil { if a.permissions.HasPermissionToTeam(newMember.UserID, board.TeamID, model.PermissionManageTeam) { newMember.SchemeAdmin = true } } } if !board.IsTemplate { if err = a.addBoardsToDefaultCategory(member.UserID, board.TeamID, []*model.Board{board}); err != nil { return nil, err } } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member) return nil }) return newMember, nil } func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, error) { board, bErr := a.store.GetBoard(member.BoardID) if model.IsErrNotFound(bErr) { return nil, nil } if bErr != nil { return nil, bErr } oldMember, err := a.store.GetMemberForBoard(member.BoardID, member.UserID) if model.IsErrNotFound(err) { return nil, nil } if err != nil { return nil, err } // if we're updating an admin, we need to check that there is at // least still another admin on the board if oldMember.SchemeAdmin && !member.SchemeAdmin { isLastAdmin, err2 := a.isLastAdmin(member.UserID, member.BoardID) if err2 != nil { return nil, err2 } if isLastAdmin { return nil, model.ErrBoardMemberIsLastAdmin } } newMember, err := a.store.SaveMember(member) if err != nil { return nil, err } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member) return nil }) return newMember, nil } func (a *App) isLastAdmin(userID, boardID string) (bool, error) { members, err := a.store.GetMembersForBoard(boardID) if err != nil { return false, err } for _, m := range members { if m.SchemeAdmin && m.UserID != userID { return false, nil } } return true, nil } func (a *App) DeleteBoardMember(boardID, userID string) error { board, bErr := a.store.GetBoard(boardID) if model.IsErrNotFound(bErr) { return nil } if bErr != nil { return bErr } oldMember, err := a.store.GetMemberForBoard(boardID, userID) if model.IsErrNotFound(err) { return nil } if err != nil { return err } // if we're removing an admin, we need to check that there is at // least still another admin on the board if oldMember.SchemeAdmin { isLastAdmin, err := a.isLastAdmin(userID, boardID) if err != nil { return err } if isLastAdmin { return model.ErrBoardMemberIsLastAdmin } } if err := a.store.DeleteMember(boardID, userID); err != nil { return err } a.blockChangeNotifier.Enqueue(func() error { if syntheticMember, _ := a.GetMemberForBoard(boardID, userID); syntheticMember != nil { a.wsAdapter.BroadcastMemberChange(board.TeamID, boardID, syntheticMember) } else { a.wsAdapter.BroadcastMemberDelete(board.TeamID, boardID, userID) } return nil }) return nil } func (a *App) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) { return a.store.SearchBoardsForUser(term, searchField, userID, includePublicBoards) } func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) { return a.store.SearchBoardsForUserInTeam(teamID, term, userID) } func (a *App) UndeleteBoard(boardID string, modifiedBy string) error { boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}) if err != nil { return err } if len(boards) == 0 { // undeleting non-existing board not considered an error return nil } err = a.store.UndeleteBoard(boardID, modifiedBy) if err != nil { return err } board, err := a.store.GetBoard(boardID) if err != nil { return err } if board == nil { a.logger.Error("Error loading the board after undelete, not propagating through websockets or notifications") return nil } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastBoardChange(board.TeamID, board) return nil }) return nil } ================================================ FILE: server/app/boards_and_blocks.go ================================================ package app import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, addMember bool) (*model.BoardsAndBlocks, error) { var newBab *model.BoardsAndBlocks var members []*model.BoardMember var err error if addMember { newBab, members, err = a.store.CreateBoardsAndBlocksWithAdmin(bab, userID) } else { newBab, err = a.store.CreateBoardsAndBlocks(bab, userID) } if err != nil { return nil, err } // all new boards should belong to the same team teamID := newBab.Boards[0].TeamID // This can be synchronous because this action is not common for _, board := range newBab.Boards { a.wsAdapter.BroadcastBoardChange(teamID, board) } for _, block := range newBab.Blocks { b := block a.wsAdapter.BroadcastBlockChange(teamID, b) a.metrics.IncrementBlocksInserted(1) a.webhook.NotifyUpdate(b) a.notifyBlockChanged(notify.Add, b, nil, userID) } if addMember { for _, member := range members { a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member) } } for _, board := range newBab.Boards { if !board.IsTemplate { if err := a.addBoardsToDefaultCategory(userID, board.TeamID, []*model.Board{board}); err != nil { return nil, err } } } return newBab, nil } func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) { oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs) if err != nil { return nil, err } oldBlocksMap := map[string]*model.Block{} for _, block := range oldBlocks { oldBlocksMap[block.ID] = block } bab, err := a.store.PatchBoardsAndBlocks(pbab, userID) if err != nil { return nil, err } a.blockChangeNotifier.Enqueue(func() error { teamID := bab.Boards[0].TeamID for _, block := range bab.Blocks { oldBlock, ok := oldBlocksMap[block.ID] if !ok { a.logger.Error("Error notifying for block change on patch boards and blocks; cannot get old block", mlog.String("blockID", block.ID)) continue } b := block a.metrics.IncrementBlocksPatched(1) a.wsAdapter.BroadcastBlockChange(teamID, b) a.webhook.NotifyUpdate(b) a.notifyBlockChanged(notify.Update, b, oldBlock, userID) } for _, board := range bab.Boards { a.wsAdapter.BroadcastBoardChange(board.TeamID, board) } return nil }) return bab, nil } func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID string) error { firstBoard, err := a.store.GetBoard(dbab.Boards[0]) if err != nil { return err } // we need the block entity to notify of the block changes, so we // fetch and store the blocks first blocks := []*model.Block{} for _, blockID := range dbab.Blocks { block, err := a.store.GetBlock(blockID) if err != nil { return err } blocks = append(blocks, block) } if err := a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil { return err } a.blockChangeNotifier.Enqueue(func() error { for _, block := range blocks { a.wsAdapter.BroadcastBlockDelete(firstBoard.TeamID, block.ID, block.BoardID) a.metrics.IncrementBlocksDeleted(1) a.notifyBlockChanged(notify.Update, block, block, userID) } for _, boardID := range dbab.Boards { a.wsAdapter.BroadcastBoardDelete(firstBoard.TeamID, boardID) } return nil }) return nil } ================================================ FILE: server/app/boards_test.go ================================================ package app import ( "testing" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) func TestAddMemberToBoard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_1" boardMember := &model.BoardMember{ BoardID: boardID, UserID: userID, SchemeEditor: true, } th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: "board_id_1", TeamID: "team_id_1", }, nil) th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(nil, nil) th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool { p := i.(*model.BoardMember) return p.BoardID == boardID && p.UserID == userID })).Return(&model.BoardMember{ BoardID: boardID, }, nil) // for WS change broadcast th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "default_category_id", Name: "Boards", Type: "system", }, }, }, nil).Times(2) th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil) addedBoardMember, err := th.App.AddMemberToBoard(boardMember) require.NoError(t, err) require.Equal(t, boardID, addedBoardMember.BoardID) }) t.Run("return existing non-synthetic membership if any", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_1" boardMember := &model.BoardMember{ BoardID: boardID, UserID: userID, SchemeEditor: true, } th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ TeamID: "team_id_1", }, nil) th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{ UserID: userID, BoardID: boardID, Synthetic: false, }, nil) addedBoardMember, err := th.App.AddMemberToBoard(boardMember) require.NoError(t, err) require.Equal(t, boardID, addedBoardMember.BoardID) }) t.Run("should convert synthetic membership into natural membership", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_1" boardMember := &model.BoardMember{ BoardID: boardID, UserID: userID, SchemeEditor: true, } th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: "board_id_1", TeamID: "team_id_1", }, nil) th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{ UserID: userID, BoardID: boardID, Synthetic: true, }, nil) th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool { p := i.(*model.BoardMember) return p.BoardID == boardID && p.UserID == userID })).Return(&model.BoardMember{ UserID: userID, BoardID: boardID, Synthetic: false, }, nil) // for WS change broadcast th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "default_category_id", Name: "Boards", Type: "system", }, }, }, nil).Times(2) th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil) th.API.EXPECT().HasPermissionToTeam("user_id_1", "team_id_1", model.PermissionManageTeam).Return(false).Times(1) addedBoardMember, err := th.App.AddMemberToBoard(boardMember) require.NoError(t, err) require.Equal(t, boardID, addedBoardMember.BoardID) }) } func TestPatchBoard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case, title patch", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_1" const teamID = "team_id_1" patchTitle := "Patched Title" patch := &model.BoardPatch{ Title: &patchTitle, } th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, Title: patchTitle, }, nil) // for WS BroadcastBoardChange th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(1) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, patchTitle, patchedBoard.Title) }) t.Run("patch type open, no users", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" patchType := model.BoardTypeOpen patch := &model.BoardPatch{ Type: &patchType, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, }, nil).Times(2) // Type not null will retrieve team members th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil) th.Store.EXPECT().GetUserByID(userID).Return(&model.User{ID: userID, Username: "UserName"}, nil) th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, }, nil) // Should call GetMembersForBoard 2 times // - for WS BroadcastBoardChange // - for AddTeamMembers check th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, boardID, patchedBoard.ID) }) t.Run("patch type private, no users", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" patchType := model.BoardTypePrivate patch := &model.BoardPatch{ Type: &patchType, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, }, nil).Times(2) // Type not null will retrieve team members th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil) th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, }, nil) // Should call GetMembersForBoard 2 times // - for WS BroadcastBoardChange // - for AddTeamMembers check th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, boardID, patchedBoard.ID) }) t.Run("patch type open, single user", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" patchType := model.BoardTypeOpen patch := &model.BoardPatch{ Type: &patchType, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, }, nil).Times(2) // Type not null will retrieve team members th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil) th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, }, nil) // Should call GetMembersForBoard 3 times // for WS BroadcastBoardChange // for AddTeamMembers check // for WS BroadcastMemberChange th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, boardID, patchedBoard.ID) }) t.Run("patch type private, single user", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" patchType := model.BoardTypePrivate patch := &model.BoardPatch{ Type: &patchType, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, }, nil).Times(2) // Type not null will retrieve team members th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil) th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, }, nil) // Should call GetMembersForBoard 3 times // for WS BroadcastBoardChange // for AddTeamMembers check // for WS BroadcastMemberChange th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, boardID, patchedBoard.ID) }) t.Run("patch type open, user with member", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" patchType := model.BoardTypeOpen patch := &model.BoardPatch{ Type: &patchType, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, }, nil).Times(3) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1) // Type not null will retrieve team members th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil) th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, }, nil) // Should call GetMembersForBoard 2 times // for WS BroadcastBoardChange // for AddTeamMembers check // We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, boardID, patchedBoard.ID) }) t.Run("patch type private, user with member", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" patchType := model.BoardTypePrivate patch := &model.BoardPatch{ Type: &patchType, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, ChannelID: "", }, nil).Times(1) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1) // Type not null will retrieve team members th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil) th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, }, nil) // Should call GetMembersForBoard 2 times // for WS BroadcastBoardChange // for AddTeamMembers check // We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, boardID, patchedBoard.ID) }) t.Run("patch type channel, user without post permissions", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" channelID := "myChannel" patchType := model.BoardTypeOpen patch := &model.BoardPatch{ Type: &patchType, ChannelID: &channelID, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, }, nil).Times(1) th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1) _, err := th.App.PatchBoard(patch, boardID, userID) require.Error(t, err) }) t.Run("patch type channel, user with post permissions", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" channelID := "myChannel" patch := &model.BoardPatch{ ChannelID: &channelID, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, }, nil).Times(2) th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1) th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return( &model.Board{ ID: boardID, TeamID: teamID, }, nil) // Should call GetMembersForBoard 2 times // - for WS BroadcastBoardChange // - for AddTeamMembers check th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2) th.Store.EXPECT().PostMessage(utils.Anything, "", "").Times(1) patchedBoard, err := th.App.PatchBoard(patch, boardID, userID) require.NoError(t, err) require.Equal(t, boardID, patchedBoard.ID) }) t.Run("patch type remove channel, user without post permissions", func(t *testing.T) { const boardID = "board_id_1" const userID = "user_id_2" const teamID = "team_id_1" const channelID = "myChannel" clearChannel := "" patchType := model.BoardTypeOpen patch := &model.BoardPatch{ Type: &patchType, ChannelID: &clearChannel, } // Type not nil, will cause board to be reteived // to check isTemplate th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ ID: boardID, TeamID: teamID, IsTemplate: true, ChannelID: channelID, }, nil).Times(2) th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1) // Should call GetMembersForBoard 2 times // for WS BroadcastBoardChange // for AddTeamMembers check // We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(1) _, err := th.App.PatchBoard(patch, boardID, userID) require.Error(t, err) }) } func TestGetBoardCount(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { boardCount := int64(100) th.Store.EXPECT().GetBoardCount().Return(boardCount, nil) count, err := th.App.GetBoardCount() require.NoError(t, err) require.Equal(t, boardCount, count) }) } func TestBoardCategory(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("no boards default category exists", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ID: "category_id_1", Name: "Category 1"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_1"}, {BoardID: "board_id_2"}, }, }, { Category: model.Category{ID: "category_id_2", Name: "Category 2"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_3"}, }, }, { Category: model.Category{ID: "category_id_3", Name: "Category 3"}, BoardMetadata: []model.CategoryBoardMetadata{}, }, }, nil).Times(1) // when this function is called the second time, the default category is created th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ID: "category_id_1", Name: "Category 1"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_1"}, {BoardID: "board_id_2"}, }, }, { Category: model.Category{ID: "category_id_2", Name: "Category 2"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_3"}, }, }, { Category: model.Category{ID: "category_id_3", Name: "Category 3"}, BoardMetadata: []model.CategoryBoardMetadata{}, }, { Category: model.Category{ID: "default_category_id", Type: model.CategoryTypeSystem, Name: "Boards"}, }, }, nil).Times(1) th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "default_category_id", Name: "Boards", }, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil) th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", []string{ "board_id_1", "board_id_2", "board_id_3", }).Return(nil) boards := []*model.Board{ {ID: "board_id_1"}, {ID: "board_id_2"}, {ID: "board_id_3"}, } err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards) assert.NoError(t, err) }) } func TestDuplicateBoard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { board := &model.Board{ ID: "board_id_2", Title: "Duplicated Board", } block := &model.Block{ ID: "block_id_1", Type: "image", } th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false).Return( &model.BoardsAndBlocks{ Boards: []*model.Board{ board, }, Blocks: []*model.Block{ block, }, }, []*model.BoardMember{}, nil, ) th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil) th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "category_id_1", Name: "Boards", Type: "system", }, }, }, nil).Times(3) th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "category_id_1", utils.Anything).Return(nil) // for WS change broadcast th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2) bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false) assert.NoError(t, err) assert.NotNil(t, bab) assert.NotNil(t, members) }) t.Run("duplicating board as template should not set it's category", func(t *testing.T) { board := &model.Board{ ID: "board_id_2", Title: "Duplicated Board", } block := &model.Block{ ID: "block_id_1", Type: "image", } th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true).Return( &model.BoardsAndBlocks{ Boards: []*model.Board{ board, }, Blocks: []*model.Block{ block, }, }, []*model.BoardMember{}, nil, ) th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil) // for WS change broadcast th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2) bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true) assert.NoError(t, err) assert.NotNil(t, bab) assert.NotNil(t, members) }) } func TestGetMembersForBoard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() const boardID = "board_id_1" const userID = "user_id_1" const teamID = "team_id_1" th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{ { BoardID: boardID, UserID: userID, SchemeEditor: true, }, }, nil).Times(3) th.Store.EXPECT().GetBoard(boardID).Return(nil, nil).Times(1) t.Run("-base case", func(t *testing.T) { members, err := th.App.GetMembersForBoard(boardID) assert.NoError(t, err) assert.NotNil(t, members) assert.False(t, members[0].SchemeAdmin) }) board := &model.Board{ ID: boardID, TeamID: teamID, } th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1) t.Run("-team check false ", func(t *testing.T) { members, err := th.App.GetMembersForBoard(boardID) assert.NoError(t, err) assert.NotNil(t, members) assert.False(t, members[0].SchemeAdmin) }) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1) t.Run("-team check true", func(t *testing.T) { members, err := th.App.GetMembersForBoard(boardID) assert.NoError(t, err) assert.NotNil(t, members) assert.True(t, members[0].SchemeAdmin) }) } func TestGetMembersForUser(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() const boardID = "board_id_1" const userID = "user_id_1" const teamID = "team_id_1" th.Store.EXPECT().GetMembersForUser(userID).Return([]*model.BoardMember{ { BoardID: boardID, UserID: userID, SchemeEditor: true, }, }, nil).Times(3) th.Store.EXPECT().GetBoard(boardID).Return(nil, nil) t.Run("-base case", func(t *testing.T) { members, err := th.App.GetMembersForUser(userID) assert.NoError(t, err) assert.NotNil(t, members) assert.False(t, members[0].SchemeAdmin) }) board := &model.Board{ ID: boardID, TeamID: teamID, } th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1) t.Run("-team check false ", func(t *testing.T) { members, err := th.App.GetMembersForUser(userID) assert.NoError(t, err) assert.NotNil(t, members) assert.False(t, members[0].SchemeAdmin) }) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1) t.Run("-team check true", func(t *testing.T) { members, err := th.App.GetMembersForUser(userID) assert.NoError(t, err) assert.NotNil(t, members) assert.True(t, members[0].SchemeAdmin) }) } ================================================ FILE: server/app/cards.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app import ( "fmt" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" ) func (a *App) CreateCard(card *model.Card, boardID string, userID string, disableNotify bool) (*model.Card, error) { // Convert the card struct to a block and insert the block. now := utils.GetMillis() card.ID = utils.NewID(utils.IDTypeCard) card.BoardID = boardID card.CreatedBy = userID card.ModifiedBy = userID card.CreateAt = now card.UpdateAt = now card.DeleteAt = 0 block := model.Card2Block(card) newBlocks, err := a.InsertBlocksAndNotify([]*model.Block{block}, userID, disableNotify) if err != nil { return nil, fmt.Errorf("cannot create card: %w", err) } newCard, err := model.Block2Card(newBlocks[0]) if err != nil { return nil, err } return newCard, nil } func (a *App) GetCardsForBoard(boardID string, page int, perPage int) ([]*model.Card, error) { opts := model.QueryBlocksOptions{ BoardID: boardID, BlockType: model.TypeCard, Page: page, PerPage: perPage, } blocks, err := a.store.GetBlocks(opts) if err != nil { return nil, err } cards := make([]*model.Card, 0, len(blocks)) for _, blk := range blocks { b := blk if card, err := model.Block2Card(b); err != nil { return nil, fmt.Errorf("Block2Card fail: %w", err) } else { cards = append(cards, card) } } return cards, nil } func (a *App) PatchCard(cardPatch *model.CardPatch, cardID string, userID string, disableNotify bool) (*model.Card, error) { blockPatch, err := model.CardPatch2BlockPatch(cardPatch) if err != nil { return nil, err } newBlock, err := a.PatchBlockAndNotify(cardID, blockPatch, userID, disableNotify) if err != nil { return nil, fmt.Errorf("cannot patch card %s: %w", cardID, err) } newCard, err := model.Block2Card(newBlock) if err != nil { return nil, err } return newCard, nil } func (a *App) GetCardByID(cardID string) (*model.Card, error) { cardBlock, err := a.GetBlockByID(cardID) if err != nil { return nil, err } card, err := model.Block2Card(cardBlock) if err != nil { return nil, err } return card, nil } ================================================ FILE: server/app/cards_test.go ================================================ package app import ( "fmt" "reflect" "testing" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateCard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() board := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), } userID := utils.NewID(utils.IDTypeUser) props := makeProps(3) card := &model.Card{ BoardID: board.ID, CreatedBy: userID, ModifiedBy: userID, Title: "test card", ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)}, Properties: props, } block := model.Card2Block(card) t.Run("success scenario", func(t *testing.T) { th.Store.EXPECT().GetBoard(board.ID).Return(board, nil) th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(nil) th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil) newCard, err := th.App.CreateCard(card, board.ID, userID, false) require.NoError(t, err) require.Equal(t, card.BoardID, newCard.BoardID) require.Equal(t, card.Title, newCard.Title) require.Equal(t, card.ContentOrder, newCard.ContentOrder) require.EqualValues(t, card.Properties, newCard.Properties) }) t.Run("error scenario", func(t *testing.T) { th.Store.EXPECT().GetBoard(board.ID).Return(board, nil) th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(blockError{"error"}) newCard, err := th.App.CreateCard(card, board.ID, userID, false) require.Error(t, err, "error") require.Nil(t, newCard) }) } func TestGetCards(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() board := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), } const cardCount = 25 // make some cards blocks := make([]*model.Block, 0, cardCount) for i := 0; i < cardCount; i++ { card := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), ParentID: board.ID, Schema: 1, Type: model.TypeCard, Title: fmt.Sprintf("card %d", i), BoardID: board.ID, } blocks = append(blocks, card) } t.Run("success scenario", func(t *testing.T) { opts := model.QueryBlocksOptions{ BoardID: board.ID, BlockType: model.TypeCard, } th.Store.EXPECT().GetBlocks(opts).Return(blocks, nil) cards, err := th.App.GetCardsForBoard(board.ID, 0, 0) require.NoError(t, err) assert.Len(t, cards, cardCount) }) t.Run("error scenario", func(t *testing.T) { opts := model.QueryBlocksOptions{ BoardID: board.ID, BlockType: model.TypeCard, } th.Store.EXPECT().GetBlocks(opts).Return(nil, blockError{"error"}) cards, err := th.App.GetCardsForBoard(board.ID, 0, 0) require.Error(t, err) require.Nil(t, cards) }) } func TestPatchCard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() board := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), } userID := utils.NewID(utils.IDTypeUser) props := makeProps(3) card := &model.Card{ BoardID: board.ID, CreatedBy: userID, ModifiedBy: userID, Title: "test card for patch", ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)}, Properties: copyProps(props), } newTitle := "patched" newIcon := "😀" newContentOrder := reverse(card.ContentOrder) cardPatch := &model.CardPatch{ Title: &newTitle, ContentOrder: &newContentOrder, Icon: &newIcon, UpdatedProperties: modifyProps(props), } t.Run("success scenario", func(t *testing.T) { expectedPatchedCard := cardPatch.Patch(card) expectedPatchedBlock := model.Card2Block(expectedPatchedCard) var blockPatch *model.BlockPatch th.Store.EXPECT().GetBoard(board.ID).Return(board, nil) th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(nil) th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetBlock(card.ID).Return(expectedPatchedBlock, nil).AnyTimes() patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false) require.NoError(t, err) require.Equal(t, board.ID, patchedCard.BoardID) require.Equal(t, newTitle, patchedCard.Title) require.Equal(t, newIcon, patchedCard.Icon) require.Equal(t, newContentOrder, patchedCard.ContentOrder) require.EqualValues(t, expectedPatchedCard.Properties, patchedCard.Properties) }) t.Run("error scenario", func(t *testing.T) { var blockPatch *model.BlockPatch th.Store.EXPECT().GetBoard(board.ID).Return(board, nil) th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(blockError{"error"}) patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false) require.Error(t, err, "error") require.Nil(t, patchedCard) }) } func TestGetCard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() boardID := utils.NewID(utils.IDTypeBoard) userID := utils.NewID(utils.IDTypeUser) props := makeProps(5) contentOrder := []string{utils.NewID(utils.IDTypeUser), utils.NewID(utils.IDTypeUser)} fields := make(map[string]any) fields["contentOrder"] = contentOrder fields["properties"] = props fields["icon"] = "😀" fields["isTemplate"] = true block := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), ParentID: boardID, Type: model.TypeCard, Title: "test card", BoardID: boardID, Fields: fields, CreatedBy: userID, ModifiedBy: userID, } t.Run("success scenario", func(t *testing.T) { th.Store.EXPECT().GetBlock(block.ID).Return(block, nil) card, err := th.App.GetCardByID(block.ID) require.NoError(t, err) require.Equal(t, boardID, card.BoardID) require.Equal(t, block.Title, card.Title) require.Equal(t, "😀", card.Icon) require.Equal(t, true, card.IsTemplate) require.Equal(t, contentOrder, card.ContentOrder) require.EqualValues(t, props, card.Properties) }) t.Run("not found", func(t *testing.T) { bogusID := utils.NewID(utils.IDTypeBlock) th.Store.EXPECT().GetBlock(bogusID).Return(nil, model.NewErrNotFound(bogusID)) card, err := th.App.GetCardByID(bogusID) require.Error(t, err, "error") require.True(t, model.IsErrNotFound(err)) require.Nil(t, card) }) t.Run("error scenario", func(t *testing.T) { th.Store.EXPECT().GetBlock(block.ID).Return(nil, blockError{"error"}) card, err := th.App.GetCardByID(block.ID) require.Error(t, err, "error") require.Nil(t, card) }) } // reverse is a helper function to copy and reverse a slice of strings. func reverse(src []string) []string { out := make([]string, 0, len(src)) for i := len(src) - 1; i >= 0; i-- { out = append(out, src[i]) } return out } func makeProps(count int) map[string]any { props := make(map[string]any) for i := 0; i < count; i++ { props[utils.NewID(utils.IDTypeBlock)] = utils.NewID(utils.IDTypeBlock) } return props } func copyProps(m map[string]any) map[string]any { out := make(map[string]any) for k, v := range m { out[k] = v } return out } func modifyProps(m map[string]any) map[string]any { out := make(map[string]any) for k := range m { out[k] = utils.NewID(utils.IDTypeBlock) } return out } ================================================ FILE: server/app/category.go ================================================ package app import ( "errors" "fmt" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" ) var errCategoryNotFound = errors.New("category ID specified in input does not exist for user") var errCategoriesLengthMismatch = errors.New("cannot update category order, passed list of categories different size than in database") var ErrCannotDeleteSystemCategory = errors.New("cannot delete a system category") var ErrCannotUpdateSystemCategory = errors.New("cannot update a system category") func (a *App) GetCategory(categoryID string) (*model.Category, error) { return a.store.GetCategory(categoryID) } func (a *App) CreateCategory(category *model.Category) (*model.Category, error) { category.Hydrate() if err := category.IsValid(); err != nil { return nil, err } if err := a.store.CreateCategory(*category); err != nil { return nil, err } createdCategory, err := a.store.GetCategory(category.ID) if err != nil { return nil, err } go func() { a.wsAdapter.BroadcastCategoryChange(*createdCategory) }() return createdCategory, nil } func (a *App) UpdateCategory(category *model.Category) (*model.Category, error) { category.Hydrate() if err := category.IsValid(); err != nil { return nil, err } // verify if category belongs to the user existingCategory, err := a.store.GetCategory(category.ID) if err != nil { return nil, err } if existingCategory.DeleteAt != 0 { return nil, model.ErrCategoryDeleted } if existingCategory.UserID != category.UserID { return nil, model.ErrCategoryPermissionDenied } if existingCategory.TeamID != category.TeamID { return nil, model.ErrCategoryPermissionDenied } // in case type was defaulted above, set to existingCategory.Type category.Type = existingCategory.Type if existingCategory.Type == model.CategoryTypeSystem { // You cannot rename or delete a system category, // So restoring its name and undeleting it if set so. category.Name = existingCategory.Name category.DeleteAt = 0 } category.UpdateAt = utils.GetMillis() if err = category.IsValid(); err != nil { return nil, err } if err = a.store.UpdateCategory(*category); err != nil { return nil, err } updatedCategory, err := a.store.GetCategory(category.ID) if err != nil { return nil, err } go func() { a.wsAdapter.BroadcastCategoryChange(*updatedCategory) }() return updatedCategory, nil } func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category, error) { existingCategory, err := a.store.GetCategory(categoryID) if err != nil { return nil, err } // category is already deleted. This avoids // overriding the original deleted at timestamp if existingCategory.DeleteAt != 0 { return existingCategory, nil } // verify if category belongs to the user if existingCategory.UserID != userID { return nil, model.ErrCategoryPermissionDenied } // verify if category belongs to the team if existingCategory.TeamID != teamID { return nil, model.NewErrInvalidCategory("category doesn't belong to the team") } if existingCategory.Type == model.CategoryTypeSystem { return nil, ErrCannotDeleteSystemCategory } if err = a.moveBoardsToDefaultCategory(userID, teamID, categoryID); err != nil { return nil, err } if err = a.store.DeleteCategory(categoryID, userID, teamID); err != nil { return nil, err } deletedCategory, err := a.store.GetCategory(categoryID) if err != nil { return nil, err } go func() { a.wsAdapter.BroadcastCategoryChange(*deletedCategory) }() return deletedCategory, nil } func (a *App) moveBoardsToDefaultCategory(userID, teamID, sourceCategoryID string) error { // we need a list of boards associated to this category // so we can move them to user's default Boards category categoryBoards, err := a.GetUserCategoryBoards(userID, teamID) if err != nil { return err } var sourceCategoryBoards *model.CategoryBoards defaultCategoryID := "" // iterate user's categories to find the source category // and the default category. // We need source category to get the list of its board // and the default category to know its ID to // move source category's boards to. for i := range categoryBoards { if categoryBoards[i].ID == sourceCategoryID { sourceCategoryBoards = &categoryBoards[i] } if categoryBoards[i].Name == defaultCategoryBoards { defaultCategoryID = categoryBoards[i].ID } // if both categories are found, no need to iterate furthur. if sourceCategoryBoards != nil && defaultCategoryID != "" { break } } if sourceCategoryBoards == nil { return errCategoryNotFound } if defaultCategoryID == "" { return fmt.Errorf("moveBoardsToDefaultCategory: %w", errNoDefaultCategoryFound) } boardIDs := make([]string, len(sourceCategoryBoards.BoardMetadata)) for i := range sourceCategoryBoards.BoardMetadata { boardIDs[i] = sourceCategoryBoards.BoardMetadata[i].BoardID } if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil { return fmt.Errorf("moveBoardsToDefaultCategory: %w", err) } return nil } func (a *App) ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) { if err := a.verifyNewCategoriesMatchExisting(userID, teamID, newCategoryOrder); err != nil { return nil, err } newOrder, err := a.store.ReorderCategories(userID, teamID, newCategoryOrder) if err != nil { return nil, err } go func() { a.wsAdapter.BroadcastCategoryReorder(teamID, userID, newOrder) }() return newOrder, nil } func (a *App) verifyNewCategoriesMatchExisting(userID, teamID string, newCategoryOrder []string) error { existingCategories, err := a.store.GetUserCategories(userID, teamID) if err != nil { return err } if len(newCategoryOrder) != len(existingCategories) { return fmt.Errorf( "%w length new categories: %d, length existing categories: %d, userID: %s, teamID: %s", errCategoriesLengthMismatch, len(newCategoryOrder), len(existingCategories), userID, teamID, ) } existingCategoriesMap := map[string]bool{} for _, category := range existingCategories { existingCategoriesMap[category.ID] = true } for _, newCategoryID := range newCategoryOrder { if _, found := existingCategoriesMap[newCategoryID]; !found { return fmt.Errorf( "%w specified category ID: %s, userID: %s, teamID: %s", errCategoryNotFound, newCategoryID, userID, teamID, ) } } return nil } ================================================ FILE: server/app/category_boards.go ================================================ package app import ( "errors" "fmt" "github.com/mattermost/focalboard/server/model" ) const defaultCategoryBoards = "Boards" var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database") var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID") var errBoardMembershipNotFound = errors.New("board membership not found for user's board") func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) { categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID) if err != nil { return nil, err } createdCategoryBoards, err := a.createDefaultCategoriesIfRequired(categoryBoards, userID, teamID) if err != nil { return nil, err } categoryBoards = append(categoryBoards, createdCategoryBoards...) return categoryBoards, nil } func (a *App) createDefaultCategoriesIfRequired(existingCategoryBoards []model.CategoryBoards, userID, teamID string) ([]model.CategoryBoards, error) { createdCategories := []model.CategoryBoards{} boardsCategoryExist := false for _, categoryBoard := range existingCategoryBoards { if categoryBoard.Name == defaultCategoryBoards { boardsCategoryExist = true } } if !boardsCategoryExist { createdCategoryBoards, err := a.createBoardsCategory(userID, teamID, existingCategoryBoards) if err != nil { return nil, err } createdCategories = append(createdCategories, *createdCategoryBoards) } return createdCategories, nil } func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards []model.CategoryBoards) (*model.CategoryBoards, error) { // create the category category := model.Category{ Name: defaultCategoryBoards, UserID: userID, TeamID: teamID, Collapsed: false, Type: model.CategoryTypeSystem, SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap, } createdCategory, err := a.CreateCategory(&category) if err != nil { return nil, fmt.Errorf("createBoardsCategory default category creation failed: %w", err) } // once the category is created, we need to move all boards which do not // belong to any category, into this category. boardMembers, err := a.GetMembersForUser(userID) if err != nil { return nil, fmt.Errorf("createBoardsCategory error fetching user's board memberships: %w", err) } boardMemberByBoardID := map[string]*model.BoardMember{} for _, boardMember := range boardMembers { boardMemberByBoardID[boardMember.BoardID] = boardMember } createdCategoryBoards := &model.CategoryBoards{ Category: *createdCategory, BoardMetadata: []model.CategoryBoardMetadata{}, } // get user's current team's baords userTeamBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false) if err != nil { return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err) } boardIDsToAdd := []string{} for _, board := range userTeamBoards { boardMembership, ok := boardMemberByBoardID[board.ID] if !ok { return nil, fmt.Errorf("createBoardsCategory: %w", errBoardMembershipNotFound) } // boards with implicit access (aka synthetic membership), // should show up in LHS only when openign them explicitelly. // So we don't process any synthetic membership boards // and only add boards with explicit access to, to the the LHS, // for example, if a user explicitelly added another user to a board. if boardMembership.Synthetic { continue } belongsToCategory := false for _, categoryBoard := range existingCategoryBoards { for _, metadata := range categoryBoard.BoardMetadata { if metadata.BoardID == board.ID { belongsToCategory = true break } } // stop looking into other categories if // the board was found in a category if belongsToCategory { break } } if !belongsToCategory { boardIDsToAdd = append(boardIDsToAdd, board.ID) newBoardMetadata := model.CategoryBoardMetadata{ BoardID: board.ID, Hidden: false, } createdCategoryBoards.BoardMetadata = append(createdCategoryBoards.BoardMetadata, newBoardMetadata) } } if len(boardIDsToAdd) > 0 { if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, boardIDsToAdd); err != nil { return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err) } } return createdCategoryBoards, nil } func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID string, boardIDs []string) error { if len(boardIDs) == 0 { return nil } err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardIDs) if err != nil { return err } userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID) if err != nil { return err } var updatedCategory *model.CategoryBoards for i := range userCategoryBoards { if userCategoryBoards[i].ID == categoryID { updatedCategory = &userCategoryBoards[i] break } } if updatedCategory == nil { return errCategoryNotFound } wsPayload := make([]*model.BoardCategoryWebsocketData, len(updatedCategory.BoardMetadata)) i := 0 for _, categoryBoardMetadata := range updatedCategory.BoardMetadata { wsPayload[i] = &model.BoardCategoryWebsocketData{ BoardID: categoryBoardMetadata.BoardID, CategoryID: categoryID, Hidden: categoryBoardMetadata.Hidden, } i++ } a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastCategoryBoardChange( teamID, userID, wsPayload, ) return nil }) return nil } func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) { if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil { return nil, err } newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder) if err != nil { return nil, err } go func() { a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder) }() return newOrder, nil } func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error { // this function is to ensure that we don't miss specifying // all boards of the category while reordering. existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID) if err != nil { return err } var targetCategoryBoards *model.CategoryBoards for i := range existingCategoryBoards { if existingCategoryBoards[i].Category.ID == categoryID { targetCategoryBoards = &existingCategoryBoards[i] break } } if targetCategoryBoards == nil { return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID) } if len(targetCategoryBoards.BoardMetadata) != len(newBoardsOrder) { return fmt.Errorf( "%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s", errCategoryBoardsLengthMismatch, len(newBoardsOrder), len(targetCategoryBoards.BoardMetadata), userID, teamID, categoryID, ) } existingBoardMap := map[string]bool{} for _, metadata := range targetCategoryBoards.BoardMetadata { existingBoardMap[metadata.BoardID] = true } for _, boardID := range newBoardsOrder { if _, found := existingBoardMap[boardID]; !found { return fmt.Errorf( "%w board ID: %s, category ID: %s, userID: %s, teamID: %s", errBoardNotFoundInCategory, boardID, categoryID, userID, teamID, ) } } return nil } func (a *App) SetBoardVisibility(teamID, userID, categoryID, boardID string, visible bool) error { if err := a.store.SetBoardVisibility(userID, categoryID, boardID, visible); err != nil { return fmt.Errorf("SetBoardVisibility: failed to update board visibility: %w", err) } a.wsAdapter.BroadcastCategoryBoardChange(teamID, userID, []*model.BoardCategoryWebsocketData{ { BoardID: boardID, CategoryID: categoryID, Hidden: !visible, }, }) return nil } ================================================ FILE: server/app/category_boards_test.go ================================================ package app import ( "testing" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/assert" ) func TestGetUserCategoryBoards(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("user had no default category and had boards", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil).Times(1) th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "boards_category_id", Type: model.CategoryTypeSystem, Name: "Boards", }, }, }, nil).Times(1) th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Name: "Boards", }, nil) board1 := &model.Board{ ID: "board_id_1", } board2 := &model.Board{ ID: "board_id_2", } board3 := &model.Board{ ID: "board_id_3", } th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{ { BoardID: "board_id_1", Synthetic: false, }, { BoardID: "board_id_2", Synthetic: false, }, { BoardID: "board_id_3", Synthetic: false, }, }, nil) th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3) th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil) categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "Boards", categoryBoards[0].Name) assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata)) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false}) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false}) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false}) }) t.Run("user had no default category BUT had no boards", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil) th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Name: "Boards", }, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil) categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "Boards", categoryBoards[0].Name) assert.Equal(t, 0, len(categoryBoards[0].BoardMetadata)) }) t.Run("user already had a default Boards category with boards in it", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{Name: "Boards"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_1", Hidden: false}, {BoardID: "board_id_2", Hidden: false}, }, }, }, nil) categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "Boards", categoryBoards[0].Name) assert.Equal(t, 2, len(categoryBoards[0].BoardMetadata)) }) } func TestCreateBoardsCategory(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("user doesn't have any boards - implicit or explicit", func(t *testing.T) { th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Type: "system", Name: "Boards", }, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil) existingCategoryBoards := []model.CategoryBoards{} boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards) assert.NoError(t, err) assert.NotNil(t, boardsCategory) assert.Equal(t, "Boards", boardsCategory.Name) assert.Equal(t, 0, len(boardsCategory.BoardMetadata)) }) t.Run("user has implicit access to some board", func(t *testing.T) { th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Type: "system", Name: "Boards", }, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{ { BoardID: "board_id_1", Synthetic: true, }, { BoardID: "board_id_2", Synthetic: true, }, { BoardID: "board_id_3", Synthetic: true, }, }, nil) th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3) existingCategoryBoards := []model.CategoryBoards{} boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards) assert.NoError(t, err) assert.NotNil(t, boardsCategory) assert.Equal(t, "Boards", boardsCategory.Name) // there should still be no boards in the default category as // the user had only implicit access to boards assert.Equal(t, 0, len(boardsCategory.BoardMetadata)) }) t.Run("user has explicit access to some board", func(t *testing.T) { th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Type: "system", Name: "Boards", }, nil) board1 := &model.Board{ ID: "board_id_1", } board2 := &model.Board{ ID: "board_id_2", } board3 := &model.Board{ ID: "board_id_3", } th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{ { BoardID: "board_id_1", Synthetic: false, }, { BoardID: "board_id_2", Synthetic: false, }, { BoardID: "board_id_3", Synthetic: false, }, }, nil) th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3) th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil) th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ Type: model.CategoryTypeSystem, ID: "boards_category_id", Name: "Boards", }, }, }, nil) existingCategoryBoards := []model.CategoryBoards{} boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards) assert.NoError(t, err) assert.NotNil(t, boardsCategory) assert.Equal(t, "Boards", boardsCategory.Name) // since user has explicit access to three boards, // they should all end up in the default category assert.Equal(t, 3, len(boardsCategory.BoardMetadata)) }) t.Run("user has both implicit and explicit access to some board", func(t *testing.T) { th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Type: "system", Name: "Boards", }, nil) board1 := &model.Board{ ID: "board_id_1", } th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1}, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{ { BoardID: "board_id_1", Synthetic: false, }, { BoardID: "board_id_2", Synthetic: true, }, { BoardID: "board_id_3", Synthetic: true, }, }, nil) th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3) th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1"}).Return(nil) th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ Type: model.CategoryTypeSystem, ID: "boards_category_id", Name: "Boards", }, }, }, nil) existingCategoryBoards := []model.CategoryBoards{} boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards) assert.NoError(t, err) assert.NotNil(t, boardsCategory) assert.Equal(t, "Boards", boardsCategory.Name) // there was only one explicit board access, // and so only that one should end up in the // default category assert.Equal(t, 1, len(boardsCategory.BoardMetadata)) }) } func TestReorderCategoryBoards(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ID: "category_id_1", Name: "Category 1"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_1", Hidden: false}, {BoardID: "board_id_2", Hidden: false}, }, }, { Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_3", Hidden: false}, }, }, { Category: model.Category{ID: "category_id_3", Name: "Category 3"}, BoardMetadata: []model.CategoryBoardMetadata{}, }, }, nil) th.Store.EXPECT().ReorderCategoryBoards("category_id_1", []string{"board_id_2", "board_id_1"}).Return([]string{"board_id_2", "board_id_1"}, nil) newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"}) assert.NoError(t, err) assert.Equal(t, 2, len(newOrder)) assert.Equal(t, "board_id_2", newOrder[0]) assert.Equal(t, "board_id_1", newOrder[1]) }) t.Run("not specifying all boards", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ID: "category_id_1", Name: "Category 1"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_1", Hidden: false}, {BoardID: "board_id_2", Hidden: false}, {BoardID: "board_id_3", Hidden: false}, }, }, { Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"}, BoardMetadata: []model.CategoryBoardMetadata{ {BoardID: "board_id_3", Hidden: false}, }, }, { Category: model.Category{ID: "category_id_3", Name: "Category 3"}, BoardMetadata: []model.CategoryBoardMetadata{}, }, }, nil) newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"}) assert.Error(t, err) assert.Nil(t, newOrder) }) } ================================================ FILE: server/app/category_test.go ================================================ package app import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" ) func TestCreateCategory(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", }, nil) category := &model.Category{ Name: "Category", UserID: "user_id", TeamID: "team_id", Type: "custom", } createdCategory, err := th.App.CreateCategory(category) assert.NotNil(t, createdCategory) assert.NoError(t, err) }) t.Run("creating invalid category", func(t *testing.T) { category := &model.Category{ Name: "", // empty name shouldn't be allowed UserID: "user_id", TeamID: "team_id", Type: "custom", } createdCategory, err := th.App.CreateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) category.Name = "Name" category.UserID = "" // empty creator user id shouldn't be allowed createdCategory, err = th.App.CreateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) category.UserID = "user_id" category.TeamID = "" // empty TeamID shouldn't be allowed createdCategory, err = th.App.CreateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) category.Type = "invalid" // unknown type shouldn't be allowed createdCategory, err = th.App.CreateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) }) } func TestUpdateCategory(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "custom", }, nil) th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{ ID: "category_id_1", Name: "Category", }, nil) category := &model.Category{ ID: "category_id_1", Name: "Category", UserID: "user_id_1", TeamID: "team_id_1", Type: "custom", } updatedCategory, err := th.App.UpdateCategory(category) assert.NotNil(t, updatedCategory) assert.NoError(t, err) }) t.Run("updating invalid category", func(t *testing.T) { th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "custom", }, nil) category := &model.Category{ ID: "category_id_1", Name: "Name", UserID: "user_id", TeamID: "team_id", Type: "custom", } category.ID = "" createdCategory, err := th.App.UpdateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) category.ID = "category_id_1" category.Name = "" createdCategory, err = th.App.UpdateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) category.Name = "Name" category.UserID = "" // empty creator user id shouldn't be allowed createdCategory, err = th.App.UpdateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) category.UserID = "user_id" category.TeamID = "" // empty TeamID shouldn't be allowed createdCategory, err = th.App.UpdateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) category.Type = "invalid" // unknown type shouldn't be allowed createdCategory, err = th.App.UpdateCategory(category) assert.Nil(t, createdCategory) assert.Error(t, err) }) t.Run("trying to update someone else's category", func(t *testing.T) { th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "custom", }, nil) category := &model.Category{ ID: "category_id_1", Name: "Category", UserID: "user_id_2", TeamID: "team_id_1", Type: "custom", } updatedCategory, err := th.App.UpdateCategory(category) assert.Nil(t, updatedCategory) assert.Error(t, err) }) t.Run("trying to update some other team's category", func(t *testing.T) { th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "custom", }, nil) category := &model.Category{ ID: "category_id_1", Name: "Category", UserID: "user_id_1", TeamID: "team_id_2", Type: "custom", } updatedCategory, err := th.App.UpdateCategory(category) assert.Nil(t, updatedCategory) assert.Error(t, err) }) t.Run("should not be allowed to rename system category", func(t *testing.T) { th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "system", }, nil).Times(1) th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "system", Collapsed: true, }, nil).Times(1) category := &model.Category{ ID: "category_id_1", Name: "Updated Name", UserID: "user_id_1", TeamID: "team_id_1", Type: "system", } updatedCategory, err := th.App.UpdateCategory(category) assert.NotNil(t, updatedCategory) assert.NoError(t, err) assert.Equal(t, "Category", updatedCategory.Name) }) t.Run("should be allowed to collapse and expand any category type", func(t *testing.T) { th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "system", Collapsed: false, }, nil).Times(1) th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "category_id_1", Name: "Category", TeamID: "team_id_1", UserID: "user_id_1", Type: "system", Collapsed: true, }, nil).Times(1) category := &model.Category{ ID: "category_id_1", Name: "Updated Name", UserID: "user_id_1", TeamID: "team_id_1", Type: "system", Collapsed: true, } updatedCategory, err := th.App.UpdateCategory(category) assert.NotNil(t, updatedCategory) assert.NoError(t, err) assert.Equal(t, "Category", updatedCategory.Name, "The name should have not been updated") assert.True(t, updatedCategory.Collapsed) }) } func TestDeleteCategory(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{ ID: "category_id_1", DeleteAt: 0, UserID: "user_id_1", TeamID: "team_id_1", Type: "custom", }, nil) th.Store.EXPECT().DeleteCategory("category_id_1", "user_id_1", "team_id_1").Return(nil) th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{ DeleteAt: 10000, }, nil) th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "category_id_default", DeleteAt: 0, UserID: "user_id_1", TeamID: "team_id_1", Type: "default", Name: "Boards", }, BoardMetadata: []model.CategoryBoardMetadata{}, }, { Category: model.Category{ ID: "category_id_1", DeleteAt: 0, UserID: "user_id_1", TeamID: "team_id_1", Type: "custom", Name: "Category 1", }, BoardMetadata: []model.CategoryBoardMetadata{}, }, }, nil) deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1") assert.NotNil(t, deletedCategory) assert.NoError(t, err) }) t.Run("trying to delete already deleted category", func(t *testing.T) { th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{ ID: "category_id_1", DeleteAt: 1000, UserID: "user_id_1", TeamID: "team_id_1", Type: "custom", }, nil) deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1") assert.NotNil(t, deletedCategory) assert.NoError(t, err) }) t.Run("trying to delete system category", func(t *testing.T) { th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{ ID: "category_id_1", DeleteAt: 0, UserID: "user_id_1", TeamID: "team_id_1", Type: "system", }, nil) deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1") assert.Nil(t, deletedCategory) assert.Error(t, err) }) } func TestMoveBoardsToDefaultCategory(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("When default category already exists", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "category_id_1", Name: "Boards", Type: "system", }, }, { Category: model.Category{ ID: "category_id_2", Name: "Custom Category 1", Type: "custom", }, }, }, nil) err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2") assert.NoError(t, err) }) t.Run("When default category doesn't already exists", func(t *testing.T) { th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "category_id_2", Name: "Custom Category 1", Type: "custom", }, }, }, nil) th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "default_category_id", Name: "Boards", Type: "system", }, nil) th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil) err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2") assert.NoError(t, err) }) } func TestReorderCategories(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ { ID: "category_id_1", Name: "Boards", Type: "system", }, { ID: "category_id_2", Name: "Category 2", Type: "custom", }, { ID: "category_id_3", Name: "Category 3", Type: "custom", }, }, nil) th.Store.EXPECT().ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}). Return([]string{"category_id_2", "category_id_3", "category_id_1"}, nil) newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}) assert.NoError(t, err) assert.Equal(t, 3, len(newOrder)) }) t.Run("not specifying all categories should fail", func(t *testing.T) { th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ { ID: "category_id_1", Name: "Boards", Type: "system", }, { ID: "category_id_2", Name: "Category 2", Type: "custom", }, { ID: "category_id_3", Name: "Category 3", Type: "custom", }, }, nil) newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3"}) assert.Error(t, err) assert.Nil(t, newOrder) }) } func TestVerifyNewCategoriesMatchExisting(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ { ID: "category_id_1", Name: "Boards", Type: "system", }, { ID: "category_id_2", Name: "Category 2", Type: "custom", }, { ID: "category_id_3", Name: "Category 3", Type: "custom", }, }, nil) err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{ "category_id_2", "category_id_3", "category_id_1", }) assert.NoError(t, err) }) t.Run("different category counts", func(t *testing.T) { th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ { ID: "category_id_1", Name: "Boards", Type: "system", }, { ID: "category_id_2", Name: "Category 2", Type: "custom", }, { ID: "category_id_3", Name: "Category 3", Type: "custom", }, }, nil) err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{ "category_id_2", "category_id_3", }) assert.Error(t, err) }) } ================================================ FILE: server/app/clientConfig.go ================================================ package app import ( "github.com/mattermost/focalboard/server/model" ) func (a *App) GetClientConfig() *model.ClientConfig { return &model.ClientConfig{ Telemetry: a.config.Telemetry, TelemetryID: a.config.TelemetryID, EnablePublicSharedBoards: a.config.EnablePublicSharedBoards, TeammateNameDisplay: a.config.TeammateNameDisplay, FeatureFlags: a.config.FeatureFlags, MaxFileSize: a.config.MaxFileSize, } } ================================================ FILE: server/app/clientConfig_test.go ================================================ package app import ( "testing" "github.com/mattermost/focalboard/server/services/config" "github.com/stretchr/testify/require" ) func TestGetClientConfig(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("Test Get Client Config", func(t *testing.T) { newConfiguration := config.Configuration{} newConfiguration.Telemetry = true newConfiguration.TelemetryID = "abcde" newConfiguration.EnablePublicSharedBoards = true newConfiguration.TeammateNameDisplay = "username" th.App.SetConfig(&newConfiguration) clientConfig := th.App.GetClientConfig() require.True(t, clientConfig.EnablePublicSharedBoards) require.True(t, clientConfig.Telemetry) require.Equal(t, "abcde", clientConfig.TelemetryID) require.Equal(t, "username", clientConfig.TeammateNameDisplay) }) } ================================================ FILE: server/app/compliance.go ================================================ package app import "github.com/mattermost/focalboard/server/model" func (a *App) GetBoardsForCompliance(opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) { return a.store.GetBoardsForCompliance(opts) } func (a *App) GetBoardsComplianceHistory(opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) { return a.store.GetBoardsComplianceHistory(opts) } func (a *App) GetBlocksComplianceHistory(opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) { return a.store.GetBlocksComplianceHistory(opts) } ================================================ FILE: server/app/content_blocks.go ================================================ package app import ( "fmt" "github.com/mattermost/focalboard/server/model" "github.com/pkg/errors" ) func (a *App) MoveContentBlock(block *model.Block, dstBlock *model.Block, where string, userID string) error { if block.ParentID != dstBlock.ParentID { message := fmt.Sprintf("not matching parent %s and %s", block.ParentID, dstBlock.ParentID) return model.NewErrBadRequest(message) } card, err := a.GetBlockByID(block.ParentID) if err != nil { return err } contentOrderData, ok := card.Fields["contentOrder"] var contentOrder []interface{} if ok { contentOrder = contentOrderData.([]interface{}) } newContentOrder := []interface{}{} foundDst := false foundSrc := false for _, id := range contentOrder { stringID, ok := id.(string) if !ok { newContentOrder = append(newContentOrder, id) continue } if dstBlock.ID == stringID { foundDst = true if where == "after" { newContentOrder = append(newContentOrder, id) newContentOrder = append(newContentOrder, block.ID) } else { newContentOrder = append(newContentOrder, block.ID) newContentOrder = append(newContentOrder, id) } continue } if block.ID == stringID { foundSrc = true continue } newContentOrder = append(newContentOrder, id) } if !foundSrc { message := fmt.Sprintf("source block %s not found", block.ID) return model.NewErrBadRequest(message) } if !foundDst { message := fmt.Sprintf("destination block %s not found", dstBlock.ID) return model.NewErrBadRequest(message) } patch := &model.BlockPatch{ UpdatedFields: map[string]interface{}{ "contentOrder": newContentOrder, }, } _, err = a.PatchBlock(block.ParentID, patch, userID) if errors.Is(err, model.ErrPatchUpdatesLimitedCards) { return err } if err != nil { return err } return nil } ================================================ FILE: server/app/content_blocks_test.go ================================================ package app import ( "fmt" "testing" "github.com/golang/mock/gomock" "github.com/pkg/errors" "github.com/stretchr/testify/require" "github.com/mattermost/focalboard/server/model" ) type contentOrderMatcher struct { contentOrder []string } func NewContentOrderMatcher(contentOrder []string) contentOrderMatcher { return contentOrderMatcher{contentOrder} } func (com contentOrderMatcher) Matches(x interface{}) bool { patch, ok := x.(*model.BlockPatch) if !ok { return false } contentOrderData, ok := patch.UpdatedFields["contentOrder"] if !ok { return false } contentOrder, ok := contentOrderData.([]interface{}) if !ok { return false } if len(contentOrder) != len(com.contentOrder) { return false } for i := range contentOrder { if contentOrder[i] != com.contentOrder[i] { return false } } return true } func (com contentOrderMatcher) String() string { return fmt.Sprint(&model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": com.contentOrder}}) } func TestMoveContentBlock(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() ttCases := []struct { name string srcBlock model.Block dstBlock model.Block parentBlock *model.Block where string userID string mockPatch bool mockPatchError error errorMessage string expectedContentOrder []string }{ { name: "not matching parents", srcBlock: model.Block{ID: "test-1", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-2", ParentID: "other-test-card"}, parentBlock: nil, where: "after", userID: "user-id", errorMessage: "not matching parent test-card and other-test-card", }, { name: "parent not found", srcBlock: model.Block{ID: "test-1", ParentID: "invalid-card"}, dstBlock: model.Block{ID: "test-2", ParentID: "invalid-card"}, parentBlock: &model.Block{ID: "invalid-card"}, where: "after", userID: "user-id", errorMessage: "{test} not found", }, { name: "valid parent without content order", srcBlock: model.Block{ID: "test-1", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-2", ParentID: "test-card"}, parentBlock: &model.Block{ID: "test-card"}, where: "after", userID: "user-id", errorMessage: "source block test-1 not found", }, { name: "valid parent with content order but without test-1 in it", srcBlock: model.Block{ID: "test-1", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-2", ParentID: "test-card"}, parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-2"}}}, where: "after", userID: "user-id", errorMessage: "source block test-1 not found", }, { name: "valid parent with content order but without test-2 in it", srcBlock: model.Block{ID: "test-1", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-2", ParentID: "test-card"}, parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1"}}}, where: "after", userID: "user-id", errorMessage: "destination block test-2 not found", }, { name: "valid request but fail on patchparent with content order", srcBlock: model.Block{ID: "test-1", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-2", ParentID: "test-card"}, parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2"}}}, where: "after", userID: "user-id", mockPatch: true, mockPatchError: errors.New("test error"), errorMessage: "test error", }, { name: "valid request with not real change", srcBlock: model.Block{ID: "test-2", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-1", ParentID: "test-card"}, parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"}, where: "after", userID: "user-id", mockPatch: true, errorMessage: "", expectedContentOrder: []string{"test-1", "test-2", "test-3"}, }, { name: "valid request changing order with before", srcBlock: model.Block{ID: "test-2", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-1", ParentID: "test-card"}, parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"}, where: "before", userID: "user-id", mockPatch: true, errorMessage: "", expectedContentOrder: []string{"test-2", "test-1", "test-3"}, }, { name: "valid request changing order with after", srcBlock: model.Block{ID: "test-1", ParentID: "test-card"}, dstBlock: model.Block{ID: "test-2", ParentID: "test-card"}, parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"}, where: "after", userID: "user-id", mockPatch: true, errorMessage: "", expectedContentOrder: []string{"test-2", "test-1", "test-3"}, }, } for _, tc := range ttCases { t.Run(tc.name, func(t *testing.T) { tc := tc if tc.parentBlock != nil { if tc.parentBlock.ID == "invalid-card" { th.Store.EXPECT().GetBlock(tc.srcBlock.ParentID).Return(nil, model.NewErrNotFound("test")) } else { th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil) if tc.mockPatch { if tc.mockPatchError != nil { th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(nil, tc.mockPatchError) } else { th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil) th.Store.EXPECT().PatchBlock(tc.parentBlock.ID, NewContentOrderMatcher(tc.expectedContentOrder), gomock.Eq("user-id")).Return(nil) th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil) th.Store.EXPECT().GetBoard(tc.parentBlock.BoardID).Return(&model.Board{ID: "test-board"}, nil) // this call comes from the WS server notification th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1) } } } } err := th.App.MoveContentBlock(&tc.srcBlock, &tc.dstBlock, tc.where, tc.userID) if tc.errorMessage == "" { require.NoError(t, err) } else { require.EqualError(t, err, tc.errorMessage) } }) } } ================================================ FILE: server/app/export.go ================================================ package app import ( "archive/zip" "encoding/json" "fmt" "io" "github.com/mattermost/focalboard/server/model" "github.com/wiggin77/merror" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var ( newline = []byte{'\n'} ) func (a *App) ExportArchive(w io.Writer, opt model.ExportArchiveOptions) (errs error) { boards, err := a.getBoardsForArchive(opt.BoardIDs) if err != nil { return err } merr := merror.New() defer func() { errs = merr.ErrorOrNil() }() // wrap the writer in a zip. zw := zip.NewWriter(w) defer func() { merr.Append(zw.Close()) }() if err := a.writeArchiveVersion(zw); err != nil { merr.Append(err) return } for _, board := range boards { if err := a.writeArchiveBoard(zw, board, opt); err != nil { merr.Append(fmt.Errorf("cannot export board %s: %w", board.ID, err)) return } } return nil } // writeArchiveVersion writes a version file to the zip. func (a *App) writeArchiveVersion(zw *zip.Writer) error { archiveHeader := model.ArchiveHeader{ Version: archiveVersion, Date: model.GetMillis(), } b, _ := json.Marshal(&archiveHeader) w, err := zw.Create("version.json") if err != nil { return fmt.Errorf("cannot write archive header: %w", err) } if _, err := w.Write(b); err != nil { return fmt.Errorf("cannot write archive header: %w", err) } return nil } // writeArchiveBoard writes a single board to the archive in a zip directory. func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.ExportArchiveOptions) error { // create a directory per board w, err := zw.Create(board.ID + "/board.jsonl") if err != nil { return err } // write the board block first if err = a.writeArchiveBoardLine(w, board); err != nil { return err } var files []string // write the board's blocks // TODO: paginate this blocks, err := a.GetBlocksForBoard(board.ID) if err != nil { return err } for _, block := range blocks { if err = a.writeArchiveBlockLine(w, block); err != nil { return err } if block.Type == model.TypeImage || block.Type == model.TypeAttachment { filename, err2 := extractFilename(block) if err2 != nil { return err2 } files = append(files, filename) } } boardMembers, err := a.GetMembersForBoard(board.ID) if err != nil { return err } for _, boardMember := range boardMembers { if err = a.writeArchiveBoardMemberLine(w, boardMember); err != nil { return err } } // write the files for _, filename := range files { if err := a.writeArchiveFile(zw, filename, board.ID, opt); err != nil { return fmt.Errorf("cannot write file %s to archive: %w", filename, err) } } return nil } // writeArchiveBoardMemberLine writes a single boardMember to the archive. func (a *App) writeArchiveBoardMemberLine(w io.Writer, boardMember *model.BoardMember) error { bm, err := json.Marshal(&boardMember) if err != nil { return err } line := model.ArchiveLine{ Type: "boardMember", Data: bm, } bm, err = json.Marshal(&line) if err != nil { return err } _, err = w.Write(bm) if err != nil { return err } _, err = w.Write(newline) return err } // writeArchiveBlockLine writes a single block to the archive. func (a *App) writeArchiveBlockLine(w io.Writer, block *model.Block) error { b, err := json.Marshal(&block) if err != nil { return err } line := model.ArchiveLine{ Type: "block", Data: b, } b, err = json.Marshal(&line) if err != nil { return err } _, err = w.Write(b) if err != nil { return err } // jsonl files need a newline _, err = w.Write(newline) return err } // writeArchiveBlockLine writes a single block to the archive. func (a *App) writeArchiveBoardLine(w io.Writer, board model.Board) error { b, err := json.Marshal(&board) if err != nil { return err } line := model.ArchiveLine{ Type: "board", Data: b, } b, err = json.Marshal(&line) if err != nil { return err } _, err = w.Write(b) if err != nil { return err } // jsonl files need a newline _, err = w.Write(newline) return err } // writeArchiveFile writes a single file to the archive. func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string, opt model.ExportArchiveOptions) error { dest, err := zw.Create(boardID + "/" + filename) if err != nil { return err } _, fileReader, err := a.GetFile(opt.TeamID, boardID, filename) if err != nil && !model.IsErrNotFound(err) { return err } if err != nil { // just log this; image file is missing but we'll still export an equivalent board a.logger.Error("image file missing for export", mlog.String("filename", filename), mlog.String("team_id", opt.TeamID), mlog.String("board_id", boardID), ) return nil } defer fileReader.Close() _, err = io.Copy(dest, fileReader) return err } // getBoardsForArchive fetches all the specified boards. func (a *App) getBoardsForArchive(boardIDs []string) ([]model.Board, error) { boards := make([]model.Board, 0, len(boardIDs)) for _, id := range boardIDs { b, err := a.GetBoard(id) if err != nil { return nil, fmt.Errorf("could not fetch board %s: %w", id, err) } boards = append(boards, *b) } return boards, nil } func extractFilename(block *model.Block) (string, error) { f, ok := block.Fields["fileId"] if !ok { f, ok = block.Fields["attachmentId"] if !ok { return "", model.ErrInvalidImageBlock } } filename, ok := f.(string) if !ok { return "", model.ErrInvalidImageBlock } return filename, nil } ================================================ FILE: server/app/files.go ================================================ package app import ( "errors" "fmt" "io" "path/filepath" "strings" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" ) const emptyString = "empty" var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed") var ErrFileNotFound = errors.New("file not found") func (a *App) SaveFile(reader io.Reader, teamID, boardID, filename string, asTemplate bool) (string, error) { // NOTE: File extension includes the dot fileExtension := strings.ToLower(filepath.Ext(filename)) if fileExtension == ".jpeg" { fileExtension = ".jpg" } createdFilename := utils.NewID(utils.IDTypeNone) newFileName := fmt.Sprintf(`%s%s`, createdFilename, fileExtension) if asTemplate { newFileName = filename } filePath := getDestinationFilePath(asTemplate, teamID, boardID, newFileName) fileSize, appErr := a.filesBackend.WriteFile(reader, filePath) if appErr != nil { return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr) } fileInfo := model.NewFileInfo(filename) fileInfo.Id = getFileInfoID(createdFilename) fileInfo.Path = filePath fileInfo.Size = fileSize err := a.store.SaveFileInfo(fileInfo) if err != nil { return "", err } return newFileName, nil } func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) { if len(filename) == 0 { return nil, errEmptyFilename } // filename is in the format 7. // we want to extract the part of this as this // will be the fileinfo id. fileInfoID := getFileInfoID(strings.Split(filename, ".")[0]) fileInfo, err := a.store.GetFileInfo(fileInfoID) if err != nil { return nil, err } return fileInfo, nil } func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, filestore.ReadCloseSeeker, error) { fileInfo, filePath, err := a.GetFilePath(teamID, rootID, fileName) if err != nil { a.logger.Error("GetFile: Failed to GetFilePath.", mlog.String("Team", teamID), mlog.String("board", rootID), mlog.String("filename", fileName), mlog.Err(err)) return nil, nil, err } exists, err := a.filesBackend.FileExists(filePath) if err != nil { a.logger.Error("GetFile: Failed to check if file exists as path. ", mlog.String("Path", filePath), mlog.Err(err)) return nil, nil, err } if !exists { return nil, nil, ErrFileNotFound } reader, err := a.filesBackend.Reader(filePath) if err != nil { a.logger.Error("GetFile: Failed to get file reader of existing file at path", mlog.String("Path", filePath), mlog.Err(err)) return nil, nil, err } return fileInfo, reader, nil } func (a *App) GetFilePath(teamID, rootID, fileName string) (*mm_model.FileInfo, string, error) { fileInfo, err := a.GetFileInfo(fileName) if err != nil && !model.IsErrNotFound(err) { return nil, "", err } var filePath string if fileInfo != nil && fileInfo.Path != "" && fileInfo.Path != emptyString { filePath = fileInfo.Path } else { filePath = filepath.Join(teamID, rootID, fileName) } return fileInfo, filePath, nil } func getDestinationFilePath(isTemplate bool, teamID, boardID, filename string) string { // if saving a file for a template, save using the "old method" that is /teamID/boardID/fileName // this will prevent template files from being deleted by DataRetention, // which deletes all files inside the "date" subdirectory if isTemplate { return filepath.Join(teamID, boardID, filename) } return filepath.Join(utils.GetBaseFilePath(), filename) } func getFileInfoID(fileName string) string { // Boards ids are 27 characters long with a prefix character. // removing the prefix, returns the 26 character uuid return fileName[1:] } func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) { filePath := filepath.Join(teamID, rootID, filename) exists, err := a.filesBackend.FileExists(filePath) if err != nil { return nil, err } // FIXUP: Check the deprecated old location if teamID == "0" && !exists { oldExists, err2 := a.filesBackend.FileExists(filename) if err2 != nil { return nil, err2 } if oldExists { err2 := a.filesBackend.MoveFile(filename, filePath) if err2 != nil { a.logger.Error("ERROR moving file", mlog.String("old", filename), mlog.String("new", filePath), mlog.Err(err2), ) } else { a.logger.Debug("Moved file", mlog.String("old", filename), mlog.String("new", filePath), ) } } } else if !exists { return nil, ErrFileNotFound } reader, err := a.filesBackend.Reader(filePath) if err != nil { return nil, err } return reader, nil } func (a *App) MoveFile(channelID, teamID, boardID, filename string) error { oldPath := filepath.Join(channelID, boardID, filename) newPath := filepath.Join(teamID, boardID, filename) err := a.filesBackend.MoveFile(oldPath, newPath) if err != nil { a.logger.Error("ERROR moving file", mlog.String("old", oldPath), mlog.String("new", newPath), mlog.Err(err), ) return err } return nil } func (a *App) CopyAndUpdateCardFiles(boardID, userID string, blocks []*model.Block, asTemplate bool) error { newFileNames, err := a.CopyCardFiles(boardID, blocks, asTemplate) if err != nil { a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err)) } // blocks now has updated file ids for any blocks containing files. We need to update the database for them. blockIDs := make([]string, 0) blockPatches := make([]model.BlockPatch, 0) for _, block := range blocks { if block.Type == model.TypeImage || block.Type == model.TypeAttachment { if fileID, ok := block.Fields["fileId"].(string); ok { blockIDs = append(blockIDs, block.ID) blockPatches = append(blockPatches, model.BlockPatch{ UpdatedFields: map[string]interface{}{ "fileId": newFileNames[fileID], }, DeletedFields: []string{"attachmentId"}, }) } } } a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs))) if len(blockIDs) != 0 { patches := &model.BlockPatchBatch{ BlockIDs: blockIDs, BlockPatches: blockPatches, } if err := a.store.PatchBlocks(patches, userID); err != nil { return fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err) } } return nil } func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block, asTemplate bool) (map[string]string, error) { // Images attached in cards have a path comprising the card's board ID. // When we create a template from this board, we need to copy the files // with the new board ID in path. // Not doing so causing images in templates (and boards created from this // template) to fail to load. // look up ID of source sourceBoard, which may be different than the blocks. sourceBoard, err := a.GetBoard(sourceBoardID) if err != nil || sourceBoard == nil { return nil, fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err) } var destBoard *model.Board newFileNames := make(map[string]string) for _, block := range copiedBlocks { if block.Type != model.TypeImage && block.Type != model.TypeAttachment { continue } fileID, isOk := block.Fields["fileId"].(string) if !isOk { fileID, isOk = block.Fields["attachmentId"].(string) if !isOk { continue } } // create unique filename ext := filepath.Ext(fileID) fileInfoID := utils.NewID(utils.IDTypeNone) destFilename := fileInfoID + ext if destBoard == nil || block.BoardID != destBoard.ID { destBoard = sourceBoard if block.BoardID != destBoard.ID { destBoard, err = a.GetBoard(block.BoardID) if err != nil { return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err) } } } // GetFilePath will retrieve the correct path // depending on whether FileInfo table is used for the file. fileInfo, sourceFilePath, err := a.GetFilePath(sourceBoard.TeamID, sourceBoard.ID, fileID) if err != nil { return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err) } destinationFilePath := getDestinationFilePath(asTemplate, destBoard.TeamID, destBoard.ID, destFilename) if fileInfo == nil { fileInfo = model.NewFileInfo(destFilename) } fileInfo.Id = getFileInfoID(fileInfoID) fileInfo.Path = destinationFilePath err = a.store.SaveFileInfo(fileInfo) if err != nil { return nil, fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err) } a.logger.Debug( "Copying card file", mlog.String("sourceFilePath", sourceFilePath), mlog.String("destinationFilePath", destinationFilePath), ) if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil { a.logger.Error( "CopyCardFiles failed to copy file", mlog.String("sourceFilePath", sourceFilePath), mlog.String("destinationFilePath", destinationFilePath), mlog.Err(err), ) } newFileNames[fileID] = destFilename } return newFileNames, nil } ================================================ FILE: server/app/files_test.go ================================================ package app import ( "errors" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" "github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks" ) const ( testFileName = "temp-file-name" testBoardID = "test-board-id" testPath = "/path/to/file/fileName.txt" ) var errDummy = errors.New("hello") type TestError struct{} func (err *TestError) Error() string { return "Mocked File backend error" } func TestGetFileReader(t *testing.T) { testFilePath := filepath.Join("1", "test-board-id", "temp-file-name") th, _ := SetupTestHelper(t) mockedReadCloseSeek := &mocks.ReadCloseSeeker{} t.Run("should get file reader from filestore successfully", func(t *testing.T) { mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend readerFunc := func(path string) filestore.ReadCloseSeeker { return mockedReadCloseSeek } readerErrorFunc := func(path string) error { return nil } fileExistsFunc := func(path string) bool { return true } fileExistsErrorFunc := func(path string) error { return nil } mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc) mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc) actual, _ := th.App.GetFileReader("1", testBoardID, testFileName) assert.Equal(t, mockedReadCloseSeek, actual) }) t.Run("should get error from filestore when file exists return error", func(t *testing.T) { mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedError := &TestError{} readerFunc := func(path string) filestore.ReadCloseSeeker { return mockedReadCloseSeek } readerErrorFunc := func(path string) error { return nil } fileExistsFunc := func(path string) bool { return false } fileExistsErrorFunc := func(path string) error { return mockedError } mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc) mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc) actual, err := th.App.GetFileReader("1", testBoardID, testFileName) assert.Error(t, err, mockedError) assert.Nil(t, actual) }) t.Run("should return error, if get reader from file backend returns error", func(t *testing.T) { mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedError := &TestError{} readerFunc := func(path string) filestore.ReadCloseSeeker { return nil } readerErrorFunc := func(path string) error { return mockedError } fileExistsFunc := func(path string) bool { return false } fileExistsErrorFunc := func(path string) error { return nil } mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc) mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc) actual, err := th.App.GetFileReader("1", testBoardID, testFileName) assert.Error(t, err, mockedError) assert.Nil(t, actual) }) t.Run("should move file from old filepath to new filepath, if file doesnot exists in new filepath and workspace id is 0", func(t *testing.T) { filePath := filepath.Join("0", "test-board-id", "temp-file-name") workspaceid := "0" mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend readerFunc := func(path string) filestore.ReadCloseSeeker { return mockedReadCloseSeek } readerErrorFunc := func(path string) error { return nil } fileExistsFunc := func(path string) bool { // return true for old path return path == testFileName } fileExistsErrorFunc := func(path string) error { return nil } moveFileFunc := func(oldFileName, newFileName string) error { return nil } mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc) mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc) mockedFileBackend.On("MoveFile", testFileName, filePath).Return(moveFileFunc) mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc) actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName) assert.Equal(t, mockedReadCloseSeek, actual) }) t.Run("should return file reader, if file doesnot exists in new filepath and old file path", func(t *testing.T) { filePath := filepath.Join("0", "test-board-id", "temp-file-name") fileName := testFileName workspaceid := "0" mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend readerFunc := func(path string) filestore.ReadCloseSeeker { return mockedReadCloseSeek } readerErrorFunc := func(path string) error { return nil } fileExistsFunc := func(path string) bool { // return true for old path return false } fileExistsErrorFunc := func(path string) error { return nil } moveFileFunc := func(oldFileName, newFileName string) error { return nil } mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc) mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc) mockedFileBackend.On("MoveFile", fileName, filePath).Return(moveFileFunc) mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc) actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName) assert.Equal(t, mockedReadCloseSeek, actual) }) } func TestSaveFile(t *testing.T) { th, _ := SetupTestHelper(t) mockedReadCloseSeek := &mocks.ReadCloseSeeker{} t.Run("should save file to file store using file backend", func(t *testing.T) { fileName := "temp-file-name.txt" mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil) writeFileFunc := func(reader io.Reader, path string) int64 { paths := strings.Split(path, string(os.PathSeparator)) assert.Equal(t, "boards", paths[0]) assert.Equal(t, time.Now().Format("20060102"), paths[1]) fileName = paths[2] return int64(10) } writeFileErrorFunc := func(reader io.Reader, filePath string) error { return nil } mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName, false) assert.Equal(t, fileName, actual) assert.Nil(t, err) }) t.Run("should save .jpeg file as jpg file to file store using file backend", func(t *testing.T) { fileName := "temp-file-name.jpeg" mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil) writeFileFunc := func(reader io.Reader, path string) int64 { paths := strings.Split(path, string(os.PathSeparator)) assert.Equal(t, "boards", paths[0]) assert.Equal(t, time.Now().Format("20060102"), paths[1]) assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1]) return int64(10) } writeFileErrorFunc := func(reader io.Reader, filePath string) error { return nil } mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName, false) assert.Nil(t, err) assert.NotNil(t, actual) }) t.Run("should return error when fileBackend.WriteFile returns error", func(t *testing.T) { fileName := "temp-file-name.jpeg" mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedError := &TestError{} writeFileFunc := func(reader io.Reader, path string) int64 { paths := strings.Split(path, string(os.PathSeparator)) assert.Equal(t, "boards", paths[0]) assert.Equal(t, time.Now().Format("20060102"), paths[1]) assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1]) return int64(10) } writeFileErrorFunc := func(reader io.Reader, filePath string) error { return mockedError } mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName, false) assert.Equal(t, "", actual) assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error()) }) } func TestGetFileInfo(t *testing.T) { th, _ := SetupTestHelper(t) t.Run("should return file info", func(t *testing.T) { fileInfo := &mm_model.FileInfo{ Id: "file_info_id", Archived: false, } th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil).Times(2) fetchedFileInfo, err := th.App.GetFileInfo("Afilename") assert.NoError(t, err) assert.Equal(t, "file_info_id", fetchedFileInfo.Id) assert.False(t, fetchedFileInfo.Archived) fetchedFileInfo, err = th.App.GetFileInfo("Afilename.txt") assert.NoError(t, err) assert.Equal(t, "file_info_id", fetchedFileInfo.Id) assert.False(t, fetchedFileInfo.Archived) }) t.Run("should return archived file info", func(t *testing.T) { fileInfo := &mm_model.FileInfo{ Id: "file_info_id", Archived: true, } th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil) fetchedFileInfo, err := th.App.GetFileInfo("Afilename") assert.NoError(t, err) assert.Equal(t, "file_info_id", fetchedFileInfo.Id) assert.True(t, fetchedFileInfo.Archived) }) t.Run("should return archived file infoerror", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("filename").Return(nil, errDummy) fetchedFileInfo, err := th.App.GetFileInfo("Afilename") assert.Error(t, err) assert.Nil(t, fetchedFileInfo) }) } func TestGetFile(t *testing.T) { th, _ := SetupTestHelper(t) t.Run("happy path, no errors", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ Id: "fileInfoID", Path: testPath, }, nil) mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedReadCloseSeek := &mocks.ReadCloseSeeker{} readerFunc := func(path string) filestore.ReadCloseSeeker { return mockedReadCloseSeek } readerErrorFunc := func(path string) error { return nil } mockedFileBackend.On("Reader", testPath).Return(readerFunc, readerErrorFunc) mockedFileBackend.On("FileExists", testPath).Return(true, nil) fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") assert.NoError(t, err) assert.NotNil(t, fileInfo) assert.NotNil(t, seeker) }) t.Run("when GetFilePath() throws error", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, errDummy) fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") assert.Error(t, err) assert.Nil(t, fileInfo) assert.Nil(t, seeker) }) t.Run("when FileExists returns false", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ Id: "fileInfoID", Path: testPath, }, nil) mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedFileBackend.On("FileExists", testPath).Return(false, nil) fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") assert.Error(t, err) assert.Nil(t, fileInfo) assert.Nil(t, seeker) }) t.Run("when FileReader throws error", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ Id: "fileInfoID", Path: testPath, }, nil) mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedFileBackend.On("Reader", testPath).Return(nil, errDummy) mockedFileBackend.On("FileExists", testPath).Return(true, nil) fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") assert.Error(t, err) assert.Nil(t, fileInfo) assert.Nil(t, seeker) }) } func TestGetFilePath(t *testing.T) { th, _ := SetupTestHelper(t) t.Run("when FileInfo exists", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ Id: "fileInfoID", Path: testPath, }, nil) fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt") assert.NoError(t, err) assert.NotNil(t, fileInfo) assert.Equal(t, testPath, filePath) }) t.Run("when FileInfo doesn't exist", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil) fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt") assert.NoError(t, err) assert.Nil(t, fileInfo) assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath) }) t.Run("when FileInfo exists but FileInfo.Path is not set", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ Id: "fileInfoID", Path: "", }, nil) fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt") assert.NoError(t, err) assert.NotNil(t, fileInfo) assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath) }) } func TestCopyCard(t *testing.T) { th, _ := SetupTestHelper(t) imageBlock := &model.Block{ ID: "imageBlock", ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", Schema: 1, Type: "image", Title: "", Fields: map[string]interface{}{"fileId": "7fileName.jpg"}, CreateAt: 1680725585250, UpdateAt: 1680725585250, DeleteAt: 0, BoardID: "boardID", } t.Run("Board doesn't exist", func(t *testing.T) { th.Store.EXPECT().GetBoard("boardID").Return(nil, errDummy) _, err := th.App.CopyCardFiles("boardID", []*model.Block{}, false) assert.Error(t, err) }) t.Run("Board exists, image block, with FileInfo", func(t *testing.T) { fileInfo := &mm_model.FileInfo{ Id: "imageBlock", Path: testPath, } th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ ID: "boardID", IsTemplate: false, }, nil) th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil) th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil) mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false) assert.NoError(t, err) assert.Equal(t, "7fileName.jpg", imageBlock.Fields["fileId"]) assert.NotNil(t, updatedFileNames["7fileName.jpg"]) assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)]) }) t.Run("Board exists, attachment block, with FileInfo", func(t *testing.T) { attachmentBlock := &model.Block{ ID: "attachmentBlock", ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", Schema: 1, Type: "attachment", Title: "", Fields: map[string]interface{}{"fileId": "7fileName.jpg"}, CreateAt: 1680725585250, UpdateAt: 1680725585250, DeleteAt: 0, BoardID: "boardID", } fileInfo := &mm_model.FileInfo{ Id: "attachmentBlock", Path: testPath, } th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ ID: "boardID", IsTemplate: false, }, nil) th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil) th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil) mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{attachmentBlock}, false) assert.NoError(t, err) assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)]) }) t.Run("Board exists, image block, without FileInfo", func(t *testing.T) { th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ ID: "boardID", IsTemplate: false, }, nil) th.Store.EXPECT().GetFileInfo(gomock.Any()).Return(nil, nil) th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil) mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false) assert.NoError(t, err) assert.NotNil(t, imageBlock.Fields["fileId"].(string)) assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)]) }) } func TestCopyAndUpdateCardFiles(t *testing.T) { th, _ := SetupTestHelper(t) imageBlock := &model.Block{ ID: "imageBlock", ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", Schema: 1, Type: "image", Title: "", Fields: map[string]interface{}{"fileId": "7fileName.jpg"}, CreateAt: 1680725585250, UpdateAt: 1680725585250, DeleteAt: 0, BoardID: "boardID", } t.Run("Board exists, image block, with FileInfo", func(t *testing.T) { fileInfo := &mm_model.FileInfo{ Id: "imageBlock", Path: testPath, } th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ ID: "boardID", IsTemplate: false, }, nil) th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil) th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil) th.Store.EXPECT().PatchBlocks(gomock.Any(), "userID").Return(nil) mockedFileBackend := &mocks.FileBackend{} th.App.filesBackend = mockedFileBackend mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) err := th.App.CopyAndUpdateCardFiles("boardID", "userID", []*model.Block{imageBlock}, false) assert.NoError(t, err) assert.NotEqual(t, testPath, imageBlock.Fields["fileId"]) }) } ================================================ FILE: server/app/helper_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app import ( "testing" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/auth" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/metrics" "github.com/mattermost/focalboard/server/services/permissions/mmpermissions" mmpermissionsMocks "github.com/mattermost/focalboard/server/services/permissions/mmpermissions/mocks" permissionsMocks "github.com/mattermost/focalboard/server/services/permissions/mocks" "github.com/mattermost/focalboard/server/services/store/mockstore" "github.com/mattermost/focalboard/server/services/webhook" "github.com/mattermost/focalboard/server/ws" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks" ) type TestHelper struct { App *App Store *mockstore.MockStore FilesBackend *mocks.FileBackend logger mlog.LoggerIFace API *mmpermissionsMocks.MockAPI } func SetupTestHelper(t *testing.T) (*TestHelper, func()) { ctrl := gomock.NewController(t) cfg := config.Configuration{} store := mockstore.NewMockStore(ctrl) filesBackend := &mocks.FileBackend{} auth := auth.New(&cfg, store, nil) logger, _ := mlog.NewLogger() sessionToken := "TESTTOKEN" wsserver := ws.NewServer(auth, sessionToken, false, logger, store) webhook := webhook.NewClient(&cfg, logger) metricsService := metrics.NewMetrics(metrics.InstanceInfo{}) mockStore := permissionsMocks.NewMockStore(ctrl) mockAPI := mmpermissionsMocks.NewMockAPI(ctrl) permissions := mmpermissions.New(mockStore, mockAPI, mlog.CreateConsoleTestLogger(t)) appServices := Services{ Auth: auth, Store: store, FilesBackend: filesBackend, Webhook: webhook, Metrics: metricsService, Logger: logger, SkipTemplateInit: true, Permissions: permissions, } app2 := New(&cfg, wsserver, appServices) tearDown := func() { app2.Shutdown() if logger != nil { _ = logger.Shutdown() } } return &TestHelper{ App: app2, Store: store, FilesBackend: filesBackend, logger: logger, API: mockAPI, }, tearDown } ================================================ FILE: server/app/import.go ================================================ package app import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "io" "path" "path/filepath" "strings" "github.com/krolaw/zipstream" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( archiveVersion = 2 legacyFileBegin = "{\"version\":1" importMaxFileSize = 1024 * 1024 * 70 ) var ( errBlockIsNotABoard = errors.New("block is not a board") errSizeLimitExceeded = errors.New("size limit exceeded") ) // ImportArchive imports an archive containing zero or more boards, plus all // associated content, including cards, content blocks, views, and images. // // Archives are ZIP files containing a `version.json` file and zero or more // directories, each containing a `board.jsonl` and zero or more image files. func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error { // peek at the first bytes to see if this is a legacy archive format br := bufio.NewReader(r) peek, err := br.Peek(len(legacyFileBegin)) if err == nil && string(peek) == legacyFileBegin { a.logger.Debug("importing legacy archive") _, errImport := a.ImportBoardJSONL(br, opt) return errImport } zr := zipstream.NewReader(br) boardMap := make(map[string]*model.Board) // maps old board ids to new fileMap := make(map[string]string) // maps old fileIds to new for { hdr, err := zr.Next() if err != nil { if errors.Is(err, io.EOF) { a.fixImagesAttachments(boardMap, fileMap, opt.TeamID, opt.ModifiedBy) a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap))) return nil } return err } dir, filename := filepath.Split(hdr.Name) dir = path.Clean(dir) switch filename { case "version.json": ver, errVer := parseVersionFile(zr) if errVer != nil { return errVer } if ver != archiveVersion { return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion) } case "board.jsonl": board, err := a.ImportBoardJSONL(zr, opt) if err != nil { return fmt.Errorf("cannot import board %s: %w", dir, err) } boardMap[dir] = board default: // import file/image; dir is the old board id board, ok := boardMap[dir] if !ok { a.logger.Warn("skipping orphan image in archive", mlog.String("dir", dir), mlog.String("filename", filename), ) continue } newFileName, err := a.SaveFile(zr, opt.TeamID, board.ID, filename, board.IsTemplate) if err != nil { return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err) } fileMap[filename] = newFileName a.logger.Debug("import archive file", mlog.String("TeamID", opt.TeamID), mlog.String("boardID", board.ID), mlog.String("filename", filename), mlog.String("newFileName", newFileName), ) } } } // Update image and attachment blocks. func (a *App) fixImagesAttachments(boardMap map[string]*model.Board, fileMap map[string]string, teamID string, userID string) { blockIDs := make([]string, 0) blockPatches := make([]model.BlockPatch, 0) for _, board := range boardMap { if board.IsTemplate { continue } opts := model.QueryBlocksOptions{ BoardID: board.ID, } newBlocks, err := a.store.GetBlocks(opts) if err != nil { a.logger.Info("cannot retrieve imported blocks for board", mlog.String("BoardID", board.ID), mlog.Err(err)) return } for _, block := range newBlocks { if block.Type == "image" || block.Type == "attachment" { fieldName := "fileId" oldID := block.Fields[fieldName] blockIDs = append(blockIDs, block.ID) blockPatches = append(blockPatches, model.BlockPatch{ UpdatedFields: map[string]interface{}{ fieldName: fileMap[oldID.(string)], }, }) } } blockPatchBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches} err = a.PatchBlocks(teamID, &blockPatchBatch, userID) if err != nil { a.logger.Info("Error patching blocks for image import", mlog.Err(err)) } } } // ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting // board id is returned. func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (*model.Board, error) { // TODO: Stream this once `model.GenerateBlockIDs` can take a stream of blocks. // We don't want to load the whole file in memory, even though it's a single board. boardsAndBlocks := &model.BoardsAndBlocks{ Blocks: make([]*model.Block, 0, 10), Boards: make([]*model.Board, 0, 10), } lineReader := &io.LimitedReader{R: r, N: importMaxFileSize + 1} scanner := bufio.NewScanner(lineReader) userID := opt.ModifiedBy if userID == model.SingleUser { userID = "" } now := utils.GetMillis() var boardID string var boardMembers []*model.BoardMember lineNum := 1 firstLine := true for scanner.Scan() { if lineReader.N <= 0 { return nil, fmt.Errorf("error parsing archive line %d: %w", lineNum, errSizeLimitExceeded) } line := bytes.TrimSpace(scanner.Bytes()) if len(line) != 0 { var skip bool if firstLine { // first line might be a header tag (old archive format) if strings.HasPrefix(string(line), legacyFileBegin) { skip = true } } if !skip { var archiveLine model.ArchiveLine if err := json.Unmarshal(line, &archiveLine); err != nil { return nil, fmt.Errorf("error parsing archive line %d: %w", lineNum, err) } // first line must be a board if firstLine && archiveLine.Type == "block" { archiveLine.Type = "board_block" } switch archiveLine.Type { case "board": var board model.Board if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil { return nil, fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2) } board.ModifiedBy = userID board.UpdateAt = now board.TeamID = opt.TeamID boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, &board) boardID = board.ID case "board_block": // legacy archives encoded boards as blocks; we need to convert them to real boards. var block *model.Block if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil { return nil, fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2) } block.ModifiedBy = userID block.UpdateAt = now board, err := a.blockToBoard(block, opt) if err != nil { return nil, fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err) } boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board) boardID = board.ID case "block": var block *model.Block if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil { return nil, fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2) } block.ModifiedBy = userID block.UpdateAt = now block.BoardID = boardID boardsAndBlocks.Blocks = append(boardsAndBlocks.Blocks, block) case "boardMember": var boardMember *model.BoardMember if err2 := json.Unmarshal(archiveLine.Data, &boardMember); err2 != nil { return nil, fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2) } boardMembers = append(boardMembers, boardMember) default: return nil, model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type) } firstLine = false } } } if errRead := scanner.Err(); errRead != nil { return nil, fmt.Errorf("error reading archive line %d: %w", lineNum, errRead) } // loop to remove the people how are not part of the team and system for i := len(boardMembers) - 1; i >= 0; i-- { if _, err := a.GetUser(boardMembers[i].UserID); err != nil { boardMembers = append(boardMembers[:i], boardMembers[i+1:]...) } } a.fixBoardsandBlocks(boardsAndBlocks, opt) var err error boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger) if err != nil { return nil, fmt.Errorf("error generating archive block IDs: %w", err) } boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false) if err != nil { return nil, fmt.Errorf("error inserting archive blocks: %w", err) } // add users to all the new boards (if not the fake system user). for _, board := range boardsAndBlocks.Boards { // make sure an admin user gets added adminMember := &model.BoardMember{ BoardID: board.ID, UserID: opt.ModifiedBy, SchemeAdmin: true, } if _, err2 := a.AddMemberToBoard(adminMember); err2 != nil { return nil, fmt.Errorf("cannot add adminMember to board: %w", err2) } for _, boardMember := range boardMembers { bm := &model.BoardMember{ BoardID: board.ID, UserID: boardMember.UserID, Roles: boardMember.Roles, MinimumRole: boardMember.MinimumRole, SchemeAdmin: boardMember.SchemeAdmin, SchemeEditor: boardMember.SchemeEditor, SchemeCommenter: boardMember.SchemeCommenter, SchemeViewer: boardMember.SchemeViewer, Synthetic: boardMember.Synthetic, } if _, err2 := a.AddMemberToBoard(bm); err2 != nil { return nil, fmt.Errorf("cannot add member to board: %w", err2) } } } // find new board id for _, board := range boardsAndBlocks.Boards { return board, nil } return nil, fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock) } // fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being // imported via callbacks. func (a *App) fixBoardsandBlocks(boardsAndBlocks *model.BoardsAndBlocks, opt model.ImportArchiveOptions) { if opt.BlockModifier == nil && opt.BoardModifier == nil { return } modInfoCache := make(map[string]interface{}) modBoards := make([]*model.Board, 0, len(boardsAndBlocks.Boards)) modBlocks := make([]*model.Block, 0, len(boardsAndBlocks.Blocks)) for _, board := range boardsAndBlocks.Boards { b := *board if opt.BoardModifier != nil && !opt.BoardModifier(&b, modInfoCache) { a.logger.Debug("skipping insert board per board modifier", mlog.String("boardID", board.ID), ) continue } modBoards = append(modBoards, &b) } for _, block := range boardsAndBlocks.Blocks { b := block if opt.BlockModifier != nil && !opt.BlockModifier(b, modInfoCache) { a.logger.Debug("skipping insert block per block modifier", mlog.String("blockID", block.ID), ) continue } modBlocks = append(modBlocks, b) } boardsAndBlocks.Boards = modBoards boardsAndBlocks.Blocks = modBlocks } // blockToBoard converts a `model.Block` to `model.Board`. Legacy archive formats encode boards as blocks // and need conversion during import. func (a *App) blockToBoard(block *model.Block, opt model.ImportArchiveOptions) (*model.Board, error) { if block.Type != model.TypeBoard { return nil, errBlockIsNotABoard } board := &model.Board{ ID: block.ID, TeamID: opt.TeamID, CreatedBy: block.CreatedBy, ModifiedBy: block.ModifiedBy, Type: model.BoardTypePrivate, Title: block.Title, CreateAt: block.CreateAt, UpdateAt: block.UpdateAt, DeleteAt: block.DeleteAt, Properties: make(map[string]interface{}), CardProperties: make([]map[string]interface{}, 0), } if icon, ok := stringValue(block.Fields, "icon"); ok { board.Icon = icon } if description, ok := stringValue(block.Fields, "description"); ok { board.Description = description } if showDescription, ok := boolValue(block.Fields, "showDescription"); ok { board.ShowDescription = showDescription } if isTemplate, ok := boolValue(block.Fields, "isTemplate"); ok { board.IsTemplate = isTemplate } if templateVer, ok := intValue(block.Fields, "templateVer"); ok { board.TemplateVersion = templateVer } if properties, ok := mapValue(block.Fields, "properties"); ok { board.Properties = properties } if cardProperties, ok := arrayMapsValue(block.Fields, "cardProperties"); ok { board.CardProperties = cardProperties } return board, nil } func stringValue(m map[string]interface{}, key string) (string, bool) { v, ok := m[key] if !ok { return "", false } s, ok := v.(string) if !ok { return "", false } return s, true } func boolValue(m map[string]interface{}, key string) (bool, bool) { v, ok := m[key] if !ok { return false, false } b, ok := v.(bool) if !ok { return false, false } return b, true } func intValue(m map[string]interface{}, key string) (int, bool) { v, ok := m[key] if !ok { return 0, false } i, ok := v.(int) if !ok { return 0, false } return i, true } func mapValue(m map[string]interface{}, key string) (map[string]interface{}, bool) { v, ok := m[key] if !ok { return nil, false } mm, ok := v.(map[string]interface{}) if !ok { return nil, false } return mm, true } func arrayMapsValue(m map[string]interface{}, key string) ([]map[string]interface{}, bool) { v, ok := m[key] if !ok { return nil, false } ai, ok := v.([]interface{}) if !ok { return nil, false } arr := make([]map[string]interface{}, 0, len(ai)) for _, mi := range ai { mm, ok := mi.(map[string]interface{}) if !ok { return nil, false } arr = append(arr, mm) } return arr, true } func parseVersionFile(r io.Reader) (int, error) { file, err := io.ReadAll(r) if err != nil { return 0, fmt.Errorf("cannot read version.json: %w", err) } var header model.ArchiveHeader if err := json.Unmarshal(file, &header); err != nil { return 0, fmt.Errorf("cannot parse version.json: %w", err) } return header.Version, nil } ================================================ FILE: server/app/import_test.go ================================================ package app import ( "bytes" "testing" "github.com/mattermost/focalboard/server/utils" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/require" ) func TestApp_ImportArchive(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() board := &model.Board{ ID: "d14b9df9-1f31-4732-8a64-92bc7162cd28", TeamID: "test-team", Title: "Cross-Functional Project Plan", } block := &model.Block{ ID: "2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e", ParentID: board.ID, Type: model.TypeView, BoardID: board.ID, } babs := &model.BoardsAndBlocks{ Boards: []*model.Board{board}, Blocks: []*model.Block{block}, } boardMember := &model.BoardMember{ BoardID: board.ID, UserID: "user", } t.Run("import asana archive", func(t *testing.T) { r := bytes.NewReader([]byte(asana)) opts := model.ImportArchiveOptions{ TeamID: "test-team", ModifiedBy: "user", } th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "user").Return(babs, nil) th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil) th.Store.EXPECT().GetBoard(board.ID).Return(board, nil) th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil) th.Store.EXPECT().GetUserCategoryBoards("user", "test-team").Return([]model.CategoryBoards{ { Category: model.Category{ Type: "default", Name: "Boards", ID: "boards_category_id", }, }, }, nil) th.Store.EXPECT().GetUserCategoryBoards("user", "test-team") th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Name: "Boards", }, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil) th.Store.EXPECT().GetMembersForUser("user").Return([]*model.BoardMember{}, nil) th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything, utils.Anything).Return(nil) err := th.App.ImportArchive(r, opts) require.NoError(t, err, "import archive should not fail") }) t.Run("import board archive", func(t *testing.T) { r := bytes.NewReader([]byte(boardArchive)) opts := model.ImportArchiveOptions{ TeamID: "test-team", ModifiedBy: "f1tydgc697fcbp8ampr6881jea", } bm1 := &model.BoardMember{ BoardID: board.ID, UserID: "f1tydgc697fcbp8ampr6881jea", } bm2 := &model.BoardMember{ BoardID: board.ID, UserID: "hxxzooc3ff8cubsgtcmpn8733e", } bm3 := &model.BoardMember{ BoardID: board.ID, UserID: "nto73edn5ir6ifimo5a53y1dwa", } user1 := &model.User{ ID: "f1tydgc697fcbp8ampr6881jea", } user2 := &model.User{ ID: "hxxzooc3ff8cubsgtcmpn8733e", } user3 := &model.User{ ID: "nto73edn5ir6ifimo5a53y1dwa", } th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "f1tydgc697fcbp8ampr6881jea").Return(babs, nil) th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{bm1, bm2, bm3}, nil) th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{}, nil) th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{ { Category: model.Category{ ID: "boards_category_id", Name: "Boards", Type: model.CategoryTypeSystem, }, }, }, nil) th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category_id", Name: "Boards", }, nil) th.Store.EXPECT().GetMembersForUser("f1tydgc697fcbp8ampr6881jea").Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("f1tydgc697fcbp8ampr6881jea", "test-team", false).Return([]*model.Board{}, nil) th.Store.EXPECT().AddUpdateCategoryBoard("f1tydgc697fcbp8ampr6881jea", utils.Anything, utils.Anything).Return(nil) th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil) th.Store.EXPECT().GetMemberForBoard(board.ID, "f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(bm1, nil) th.Store.EXPECT().GetMemberForBoard(board.ID, "hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(bm2, nil) th.Store.EXPECT().GetMemberForBoard(board.ID, "nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(bm3, nil) th.Store.EXPECT().GetUserByID("f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(user1, nil) th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil) th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil) newBoard, err := th.App.ImportBoardJSONL(r, opts) require.NoError(t, err, "import archive should not fail") require.Equal(t, board.ID, newBoard.ID, "Board ID should be same") }) t.Run("fix image and attachment", func(t *testing.T) { boardMap := map[string]*model.Board{ "test": board, } fileMap := map[string]string{ "oldFileName1.jpg": "newFileName1.jpg", "oldFileName2.jpg": "newFileName2.jpg", } imageBlock := &model.Block{ ID: "blockID-1", ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", Schema: 1, Type: "image", Title: "", Fields: map[string]interface{}{"fileId": "oldFileName1.jpg"}, CreateAt: 1680725585250, UpdateAt: 1680725585250, DeleteAt: 0, BoardID: "board-id", } attachmentBlock := &model.Block{ ID: "blockID-2", ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", Schema: 1, Type: "attachment", Title: "", Fields: map[string]interface{}{"fileId": "oldFileName2.jpg"}, CreateAt: 1680725585250, UpdateAt: 1680725585250, DeleteAt: 0, BoardID: "board-id", } blockIDs := []string{"blockID-1", "blockID-2"} blockPatch := model.BlockPatch{ UpdatedFields: map[string]interface{}{"fileId": "newFileName1.jpg"}, } blockPatch2 := model.BlockPatch{ UpdatedFields: map[string]interface{}{"fileId": "newFileName2.jpg"}, } blockPatches := []model.BlockPatch{blockPatch, blockPatch2} blockPatchesBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches} opts := model.QueryBlocksOptions{ BoardID: board.ID, } th.Store.EXPECT().GetBlocks(opts).Return([]*model.Block{imageBlock, attachmentBlock}, nil) th.Store.EXPECT().GetBlocksByIDs(blockIDs).Return([]*model.Block{imageBlock, attachmentBlock}, nil) th.Store.EXPECT().GetBlock(blockIDs[0]).Return(imageBlock, nil) th.Store.EXPECT().GetBlock(blockIDs[1]).Return(attachmentBlock, nil) th.Store.EXPECT().GetMembersForBoard("board-id").AnyTimes().Return([]*model.BoardMember{}, nil) th.Store.EXPECT().PatchBlocks(&blockPatchesBatch, "my-userid") th.App.fixImagesAttachments(boardMap, fileMap, "test-team", "my-userid") }) } //nolint:lll const asana = `{"version":1,"date":1614714686842} {"type":"block","data":{"id":"d14b9df9-1f31-4732-8a64-92bc7162cd28","fields":{"icon":"","description":"","cardProperties":[{"id":"3bdcbaeb-bc78-4884-8531-a0323b74676a","name":"Section","type":"select","options":[{"id":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732","value":"Planning","color":"propColorGray"},{"id":"454559bb-b788-4ff6-873e-04def8491d2c","value":"Milestones","color":"propColorBrown"},{"id":"deaab476-c690-48df-828f-725b064dc476","value":"Next steps","color":"propColorOrange"},{"id":"2138305a-3157-461c-8bbe-f19ebb55846d","value":"Comms Plan","color":"propColorYellow"}]}]},"createAt":1614714686836,"updateAt":1614714686836,"deleteAt":0,"schema":1,"parentId":"","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"board","title":"Cross-Functional Project Plan"}} {"type":"block","data":{"id":"2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e","fields":{"sortOptions":[],"visiblePropertyIds":[],"visibleOptionIds":[],"hiddenOptionIds":[],"filter":{"operation":"and","filters":[]},"cardOrder":[],"columnWidths":{},"viewType":"board"},"createAt":1614714686840,"updateAt":1614714686840,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"view","title":"Board View"}} {"type":"block","data":{"id":"520c332b-adf5-4a32-88ab-43655c8b6aa2","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["deb3966c-6d56-43b1-8e95-36806877ce81"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[READ ME] - Instructions for using this template"}} {"type":"block","data":{"id":"deb3966c-6d56-43b1-8e95-36806877ce81","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"520c332b-adf5-4a32-88ab-43655c8b6aa2","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"This project template is set up in List View with sections and Asana-created Custom Fields to help you track your team's work. We've provided some example content in this template to get you started, but you should add tasks, change task names, add more Custom Fields, and change any other info to make this project your own.\n\nSend feedback about this template: https://asa.na/templatesfeedback"}} {"type":"block","data":{"id":"be791f66-a5e5-4408-82f6-cb1280f5bc45","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["2688b31f-e7ff-4de1-87ae-d4b5570f8712"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"Redesign the landing page of our website"}} {"type":"block","data":{"id":"2688b31f-e7ff-4de1-87ae-d4b5570f8712","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"be791f66-a5e5-4408-82f6-cb1280f5bc45","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"Redesign the landing page to focus on the main persona."}} {"type":"block","data":{"id":"98f74948-1700-4a3c-8cc2-8bb632499def","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Consider trying a new email marketing service"}} {"type":"block","data":{"id":"142fba5d-05e6-4865-83d9-b3f54d9de96e","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Budget finalization"}} {"type":"block","data":{"id":"ca6670b1-b034-4e42-8971-c659b478b9e0","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Find a venue for the holiday party"}} {"type":"block","data":{"id":"db1dd596-0999-4741-8b05-72ca8e438e31","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Approve campaign copy"}} {"type":"block","data":{"id":"16861c05-f31f-46af-8429-80a87b5aa93a","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"2138305a-3157-461c-8bbe-f19ebb55846d"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Send out updated attendee list"}} ` //nolint:lll const boardArchive = `{"type":"board","data":{"id":"bfoi6yy6pa3yzika53spj7pq9ee","teamId":"wsmqbtwb5jb35jb3mtp85c8a9h","channelId":"","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","type":"P","minimumRole":"","title":"Custom","description":"","icon":"","showDescription":false,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"aonihehbifijmx56aqzu3cc7w1r","name":"Status","options":[],"type":"select"},{"id":"aohjkzt769rxhtcz1o9xcoce5to","name":"Person","options":[],"type":"person"}],"createAt":1672750481591,"updateAt":1672750481591,"deleteAt":0}} {"type":"block","data":{"id":"ckpc3b1dp3pbw7bqntfryy9jbzo","parentId":"bjaqxtbyqz3bu7pgyddpgpms74a","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"card","title":"Test","fields":{"contentOrder":[],"icon":"","isTemplate":false,"properties":{"aohjkzt769rxhtcz1o9xcoce5to":"hxxzooc3ff8cubsgtcmpn8733e"}},"createAt":1672750481612,"updateAt":1672845003530,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}} {"type":"block","data":{"id":"v7tdajwpm47r3u8duedk89bhxar","parentId":"bpypang3a3errqstj1agx9kuqay","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"view","title":"Board view","fields":{"cardOrder":["crsyw7tbr3pnjznok6ppngmmyya","c5titiemp4pgaxbs4jksgybbj4y"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":[],"visiblePropertyIds":["aohjkzt769rxhtcz1o9xcoce5to"]},"createAt":1672750481626,"updateAt":1672750481626,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}} {"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"f1tydgc697fcbp8ampr6881jea","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}} {"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"hxxzooc3ff8cubsgtcmpn8733e","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}} {"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"nto73edn5ir6ifimo5a53y1dwa","roles":"","minimumRole":"","schemeAdmin":true,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":false,"synthetic":false}} ` ================================================ FILE: server/app/initialize.go ================================================ package app import ( "context" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // initialize is called when the App is first created. func (a *App) initialize(skipTemplateInit bool) { if !skipTemplateInit { if err := a.InitTemplates(); err != nil { a.logger.Error(`InitializeTemplates failed`, mlog.Err(err)) } } } func (a *App) Shutdown() { if a.blockChangeNotifier != nil { ctx, cancel := context.WithTimeout(context.Background(), blockChangeNotifierShutdownTimeout) defer cancel() if !a.blockChangeNotifier.Shutdown(ctx) { a.logger.Warn("blockChangeNotifier shutdown timed out") } } } ================================================ FILE: server/app/onboarding.go ================================================ package app import ( "errors" "github.com/mattermost/focalboard/server/model" ) const ( KeyOnboardingTourStarted = "onboardingTourStarted" KeyOnboardingTourCategory = "tourCategory" KeyOnboardingTourStep = "onboardingTourStep" ValueOnboardingFirstStep = "0" ValueTourCategoryOnboarding = "onboarding" WelcomeBoardTitle = "Welcome to Boards!" ) var ( errUnableToFindWelcomeBoard = errors.New("unable to find welcome board in newly created blocks") errCannotCreateBoard = errors.New("new board wasn't created") ) func (a *App) PrepareOnboardingTour(userID string, teamID string) (string, string, error) { // copy the welcome board into this workspace boardID, err := a.createWelcomeBoard(userID, teamID) if err != nil { return "", "", err } // set user's tour state to initial state userPreferencesPatch := model.UserPreferencesPatch{ UpdatedFields: map[string]string{ KeyOnboardingTourStarted: "1", KeyOnboardingTourStep: ValueOnboardingFirstStep, KeyOnboardingTourCategory: ValueTourCategoryOnboarding, }, } if _, err := a.store.PatchUserPreferences(userID, userPreferencesPatch); err != nil { return "", "", err } return teamID, boardID, nil } func (a *App) getOnboardingBoardID() (string, error) { boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "") if err != nil { return "", err } var onboardingBoardID string for _, block := range boards { if block.Title == WelcomeBoardTitle && block.TeamID == model.GlobalTeamID { onboardingBoardID = block.ID break } } if onboardingBoardID == "" { return "", errUnableToFindWelcomeBoard } return onboardingBoardID, nil } func (a *App) createWelcomeBoard(userID, teamID string) (string, error) { onboardingBoardID, err := a.getOnboardingBoardID() if err != nil { return "", err } bab, _, err := a.DuplicateBoard(onboardingBoardID, userID, teamID, false) if err != nil { return "", err } if len(bab.Boards) != 1 { return "", errCannotCreateBoard } // need variable for this to // get reference for board patch newType := model.BoardTypePrivate patch := &model.BoardPatch{ Type: &newType, } if _, err := a.PatchBoard(patch, bab.Boards[0].ID, userID); err != nil { return "", err } return bab.Boards[0].ID, nil } ================================================ FILE: server/app/onboarding_test.go ================================================ package app import ( "testing" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/assert" ) const ( testTeamID = "team_id" ) func TestPrepareOnboardingTour(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { teamID := testTeamID userID := "user_id_1" welcomeBoard := model.Board{ ID: "board_id_1", Title: "Welcome to Boards!", TeamID: "0", IsTemplate: true, } th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{ { ID: "board_id_2", Title: "Welcome to Boards!", TeamID: "0", IsTemplate: true, }, }}, nil, nil) th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2) th.Store.EXPECT().GetMembersForBoard("board_id_2").Return([]*model.BoardMember{}, nil).Times(1) th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).Times(2) th.Store.EXPECT().GetBoard("board_id_2").Return(&welcomeBoard, nil).Times(1) th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil) privateWelcomeBoard := model.Board{ ID: "board_id_1", Title: "Welcome to Boards!", TeamID: "0", IsTemplate: true, Type: model.BoardTypePrivate, } newType := model.BoardTypePrivate th.Store.EXPECT().PatchBoard("board_id_2", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil) th.Store.EXPECT().GetMembersForUser("user_id_1").Return([]*model.BoardMember{}, nil) userPreferencesPatch := model.UserPreferencesPatch{ UpdatedFields: map[string]string{ KeyOnboardingTourStarted: "1", KeyOnboardingTourStep: ValueOnboardingFirstStep, KeyOnboardingTourCategory: ValueTourCategoryOnboarding, }, } th.Store.EXPECT().PatchUserPreferences(userID, userPreferencesPatch).Return(nil, nil) th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{}, nil).Times(1) // when this is called the second time, the default category is created so we need to include that in the response list th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ID: "boards_category_id", Name: "Boards"}, }, }, nil).Times(2) th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil).Times(1) th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ ID: "boards_category", Name: "Boards", }, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil) th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_2"}).Return(nil) teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID) assert.NoError(t, err) assert.Equal(t, testTeamID, teamID) assert.NotEmpty(t, boardID) }) } func TestCreateWelcomeBoard(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { teamID := testTeamID userID := "user_id_1" welcomeBoard := model.Board{ ID: "board_id_1", Title: "Welcome to Boards!", TeamID: "0", IsTemplate: true, } th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false). Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil) th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(3) th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).AnyTimes() th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil) privateWelcomeBoard := model.Board{ ID: "board_id_1", Title: "Welcome to Boards!", TeamID: "0", IsTemplate: true, Type: model.BoardTypePrivate, } newType := model.BoardTypePrivate th.Store.EXPECT().PatchBoard("board_id_1", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil) th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{ { Category: model.Category{ID: "boards_category_id", Name: "Boards"}, }, }, nil).Times(3) th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_1"}).Return(nil) boardID, err := th.App.createWelcomeBoard(userID, teamID) assert.Nil(t, err) assert.NotEmpty(t, boardID) }) t.Run("template doesn't contain a board", func(t *testing.T) { teamID := testTeamID th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil) boardID, err := th.App.createWelcomeBoard("user_id_1", teamID) assert.Error(t, err) assert.Empty(t, boardID) }) t.Run("template doesn't contain the welcome board", func(t *testing.T) { teamID := testTeamID welcomeBoard := model.Board{ ID: "board_id_1", Title: "Other template", TeamID: teamID, IsTemplate: true, } th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1") assert.Error(t, err) assert.Empty(t, boardID) }) } func TestGetOnboardingBoardID(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("base case", func(t *testing.T) { welcomeBoard := model.Board{ ID: "board_id_1", Title: "Welcome to Boards!", TeamID: "0", IsTemplate: true, } th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) onboardingBoardID, err := th.App.getOnboardingBoardID() assert.NoError(t, err) assert.Equal(t, "board_id_1", onboardingBoardID) }) t.Run("no blocks found", func(t *testing.T) { th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil) onboardingBoardID, err := th.App.getOnboardingBoardID() assert.Error(t, err) assert.Empty(t, onboardingBoardID) }) t.Run("onboarding board doesn't exists", func(t *testing.T) { welcomeBoard := model.Board{ ID: "board_id_1", Title: "Other template", TeamID: "0", IsTemplate: true, } th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) onboardingBoardID, err := th.App.getOnboardingBoardID() assert.Error(t, err) assert.Empty(t, onboardingBoardID) }) } ================================================ FILE: server/app/permissions.go ================================================ package app import ( mm_model "github.com/mattermost/mattermost/server/public/model" ) func (a *App) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool { return a.permissions.HasPermissionToBoard(userID, boardID, permission) } ================================================ FILE: server/app/server_metadata.go ================================================ package app import ( "runtime" "github.com/mattermost/focalboard/server/model" ) type ServerMetadata struct { Version string `json:"version"` BuildNumber string `json:"build_number"` BuildDate string `json:"build_date"` Commit string `json:"commit"` Edition string `json:"edition"` DBType string `json:"db_type"` DBVersion string `json:"db_version"` OSType string `json:"os_type"` OSArch string `json:"os_arch"` SKU string `json:"sku"` } func (a *App) GetServerMetadata() *ServerMetadata { var dbType string var dbVersion string if a != nil && a.store != nil { dbType = a.store.DBType() dbVersion = a.store.DBVersion() } return &ServerMetadata{ Version: model.CurrentVersion, BuildNumber: model.BuildNumber, BuildDate: model.BuildDate, Commit: model.BuildHash, Edition: model.Edition, DBType: dbType, DBVersion: dbVersion, OSType: runtime.GOOS, OSArch: runtime.GOARCH, SKU: "personal_server", } } ================================================ FILE: server/app/server_metadata_test.go ================================================ package app import ( "reflect" "runtime" "testing" "github.com/mattermost/focalboard/server/model" ) func TestGetServerMetadata(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.Store.EXPECT().DBType().Return("TEST_DB_TYPE") th.Store.EXPECT().DBVersion().Return("TEST_DB_VERSION") t.Run("Get Server Metadata", func(t *testing.T) { got := th.App.GetServerMetadata() want := &ServerMetadata{ Version: model.CurrentVersion, BuildNumber: model.BuildNumber, BuildDate: model.BuildDate, Commit: model.BuildHash, Edition: model.Edition, DBType: "TEST_DB_TYPE", DBVersion: "TEST_DB_VERSION", OSType: runtime.GOOS, OSArch: runtime.GOARCH, SKU: "personal_server", } if !reflect.DeepEqual(got, want) { t.Errorf("got: %q, want: %q", got, want) } }) } ================================================ FILE: server/app/sharing.go ================================================ package app import ( "github.com/mattermost/focalboard/server/model" ) func (a *App) GetSharing(boardID string) (*model.Sharing, error) { sharing, err := a.store.GetSharing(boardID) if err != nil { return nil, err } return sharing, nil } func (a *App) UpsertSharing(sharing model.Sharing) error { return a.store.UpsertSharing(sharing) } ================================================ FILE: server/app/sharing_test.go ================================================ package app import ( "database/sql" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/pkg/errors" "github.com/stretchr/testify/require" ) func TestGetSharing(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() t.Run("should get a sharing successfully", func(t *testing.T) { want := &model.Sharing{ ID: utils.NewID(utils.IDTypeBlock), Enabled: true, Token: "token", ModifiedBy: "otherid", UpdateAt: utils.GetMillis(), } th.Store.EXPECT().GetSharing("test-id").Return(want, nil) result, err := th.App.GetSharing("test-id") require.NoError(t, err) require.Equal(t, result, want) require.NotNil(t, th.App) }) t.Run("should fail to get a sharing", func(t *testing.T) { th.Store.EXPECT().GetSharing("test-id").Return( nil, errors.New("sharing not found"), ) result, err := th.App.GetSharing("test-id") require.Nil(t, result) require.Error(t, err) require.Equal(t, "sharing not found", err.Error()) }) t.Run("should return a not found error", func(t *testing.T) { th.Store.EXPECT().GetSharing("test-id").Return( nil, sql.ErrNoRows, ) result, err := th.App.GetSharing("test-id") require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, result) }) } func TestUpsertSharing(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() sharing := model.Sharing{ ID: utils.NewID(utils.IDTypeBlock), Enabled: true, Token: "token", ModifiedBy: "otherid", UpdateAt: utils.GetMillis(), } t.Run("should success to upsert sharing", func(t *testing.T) { th.Store.EXPECT().UpsertSharing(sharing).Return(nil) err := th.App.UpsertSharing(sharing) require.NoError(t, err) }) t.Run("should fail to upsert a sharing", func(t *testing.T) { th.Store.EXPECT().UpsertSharing(sharing).Return(errors.New("sharing not found")) err := th.App.UpsertSharing(sharing) require.Error(t, err) require.Equal(t, "sharing not found", err.Error()) }) } ================================================ FILE: server/app/statistics.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app func (a *App) GetUsedCardsCount() (int, error) { return a.store.GetUsedCardsCount() } ================================================ FILE: server/app/subscriptions.go ================================================ package app import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *App) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) { sub, err := a.store.CreateSubscription(sub) if err != nil { return nil, err } a.notifySubscriptionChanged(sub) return sub, nil } func (a *App) DeleteSubscription(blockID string, subscriberID string) (*model.Subscription, error) { sub, err := a.store.GetSubscription(blockID, subscriberID) if err != nil { return nil, err } if err := a.store.DeleteSubscription(blockID, subscriberID); err != nil { return nil, err } sub.DeleteAt = utils.GetMillis() a.notifySubscriptionChanged(sub) return sub, nil } func (a *App) GetSubscriptions(subscriberID string) ([]*model.Subscription, error) { return a.store.GetSubscriptions(subscriberID) } func (a *App) notifySubscriptionChanged(subscription *model.Subscription) { if a.notifications == nil { return } board, err := a.getBoardForBlock(subscription.BlockID) if err != nil { a.logger.Error("Error notifying subscription change", mlog.String("subscriber_id", subscription.SubscriberID), mlog.String("block_id", subscription.BlockID), mlog.Err(err), ) } a.wsAdapter.BroadcastSubscriptionChange(board.TeamID, subscription) } ================================================ FILE: server/app/teams.go ================================================ package app import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (a *App) GetRootTeam() (*model.Team, error) { teamID := "0" team, _ := a.store.GetTeam(teamID) if team == nil { team = &model.Team{ ID: teamID, SignupToken: utils.NewID(utils.IDTypeToken), } err := a.store.UpsertTeamSignupToken(*team) if err != nil { a.logger.Error("Unable to initialize team", mlog.Err(err)) return nil, err } team, err = a.store.GetTeam(teamID) if err != nil { a.logger.Error("Unable to get initialized team", mlog.Err(err)) return nil, err } a.logger.Info("initialized team") } return team, nil } func (a *App) GetTeam(id string) (*model.Team, error) { team, err := a.store.GetTeam(id) if model.IsErrNotFound(err) { return nil, nil } if err != nil { return nil, err } return team, nil } func (a *App) GetTeamsForUser(userID string) ([]*model.Team, error) { return a.store.GetTeamsForUser(userID) } func (a *App) DoesUserHaveTeamAccess(userID string, teamID string) bool { return a.auth.DoesUserHaveTeamAccess(userID, teamID) } func (a *App) UpsertTeamSettings(team model.Team) error { return a.store.UpsertTeamSettings(team) } func (a *App) UpsertTeamSignupToken(team model.Team) error { return a.store.UpsertTeamSignupToken(team) } func (a *App) GetTeamCount() (int64, error) { return a.store.GetTeamCount() } ================================================ FILE: server/app/teams_test.go ================================================ package app import ( "database/sql" "errors" "testing" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var errInvalidTeam = errors.New("invalid team id") var mockTeam = &model.Team{ ID: "mock-team-id", Title: "MockTeam", } var errUpsertSignupToken = errors.New("upsert error") func TestGetRootTeam(t *testing.T) { var newRootTeam = &model.Team{ ID: "0", Title: "NewRootTeam", } testCases := []struct { title string teamToReturnBeforeUpsert *model.Team teamToReturnAfterUpsert *model.Team isError bool }{ { "Success, Return new root team, when root team returned by mockstore is nil", nil, newRootTeam, false, }, { "Success, Return existing root team, when root team returned by mockstore is notnil", newRootTeam, nil, false, }, { "Fail, Return nil, when root team returned by mockstore is nil, and upsert new root team fails", nil, nil, true, }, } for _, tc := range testCases { t.Run(tc.title, func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnBeforeUpsert, nil) if tc.teamToReturnBeforeUpsert == nil { th.Store.EXPECT().UpsertTeamSignupToken(gomock.Any()).DoAndReturn( func(arg0 model.Team) error { if tc.isError { return errUpsertSignupToken } th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnAfterUpsert, nil) return nil }) } rootTeam, err := th.App.GetRootTeam() if tc.isError { require.Error(t, err) } else { assert.NotNil(t, rootTeam.ID) assert.NotNil(t, rootTeam.SignupToken) assert.Equal(t, "", rootTeam.ModifiedBy) assert.Equal(t, int64(0), rootTeam.UpdateAt) assert.Equal(t, "NewRootTeam", rootTeam.Title) require.NoError(t, err) require.NotNil(t, rootTeam) } }) } } func TestGetTeam(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() testCases := []struct { title string teamID string isError bool }{ { "Success, Return new root team, when team returned by mockstore is not nil", "mock-team-id", false, }, { "Success, Return nil, when get team returns an sql error", "team-not-available-id", false, }, { "Fail, Return nil, when get team by mockstore returns an error", "invalid-team-id", true, }, } th.Store.EXPECT().GetTeam("mock-team-id").Return(mockTeam, nil) th.Store.EXPECT().GetTeam("invalid-team-id").Return(nil, errInvalidTeam) th.Store.EXPECT().GetTeam("team-not-available-id").Return(nil, sql.ErrNoRows) for _, tc := range testCases { t.Run(tc.title, func(t *testing.T) { t.Log(tc.title) team, err := th.App.GetTeam(tc.teamID) if tc.isError { require.Error(t, err) } else if tc.teamID != "team-not-available-id" { assert.NotNil(t, team.ID) assert.NotNil(t, team.SignupToken) assert.Equal(t, "mock-team-id", team.ID) assert.Equal(t, "", team.ModifiedBy) assert.Equal(t, int64(0), team.UpdateAt) assert.Equal(t, "MockTeam", team.Title) require.NoError(t, err) require.NotNil(t, team) } }) } } func TestTeamOperations(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.Store.EXPECT().UpsertTeamSettings(*mockTeam).Return(nil) th.Store.EXPECT().UpsertTeamSignupToken(*mockTeam).Return(nil) th.Store.EXPECT().GetTeamCount().Return(int64(10), nil) errUpsertTeamSettings := th.App.UpsertTeamSettings(*mockTeam) assert.NoError(t, errUpsertTeamSettings) errUpsertTeamSignupToken := th.App.UpsertTeamSignupToken(*mockTeam) assert.NoError(t, errUpsertTeamSignupToken) count, errGetTeamCount := th.App.GetTeamCount() assert.NoError(t, errGetTeamCount) assert.Equal(t, int64(10), count) } ================================================ FILE: server/app/templates.go ================================================ package app import ( "bytes" "fmt" "strings" "github.com/mattermost/focalboard/server/assets" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( defaultTemplateVersion = 6 // bump this number to force default templates to be re-imported ) func (a *App) InitTemplates() error { _, err := a.initializeTemplates() return err } // initializeTemplates imports default templates if the boards table is empty. func (a *App) initializeTemplates() (bool, error) { boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "") if err != nil { return false, fmt.Errorf("cannot initialize templates: %w", err) } a.logger.Debug("Fetched template boards", mlog.Int("count", len(boards))) isNeeded, reason := a.isInitializationNeeded(boards) if !isNeeded { a.logger.Debug("Template import not needed, skipping") return false, nil } a.logger.Debug("Importing new default templates", mlog.String("reason", reason), mlog.Int("size", len(assets.DefaultTemplatesArchive)), ) // Remove in case of newer Templates if err = a.store.RemoveDefaultTemplates(boards); err != nil { return false, fmt.Errorf("cannot remove old template boards: %w", err) } r := bytes.NewReader(assets.DefaultTemplatesArchive) opt := model.ImportArchiveOptions{ TeamID: model.GlobalTeamID, ModifiedBy: model.SystemUserID, BlockModifier: fixTemplateBlock, BoardModifier: fixTemplateBoard, } if err = a.ImportArchive(r, opt); err != nil { return false, fmt.Errorf("cannot initialize global templates for team %s: %w", model.GlobalTeamID, err) } return true, nil } // isInitializationNeeded returns true if the blocks table contains no default templates, // or contains at least one default template with an old version number. func (a *App) isInitializationNeeded(boards []*model.Board) (bool, string) { if len(boards) == 0 { return true, "no default templates found" } // look for any built-in template boards with the wrong version number (or no version #). for _, board := range boards { // if not built-in board...skip if board.CreatedBy != model.SystemUserID { continue } if board.TemplateVersion < defaultTemplateVersion { return true, "template_version too old" } } return false, "" } // fixTemplateBlock fixes a block to be inserted as part of a template. func fixTemplateBlock(block *model.Block, cache map[string]interface{}) bool { // cache contains ids of skipped boards. Ensure their children are skipped as well. if _, ok := cache[block.BoardID]; ok { cache[block.ID] = struct{}{} return false } if _, ok := cache[block.ParentID]; ok { cache[block.ID] = struct{}{} return false } return true } // fixTemplateBoard fixes a board to be inserted as part of a template. func fixTemplateBoard(board *model.Board, cache map[string]interface{}) bool { // filter out template blocks; we only want the non-template // blocks which we will turn into default template blocks. if board.IsTemplate { cache[board.ID] = struct{}{} return false } // remove '(NEW)' from title & force template flag board.Title = strings.ReplaceAll(board.Title, "(NEW)", "") board.IsTemplate = true board.TemplateVersion = defaultTemplateVersion board.Type = model.BoardTypeOpen return true } ================================================ FILE: server/app/templates_test.go ================================================ package app import ( "testing" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" ) func TestApp_initializeTemplates(t *testing.T) { board := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: model.GlobalTeamID, Type: model.BoardTypeOpen, Title: "test board", IsTemplate: true, TemplateVersion: defaultTemplateVersion, } block := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), ParentID: board.ID, BoardID: board.ID, Type: model.TypeText, Title: "test text", } boardsAndBlocks := &model.BoardsAndBlocks{ Boards: []*model.Board{board}, Blocks: []*model.Block{block}, } boardMember := &model.BoardMember{ BoardID: board.ID, UserID: "test-user", } t.Run("Needs template init", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{}, nil) th.Store.EXPECT().RemoveDefaultTemplates([]*model.Board{}).Return(nil) th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, nil) th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil) th.Store.EXPECT().GetMemberForBoard(gomock.Any(), gomock.Any()).AnyTimes().Return(boardMember, nil) th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil).AnyTimes() th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil) done, err := th.App.initializeTemplates() require.NoError(t, err, "initializeTemplates should not error") require.True(t, done, "initialization was needed") }) t.Run("Skip template init", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{board}, nil) done, err := th.App.initializeTemplates() require.NoError(t, err, "initializeTemplates should not error") require.False(t, done, "initialization was not needed") }) } ================================================ FILE: server/app/user.go ================================================ package app import ( "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" ) func (a *App) GetTeamUsers(teamID string, asGuestID string) ([]*model.User, error) { return a.store.GetUsersByTeam(teamID, asGuestID, a.config.ShowEmailAddress, a.config.ShowFullName) } func (a *App) SearchTeamUsers(teamID string, searchQuery string, asGuestID string, excludeBots bool) ([]*model.User, error) { users, err := a.store.SearchUsersByTeam(teamID, searchQuery, asGuestID, excludeBots, a.config.ShowEmailAddress, a.config.ShowFullName) if err != nil { return nil, err } for i, u := range users { if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) { users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id) } if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) { users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id) } } return users, nil } func (a *App) UpdateUserConfig(userID string, patch model.UserPreferencesPatch) ([]mmModel.Preference, error) { updatedPreferences, err := a.store.PatchUserPreferences(userID, patch) if err != nil { return nil, err } return updatedPreferences, nil } func (a *App) GetUserPreferences(userID string) ([]mmModel.Preference, error) { return a.store.GetUserPreferences(userID) } func (a *App) UserIsGuest(userID string) (bool, error) { user, err := a.store.GetUserByID(userID) if err != nil { return false, err } return user.IsGuest, nil } func (a *App) CanSeeUser(seerUser string, seenUser string) (bool, error) { isGuest, err := a.UserIsGuest(seerUser) if err != nil { return false, err } if isGuest { hasSharedChannels, err := a.store.CanSeeUser(seerUser, seenUser) if err != nil { return false, err } return hasSharedChannels, nil } return true, nil } func (a *App) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) { channels, err := a.store.SearchUserChannels(teamID, userID, query) if err != nil { return nil, err } var writeableChannels []*mmModel.Channel for _, channel := range channels { if a.permissions.HasPermissionToChannel(userID, channel.Id, model.PermissionCreatePost) { writeableChannels = append(writeableChannels, channel) } } return writeableChannels, nil } func (a *App) GetChannel(teamID string, channelID string) (*mmModel.Channel, error) { return a.store.GetChannel(teamID, channelID) } func (a *App) SanitizeProfile(user *model.User, isAdmin bool) { options := map[string]bool{} if isAdmin { options["fullname"] = true options["email"] = true } else { options["fullname"] = a.config.ShowFullName options["email"] = a.config.ShowEmailAddress } user.Sanitize(options) } ================================================ FILE: server/app/user_test.go ================================================ package app import ( "testing" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/assert" ) func TestSearchUsers(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.App.config.ShowEmailAddress = false th.App.config.ShowFullName = false teamID := "team-id-1" userID := "user-id-1" t.Run("return empty users", func(t *testing.T) { th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{}, nil) users, err := th.App.SearchTeamUsers(teamID, "", "", true) assert.NoError(t, err) assert.Equal(t, 0, len(users)) }) t.Run("return user", func(t *testing.T) { th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil) th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1) th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1) users, err := th.App.SearchTeamUsers(teamID, "", "", true) assert.NoError(t, err) assert.Equal(t, 1, len(users)) assert.Equal(t, 0, len(users[0].Permissions)) }) t.Run("return team admin", func(t *testing.T) { th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil) th.App.config.ShowEmailAddress = false th.App.config.ShowFullName = false th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1) th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1) users, err := th.App.SearchTeamUsers(teamID, "", "", true) assert.NoError(t, err) assert.Equal(t, 1, len(users)) assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id) }) t.Run("return system admin", func(t *testing.T) { th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil) th.App.config.ShowEmailAddress = false th.App.config.ShowFullName = false th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1) th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(true).Times(1) users, err := th.App.SearchTeamUsers(teamID, "", "", true) assert.NoError(t, err) assert.Equal(t, 1, len(users)) assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id) assert.Equal(t, users[0].Permissions[1], model.PermissionManageSystem.Id) }) t.Run("test user channels", func(t *testing.T) { channelID := "Channel1" th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mmModel.Channel{{Id: channelID}}, nil) th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1) channels, err := th.App.SearchUserChannels(teamID, userID, "") assert.NoError(t, err) assert.Equal(t, 1, len(channels)) }) t.Run("test user channels- no permissions", func(t *testing.T) { channelID := "Channel1" th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mmModel.Channel{{Id: channelID}}, nil) th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1) channels, err := th.App.SearchUserChannels(teamID, userID, "") assert.NoError(t, err) assert.Equal(t, 0, len(channels)) }) } ================================================ FILE: server/assets/assets.go ================================================ package assets import ( _ "embed" ) // DefaultTemplatesArchive is an embedded archive file containing the default // templates to be imported to team 0. // This archive is generated with `make templates-archive` // //go:embed templates.boardarchive var DefaultTemplatesArchive []byte ================================================ FILE: server/assets/build-template-archive/main.go ================================================ package main import ( "archive/zip" "encoding/json" "flag" "fmt" "io" "os" "path/filepath" ) const ( defArchiveFilename = "templates.boardarchive" versionFilename = "version.json" boardFilename = "board.jsonl" minArchiveVersion = 2 maxArchiveVersion = 2 ) type archiveVersion struct { Version int `json:"version"` Date int64 `json:"date"` } type appConfig struct { dir string out string verbose bool } func main() { cfg := appConfig{} flag.StringVar(&cfg.dir, "dir", "", "source directory of templates") flag.StringVar(&cfg.out, "out", defArchiveFilename, "output filename") flag.BoolVar(&cfg.verbose, "verbose", false, "enable verbose output") flag.Parse() if cfg.dir == "" { flag.Usage() os.Exit(-1) } var code int if err := build(cfg); err != nil { code = -1 fmt.Fprintf(os.Stderr, "error creating archive: %v\n", err) } else if cfg.verbose { fmt.Fprintf(os.Stdout, "archive created: %s\n", cfg.out) } os.Exit(code) } func build(cfg appConfig) (err error) { version, err := getVersionFile(cfg) if err != nil { return err } // create the output archive zip file archiveFile, err := os.Create(cfg.out) if err != nil { return fmt.Errorf("error creating %s: %w", cfg.out, err) } archiveZip := zip.NewWriter(archiveFile) defer func() { if err2 := archiveZip.Close(); err2 != nil { if err == nil { err = fmt.Errorf("error closing zip %s: %w", cfg.out, err2) } } if err2 := archiveFile.Close(); err2 != nil { if err == nil { err = fmt.Errorf("error closing %s: %w", cfg.out, err2) } } }() // write the version file v, err := archiveZip.Create(versionFilename) if err != nil { return fmt.Errorf("error creating %s: %w", cfg.out, err) } if _, err = v.Write(version); err != nil { return fmt.Errorf("error writing %s: %w", cfg.out, err) } // each board is a subdirectory; write each to the archive files, err := os.ReadDir(cfg.dir) if err != nil { return fmt.Errorf("error reading directory %s: %w", cfg.dir, err) } for _, f := range files { if !f.IsDir() { if f.Name() != versionFilename && cfg.verbose { fmt.Fprintf(os.Stdout, "skipping non-directory %s\n", f.Name()) } continue } if err = writeBoard(archiveZip, f.Name(), cfg); err != nil { return fmt.Errorf("error writing board %s: %w", f.Name(), err) } } return nil } func getVersionFile(cfg appConfig) ([]byte, error) { path := filepath.Join(cfg.dir, versionFilename) buf, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("cannot read %s: %w", path, err) } var version archiveVersion if err := json.Unmarshal(buf, &version); err != nil { return nil, fmt.Errorf("cannot parse %s: %w", path, err) } if version.Version < minArchiveVersion || version.Version > maxArchiveVersion { return nil, errUnsupportedVersion{Min: minArchiveVersion, Max: maxArchiveVersion, Got: version.Version} } return buf, nil } func writeBoard(w *zip.Writer, boardID string, cfg appConfig) error { // copy the board's jsonl file first. BoardID is also the directory name. srcPath := filepath.Join(cfg.dir, boardID, boardFilename) destPath := filepath.Join(boardID, boardFilename) if err := writeFile(w, srcPath, destPath, cfg); err != nil { return err } boardPath := filepath.Join(cfg.dir, boardID) files, err := os.ReadDir(boardPath) if err != nil { return fmt.Errorf("error reading board directory %s: %w", cfg.dir, err) } for _, f := range files { if f.IsDir() { if cfg.verbose { fmt.Fprintf(os.Stdout, "skipping directory %s\n", f.Name()) } continue } if f.Name() == boardFilename { continue } srcPath = filepath.Join(cfg.dir, boardID, f.Name()) destPath = filepath.Join(boardID, f.Name()) if err = writeFile(w, srcPath, destPath, cfg); err != nil { return fmt.Errorf("error writing %s: %w", destPath, err) } } return nil } func writeFile(w *zip.Writer, srcPath string, destPath string, cfg appConfig) (err error) { inFile, err := os.Open(srcPath) if err != nil { return fmt.Errorf("error reading %s: %w", srcPath, err) } defer inFile.Close() outFile, err := w.Create(destPath) if err != nil { return fmt.Errorf("error creating %s: %w", destPath, err) } size, err := io.Copy(outFile, inFile) if err != nil { return fmt.Errorf("error writing %s: %w", destPath, err) } if cfg.verbose { fmt.Fprintf(os.Stdout, "%s written (%d bytes)\n", destPath, size) } return nil } type errUnsupportedVersion struct { Min int Max int Got int } func (e errUnsupportedVersion) Error() string { return fmt.Sprintf("unsupported archive version; require between %d and %d inclusive, got %d", e.Min, e.Max, e.Got) } ================================================ FILE: server/assets/templates-boardarchive/b7wnw9awd4pnefryhq51apbzb4c/board.jsonl ================================================ {"type":"block","data":{"id":"b7wnw9awd4pnefryhq51apbzb4c","parentId":"","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"board","title":"Meeting Agenda (NEW)","fields":{"cardProperties":[{"id":"d777ba3b-8728-40d1-87a6-59406bbbbfb0","name":"Status","options":[{"color":"propColorPink","id":"34eb9c25-d5bf-49d9-859e-f74f4e0030e7","value":"To Discuss 💬"},{"color":"propColorYellow","id":"d37a61f4-f332-4db9-8b2d-5e0a91aa20ed","value":"Revisit Later ⏳"},{"color":"propColorGreen","id":"dabadd9b-adf1-4d9f-8702-805ac6cef602","value":"Done / Archived 📦"}],"type":"select"},{"id":"4cf1568d-530f-4028-8ffd-bdc65249187e","name":"Priority","options":[{"color":"propColorRed","id":"8b05c83e-a44a-4d04-831e-97f01d8e2003","value":"1. High"},{"color":"propColorYellow","id":"b1abafbf-a038-4a19-8b68-56e0fd2319f7","value":"2. Medium"},{"color":"propColorGray","id":"2491ffaa-eb55-417b-8aff-4bd7d4136613","value":"3. Low"}],"type":"select"},{"id":"aw4w63xhet79y9gueqzzeiifdoe","name":"Created by","options":[],"type":"createdBy"},{"id":"a6ux19353xcwfqg9k1inqg5sg4w","name":"Created time","options":[],"type":"createdTime"}],"description":"Use this template for recurring meeting agendas, like team meetings and 1:1's. To use this board:\n* Participants queue new items to discuss under \"To Discuss\"\n* Go through items during the meeting\n* Move items to Done or Revisit Later as needed","icon":"🍩","isTemplate":false,"showDescription":true},"createAt":1641497047916,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cgwagmaw6gin7xcq7nwew8rsynr","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Team Schedule","fields":{"contentOrder":["a4t1p1pbxbtnnu8p8e538o8369a","7b7hsbkm6sifqfqi4gstxxaz7my","aoqz1pydxbtnzdcs4ehcuys6cuc","7b3njq5m3n78hdpe4bimzr34fic","73dzfgistnbgzuekc6c8irou9wy","7z4cjur4ybbfibgmydhfct4jdke"],"icon":"⏰","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"8b05c83e-a44a-4d04-831e-97f01d8e2003","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"34eb9c25-d5bf-49d9-859e-f74f4e0030e7"}},"createAt":1641497048246,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"chki1tsudciyiiffrkqbcmp71rh","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Video production","fields":{"contentOrder":["a9ti13dqo8jfmjdmg97f5umfdyw","717fa85sx3f8f8m81f771s9hmwr","a4se5s4ozx3ry8ec57w6z6jpk7y","7n37rxrn9uffdzrfi1xajotzjey","7ifofmuwjzbdzppfxgtuai4i47h","7cfc4fkpz53gn9frciz9kui4p1c"],"icon":"📹","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"b1abafbf-a038-4a19-8b68-56e0fd2319f7","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"34eb9c25-d5bf-49d9-859e-f74f4e0030e7"}},"createAt":1641497048092,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cmt5usr1mw3fom886t34ekjquay","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Offsite plans","fields":{"contentOrder":["aw53ugkfq8pyi9fjh9j6i4kdeiw","7ni9593iz3pnb7xitoz3guwq5gh","agjkcro3x7irbxedyxrn8iuerrr","75zkot1f3sjb7ifysuzijitw91y","7is5m8apdu3g53c8f6cz6sq7bmh","7xsmzscbqn3ftudzqbb4w1q7t7e"],"icon":"🚙","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"8b05c83e-a44a-4d04-831e-97f01d8e2003","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"dabadd9b-adf1-4d9f-8702-805ac6cef602"}},"createAt":1641497048336,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cnqsbzg4b7brfddtyh7fc66atrw","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Social Media Strategy","fields":{"contentOrder":["ao57n1fbtmt8q8bfk8ieqgzqt3a","76h9y996sdj8sbrbpqjo9d8cwto","aco8iu5jp7jbyzmzegwxkeusgzr","7y6zcyofmsfrbt899ts1ixr3iey","7hudywfzcwirkpcp1p5jhsfs83r","7jzw67ngdgtns8mstsg9g614oac"],"icon":"🎉","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"b1abafbf-a038-4a19-8b68-56e0fd2319f7","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"d37a61f4-f332-4db9-8b2d-5e0a91aa20ed"}},"createAt":1641497048417,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vfs8sj79dt7n75bomn46fybxmfo","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"view","title":"Discussion Items","fields":{"cardOrder":["cjpkiya33qsagr4f9hrdwhgiajc"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"d777ba3b-8728-40d1-87a6-59406bbbbfb0","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[{"propertyId":"4cf1568d-530f-4028-8ffd-bdc65249187e","reversed":false}],"viewType":"board","visibleOptionIds":[],"visiblePropertyIds":["4cf1568d-530f-4028-8ffd-bdc65249187e"]},"createAt":1641497048501,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"73dzfgistnbgzuekc6c8irou9wy","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586451774,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7b3njq5m3n78hdpe4bimzr34fic","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586448934,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7b7hsbkm6sifqfqi4gstxxaz7my","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586358664,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7z4cjur4ybbfibgmydhfct4jdke","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586454130,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a4t1p1pbxbtnnu8p8e538o8369a","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586355777,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aoqz1pydxbtnzdcs4ehcuys6cuc","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586443526,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"766mkfhc4u7dxzcc36nhfpmm5fy","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586677789,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"76w5qigi5ufgktcmmnw9ze88w5w","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641497389096,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"79wi7osb3utd3mjt9x57h7wpqfa","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641497390990,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7un1ccdg7qi8j3gxmkx5y3d9nhr","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641497382984,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"as3orhrci6tnutp5etbh6bzbgdy","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"text","title":"# Action Items","fields":{},"createAt":1641497371429,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"axyitfq8ae38qictgcw34cmwueh","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"text","title":"# Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641497348992,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"717fa85sx3f8f8m81f771s9hmwr","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586368705,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7cfc4fkpz53gn9frciz9kui4p1c","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586479058,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7ifofmuwjzbdzppfxgtuai4i47h","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586476646,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7n37rxrn9uffdzrfi1xajotzjey","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586469805,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a4se5s4ozx3ry8ec57w6z6jpk7y","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586462602,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a9ti13dqo8jfmjdmg97f5umfdyw","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586365342,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"75zkot1f3sjb7ifysuzijitw91y","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586514173,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7is5m8apdu3g53c8f6cz6sq7bmh","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586516563,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7ni9593iz3pnb7xitoz3guwq5gh","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586383504,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7xsmzscbqn3ftudzqbb4w1q7t7e","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586518624,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"agjkcro3x7irbxedyxrn8iuerrr","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586506048,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aw53ugkfq8pyi9fjh9j6i4kdeiw","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586380592,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"76h9y996sdj8sbrbpqjo9d8cwto","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586375619,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7hudywfzcwirkpcp1p5jhsfs83r","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586495344,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7jzw67ngdgtns8mstsg9g614oac","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586497433,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7y6zcyofmsfrbt899ts1ixr3iey","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586492877,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aco8iu5jp7jbyzmzegwxkeusgzr","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586487881,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ao57n1fbtmt8q8bfk8ieqgzqt3a","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586373252,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} ================================================ FILE: server/assets/templates-boardarchive/bbkpwdj8x17bdpdqd176n8ctoua/board.jsonl ================================================ {"type":"board","data":{"id":"bbkpwdj8x17bdpdqd176n8ctoua","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Sales Pipeline CRM","description":"Use this template to grow and keep track of your sales opportunities.","icon":"📈","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"a5hwxjsmkn6bak6r7uea5bx1kwc","name":"Status","options":[{"color":"propColorGray","id":"akj61wc9yxdwyw3t6m8igyf9d5o","value":"Lead"},{"color":"propColorYellow","id":"aic89a5xox4wbppi6mbyx6ujsda","value":"Qualified"},{"color":"propColorBrown","id":"ah6ehh43rwj88jy4awensin8pcw","value":"Meeting"},{"color":"propColorPurple","id":"aprhd96zwi34o9cs4xyr3o9sf3c","value":"Proposal"},{"color":"propColorOrange","id":"axesd74yuxtbmw1sbk8ufax7z3a","value":"Negotiation"},{"color":"propColorRed","id":"a5txuiubumsmrs8gsd5jz5gc1oa","value":"Lost"},{"color":"propColorGreen","id":"acm9q494bcthyoqzmfogxxy5czy","value":"Closed 🏆"}],"type":"select"},{"id":"aoheuj1f3mu6eehygr45fxa144y","name":"Account Owner","options":[],"type":"multiPerson"},{"id":"aro91wme9kfaie5ceu9qasmtcnw","name":"Priority","options":[{"color":"propColorRed","id":"apjnaggwixchfxwiatfh7ey7uno","value":"High 🔥"},{"color":"propColorYellow","id":"apiswzj7uiwbh87z8dw8c6mturw","value":"Medium"},{"color":"propColorBrown","id":"auu9bfzqeuruyjwzzqgz7q8apuw","value":"Low"}],"type":"select"},{"id":"ainpw47babwkpyj77ic4b9zq9xr","name":"Company","options":[],"type":"text"},{"id":"ahf43e44h3y8ftanqgzno9z7q7w","name":"Estimated Value","options":[],"type":"number"},{"id":"amahgyn9n4twaapg3jyxb6y4jic","name":"Territory","options":[{"color":"propColorBrown","id":"ar6t1ttcumgfuqugg5o4g4mzrza","value":"Western US"},{"color":"propColorGreen","id":"asbwojkm7zb4ohrtij97jkdfgwe","value":"Mountain West / Central US"},{"color":"propColorGray","id":"aw8ppwtcrm8iwopdadje3ni196w","value":"Mid-Atlantic / Southeast"},{"color":"propColorBlue","id":"aafwyza5iwdcwcyfyj6bp7emufw","value":"Northeast US / Canada"},{"color":"propColorPink","id":"agw8rcb9uxyt3c7g6tq3r65fgqe","value":"Eastern Europe"},{"color":"propColorPurple","id":"as5bk6afoaaa7caewe1zc391sce","value":"Central Europe / Africa"},{"color":"propColorYellow","id":"a8fj94bka8z9t6p95qd3hn6t5re","value":"Middle East"},{"color":"propColorOrange","id":"arpxa3faaou9trt4zx5sh435gne","value":"UK"},{"color":"propColorRed","id":"azdidd5wze4kcxf8neefj3ctkyr","value":"Asia"},{"color":"propColorGray","id":"a4jn5mhqs3thknqf5opykntgsnc","value":"Australia"},{"color":"propColorBrown","id":"afjbgrecb7hp5owj7xh8u4w33tr","value":"Latin America"}],"type":"select"},{"id":"abru6tz8uebdxy4skheqidh7zxy","name":"Email","options":[],"type":"email"},{"id":"a1438fbbhjeffkexmcfhnx99o1h","name":"Phone","options":[],"type":"phone"},{"id":"auhf91pm85f73swwidi4wid8jqe","name":"Last Contact Date","options":[],"type":"date"},{"id":"adtf1151chornmihz4xbgbk9exa","name":"Expected Close","options":[],"type":"date"},{"id":"aejo5tcmq54bauuueem9wc4fw4y","name":"Close Probability","options":[],"type":"text"},{"id":"amba7ot98fh7hwsx8jdcfst5g7h","name":"Created Date","options":[],"type":"createdTime"}],"createAt":1667509277974,"updateAt":1667511890353,"deleteAt":0}} {"type":"block","data":{"id":"v76ciioz6ujd49phimp5jzomsww","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"All Contacts","fields":{"cardOrder":["cyt3qdus94pg3fkxq4ojebyd5fr","chew1d7kc3py3pj51qyqaiz6ade","c91bktnpajfrrdpxs7ck1h7ziwh","c77c6z9k9oigdpbocg8kxi7h8ah","c9ciauq49ifdntc99rnehkkshpr"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":240,"a1438fbbhjeffkexmcfhnx99o1h":151,"a5hwxjsmkn6bak6r7uea5bx1kwc":132,"abru6tz8uebdxy4skheqidh7zxy":247,"adtf1151chornmihz4xbgbk9exa":125,"aejo5tcmq54bauuueem9wc4fw4y":127,"ahf43e44h3y8ftanqgzno9z7q7w":129,"ainpw47babwkpyj77ic4b9zq9xr":157,"amahgyn9n4twaapg3jyxb6y4jic":224,"amba7ot98fh7hwsx8jdcfst5g7h":171,"aoheuj1f3mu6eehygr45fxa144y":130,"auhf91pm85f73swwidi4wid8jqe":157},"defaultTemplateId":"cphg5tyix4irsipkcp9ujaj3gwh","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a5hwxjsmkn6bak6r7uea5bx1kwc","aoheuj1f3mu6eehygr45fxa144y","aro91wme9kfaie5ceu9qasmtcnw","ainpw47babwkpyj77ic4b9zq9xr","ahf43e44h3y8ftanqgzno9z7q7w","amahgyn9n4twaapg3jyxb6y4jic","abru6tz8uebdxy4skheqidh7zxy","a1438fbbhjeffkexmcfhnx99o1h","auhf91pm85f73swwidi4wid8jqe","adtf1151chornmihz4xbgbk9exa","aejo5tcmq54bauuueem9wc4fw4y","amba7ot98fh7hwsx8jdcfst5g7h"]},"createAt":1667513494864,"updateAt":1667513802156,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"va9qcbagmdbfwb8xq5hawbq1a4r","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Pipeline Tracker","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["akj61wc9yxdwyw3t6m8igyf9d5o","aic89a5xox4wbppi6mbyx6ujsda","ah6ehh43rwj88jy4awensin8pcw","aprhd96zwi34o9cs4xyr3o9sf3c","axesd74yuxtbmw1sbk8ufax7z3a","a5txuiubumsmrs8gsd5jz5gc1oa","acm9q494bcthyoqzmfogxxy5czy"],"visiblePropertyIds":["aro91wme9kfaie5ceu9qasmtcnw","amahgyn9n4twaapg3jyxb6y4jic"]},"createAt":1667513379646,"updateAt":1667513589086,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"c77c6z9k9oigdpbocg8kxi7h8ah","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Jonathan Frazier","fields":{"contentOrder":["a7hc9n8oz47gybkxj4ssnwgi7ky","a4bminunz1j8p3go9ixxdxpi4no","71ibw3rrac7gcmgr4f16st7fz1c","736fwfii9t7nafekshdjc6y4rge","78aiw1o1wzibzzbiuo4e78p4pdr","7ei858uzb9jye8yqo7j5nq1knaa","7bai1o5z5fibiuxs7i9i8tti87w","7cg1mxma4fjb67xmh1p7fyxekro","76ry4rpfhq7ykprpmbidxdjr33o","77gckzfpcmjb1bysnnqs7cnzseo","7biw71wn9nfdgxd7fbh9un68zrc","7iz6fjou66i8muqnhzb9pocff3e"],"icon":"🙎‍♂️","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(999) 123-5678","a5hwxjsmkn6bak6r7uea5bx1kwc":"a5txuiubumsmrs8gsd5jz5gc1oa","abru6tz8uebdxy4skheqidh7zxy":"jonathan.frazier@email.com","aejo5tcmq54bauuueem9wc4fw4y":"0%","ahf43e44h3y8ftanqgzno9z7q7w":"$800,000","ainpw47babwkpyj77ic4b9zq9xr":"Ositions Inc.","amahgyn9n4twaapg3jyxb6y4jic":"as5bk6afoaaa7caewe1zc391sce","aro91wme9kfaie5ceu9qasmtcnw":"apiswzj7uiwbh87z8dw8c6mturw","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1669118400000}"}},"createAt":1667513212844,"updateAt":1667513367839,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"c91bktnpajfrrdpxs7ck1h7ziwh","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Richard Guzman","fields":{"contentOrder":["a43kn6138w7boimnp5xe1khezjc","ab6q8dsqh7ifhmk14ow4m9ytj3e","73p7qyd8h13nq5fk54rqgbee7or","7sqafho6jofdtjk5byn3yskq5ry","7q6pi4f9dbtrpzbcm65hg9useso","7976uafbzbjrmjya983z5bweesy","7nu71kxnutbd6fdnmzjfbrczinw","7j3q9i5a337ym3fdnigx3ifrhoh","7jap7w5js9bfazgsa59skmocmhw","7mndskgucj3g18ys7c6wjpub78o","7kwmrfpx8pir5ieg5w8orbtq8ba","7mwxoycnpq7nhix7r5x3wtmqd3h"],"icon":"👨‍💼","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(222) 123-1234","a5hwxjsmkn6bak6r7uea5bx1kwc":"axesd74yuxtbmw1sbk8ufax7z3a","abru6tz8uebdxy4skheqidh7zxy":"richard.guzman@email.com","adtf1151chornmihz4xbgbk9exa":"{\"from\":1681992000000}","aejo5tcmq54bauuueem9wc4fw4y":"80%","ahf43e44h3y8ftanqgzno9z7q7w":"$3,200,000","ainpw47babwkpyj77ic4b9zq9xr":"Afformance Ltd.","amahgyn9n4twaapg3jyxb6y4jic":"ar6t1ttcumgfuqugg5o4g4mzrza","aro91wme9kfaie5ceu9qasmtcnw":"apjnaggwixchfxwiatfh7ey7uno","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1667476800000}"}},"createAt":1667512379637,"updateAt":1667512604683,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"c9ciauq49ifdntc99rnehkkshpr","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Byron Cole","fields":{"contentOrder":["a4wwsynhjafb4dgbubda18ho3fr","a1ag6b4hwkibbbbxdmse74cw3ur","767qdn4uhbbrb8gyq4x7w1rfcoc","7ad16jbhbcpro78cueumekyqjyy","7xbj8zr1jxfnxfkyfyccb84ddeo","7sggexapxebb1zk9oqta6gcwsda","7y3ncauhatfrg7nzyr67twe36wc","74ach4ckw53grfygwp8m6wbj4ya","7agc943grqtgidb3e49dkqumrce","7owy1izqn1if55r5hc3fgu8fada","7zcbwgrw5apd4frn6uxd386rktc","7zijtxs3enjy5frzc4zb6937b3w"],"icon":"🤵","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(333) 123-1234","a5hwxjsmkn6bak6r7uea5bx1kwc":"acm9q494bcthyoqzmfogxxy5czy","abru6tz8uebdxy4skheqidh7zxy":"byron.cole@email.com","adtf1151chornmihz4xbgbk9exa":"{\"from\":1667563200000}","aejo5tcmq54bauuueem9wc4fw4y":"100%","ahf43e44h3y8ftanqgzno9z7q7w":"$500,000","ainpw47babwkpyj77ic4b9zq9xr":"Helx Industries","amahgyn9n4twaapg3jyxb6y4jic":"aafwyza5iwdcwcyfyj6bp7emufw","aro91wme9kfaie5ceu9qasmtcnw":"apjnaggwixchfxwiatfh7ey7uno","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1667822400000}"}},"createAt":1667512692248,"updateAt":1667512904723,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"chew1d7kc3py3pj51qyqaiz6ade","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Caitlyn Russel","fields":{"contentOrder":["atgpetmwdubb5jkugcb6jm9pzyo","acod8woq6zjbzmc1hz8qkfxyi1h","7s47d7rzh4pnw5rcnpjysxg6duh","7s9smhppjff87tndwawqwdmfryo","7t3ib1amo7fgzbmhg4tkzqustcy","7s5bgtoajcbnd8rrc5bxzabdcyw","7nze85jfmobfm8j8xfmrbdwyrfa","7jrwix8rkbtb5bdek79mtat8w1c","7c1iwiqsi1iddpfzqisbkubjxhh","7tp1rgey147nnfjuose7418oioh","7ftxm79a1e7nuxpb913aqphoqbo","799cbodnfr3ydfjp53die7egd1e"],"icon":"🧑‍💼","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(111) 123-1234","a5hwxjsmkn6bak6r7uea5bx1kwc":"ah6ehh43rwj88jy4awensin8pcw","abru6tz8uebdxy4skheqidh7zxy":"caitlyn.russel@email.com","adtf1151chornmihz4xbgbk9exa":"{\"from\":1689336000000}","aejo5tcmq54bauuueem9wc4fw4y":"20%","ahf43e44h3y8ftanqgzno9z7q7w":"$250,000","ainpw47babwkpyj77ic4b9zq9xr":"Liminary Corp.","amahgyn9n4twaapg3jyxb6y4jic":"aafwyza5iwdcwcyfyj6bp7emufw","aro91wme9kfaie5ceu9qasmtcnw":"apiswzj7uiwbh87z8dw8c6mturw","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1668168000000}"}},"createAt":1667509567800,"updateAt":1667512683024,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"cphg5tyix4irsipkcp9ujaj3gwh","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"New Prospect","fields":{"contentOrder":["atw8pz7bqgp877pd5714jbpqrsh","azwoek6rwfpfqiruig13owyyagr","71735rqboe3rkxypssssddjykkc","7jgf4cownfiy7xpaznxdsnyze9a","74khjujy4hir4zmer4hkj1gcckh","768ut9xkqipgf9fk6ub146spu5e","7jryotoo5wig9bdt3kh1fmgm5qw","7p7hz5ky15jgrirb64533xzsquo","7c9cy5ohjd3b85xkee539zw9owh","7dsynp6qf8tdtjpcqsxfyuqyzmo","7kxzdhjtx8pdazm7bufusybwygo","7h1zyk7thz7gx3r5degq6qorjay"],"icon":"👤","isTemplate":true,"properties":{"a5hwxjsmkn6bak6r7uea5bx1kwc":"akj61wc9yxdwyw3t6m8igyf9d5o"}},"createAt":1667513652330,"updateAt":1667513749765,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"cyt3qdus94pg3fkxq4ojebyd5fr","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Shelby Olson","fields":{"contentOrder":["ay46giuhekby5tmrnw4abnz97pw","awugzceoocjdnmn1ab6srb5cc6r","7bncmywm3h38s7ndpcu9sytfffy","7zmoekhdb4i848p313eh1okp78c","7xxy1eewp8jdxpcouho8jq7ed4w","7rgodjyks6jrtprbeizusduat4c","7u5qxs77u57bc8bd33ug5aa91rw","7jexsw3nutb8m3x6eyqio7gtcxr","7abw4xifxubn1urheakij9kjc5e","7r47h1d8fjfrpzkfzyxha44wrqe","7yq4oh69547rm9mp3eqg9zqzoxw","7yhpeqyesfif188x4pabwurnw4o"],"icon":"🙎‍♀️","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(111) 321-5678","a5hwxjsmkn6bak6r7uea5bx1kwc":"akj61wc9yxdwyw3t6m8igyf9d5o","abru6tz8uebdxy4skheqidh7zxy":"shelby.olson@email.com","ahf43e44h3y8ftanqgzno9z7q7w":"$30,000","ainpw47babwkpyj77ic4b9zq9xr":"Kadera Global","amahgyn9n4twaapg3jyxb6y4jic":"ar6t1ttcumgfuqugg5o4g4mzrza","aro91wme9kfaie5ceu9qasmtcnw":"auu9bfzqeuruyjwzzqgz7q8apuw","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1669291200000}"}},"createAt":1667512982640,"updateAt":1667513171727,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"vg19cqh9bnbfq5edwq4kep3ssxr","parentId":"bzwb99zf498tsm7mjqbiy7g81ze","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Open Deals","fields":{"cardOrder":["chew1d7kc3py3pj51qyqaiz6ade"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[{"condition":"includes","propertyId":"a5hwxjsmkn6bak6r7uea5bx1kwc","values":["akj61wc9yxdwyw3t6m8igyf9d5o","aic89a5xox4wbppi6mbyx6ujsda","ah6ehh43rwj88jy4awensin8pcw","aprhd96zwi34o9cs4xyr3o9sf3c","axesd74yuxtbmw1sbk8ufax7z3a"]}],"operation":"and"},"groupById":"aro91wme9kfaie5ceu9qasmtcnw","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["apjnaggwixchfxwiatfh7ey7uno","apiswzj7uiwbh87z8dw8c6mturw","auu9bfzqeuruyjwzzqgz7q8apuw",""],"visiblePropertyIds":[]},"createAt":1667509277984,"updateAt":1667513521431,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"71ibw3rrac7gcmgr4f16st7fz1c","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667513212852,"updateAt":1667513212852,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"736fwfii9t7nafekshdjc6y4rge","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667513212861,"updateAt":1667513341391,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"76ry4rpfhq7ykprpmbidxdjr33o","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{"value":true},"createAt":1667513212920,"updateAt":1667513348088,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"77gckzfpcmjb1bysnnqs7cnzseo","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667513212930,"updateAt":1667513212930,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"78aiw1o1wzibzzbiuo4e78p4pdr","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667513212869,"updateAt":1667513342078,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7bai1o5z5fibiuxs7i9i8tti87w","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667513212887,"updateAt":1667513344670,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7biw71wn9nfdgxd7fbh9un68zrc","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667513212939,"updateAt":1667513212939,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7cg1mxma4fjb67xmh1p7fyxekro","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{"value":true},"createAt":1667513212912,"updateAt":1667513345694,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7ei858uzb9jye8yqo7j5nq1knaa","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667513212878,"updateAt":1667513343116,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7iz6fjou66i8muqnhzb9pocff3e","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667513212947,"updateAt":1667513212947,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"a4bminunz1j8p3go9ixxdxpi4no","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667513212903,"updateAt":1667513212903,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"a7hc9n8oz47gybkxj4ssnwgi7ky","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667513212895,"updateAt":1667513212895,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"73p7qyd8h13nq5fk54rqgbee7or","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667512379656,"updateAt":1667512968074,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7976uafbzbjrmjya983z5bweesy","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667512379686,"updateAt":1667512970061,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7j3q9i5a337ym3fdnigx3ifrhoh","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{"value":true},"createAt":1667512379752,"updateAt":1667512975240,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7jap7w5js9bfazgsa59skmocmhw","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{"value":true},"createAt":1667512379772,"updateAt":1667512975857,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7kwmrfpx8pir5ieg5w8orbtq8ba","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667512379805,"updateAt":1667512379805,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7mndskgucj3g18ys7c6wjpub78o","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667512379792,"updateAt":1667512379792,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7mwxoycnpq7nhix7r5x3wtmqd3h","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667512379814,"updateAt":1667512379814,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7nu71kxnutbd6fdnmzjfbrczinw","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667512379695,"updateAt":1667512973476,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7q6pi4f9dbtrpzbcm65hg9useso","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667512379677,"updateAt":1667512969519,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7sqafho6jofdtjk5byn3yskq5ry","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667512379668,"updateAt":1667512968798,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"a43kn6138w7boimnp5xe1khezjc","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512379704,"updateAt":1667512379704,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"ab6q8dsqh7ifhmk14ow4m9ytj3e","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512379729,"updateAt":1667512379728,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"74ach4ckw53grfygwp8m6wbj4ya","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{"value":true},"createAt":1667512692319,"updateAt":1667512917248,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"767qdn4uhbbrb8gyq4x7w1rfcoc","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667512692257,"updateAt":1667512911931,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7ad16jbhbcpro78cueumekyqjyy","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667512692265,"updateAt":1667512912836,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7agc943grqtgidb3e49dkqumrce","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{"value":true},"createAt":1667512692327,"updateAt":1667512919194,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7owy1izqn1if55r5hc3fgu8fada","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{"value":true},"createAt":1667512692335,"updateAt":1667512920115,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7sggexapxebb1zk9oqta6gcwsda","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667512692283,"updateAt":1667512914481,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7xbj8zr1jxfnxfkyfyccb84ddeo","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667512692273,"updateAt":1667512913567,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7y3ncauhatfrg7nzyr67twe36wc","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667512692292,"updateAt":1667512915496,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7zcbwgrw5apd4frn6uxd386rktc","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{"value":true},"createAt":1667512692344,"updateAt":1667512920721,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7zijtxs3enjy5frzc4zb6937b3w","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{"value":true},"createAt":1667512692353,"updateAt":1667512922687,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"a1ag6b4hwkibbbbxdmse74cw3ur","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512692310,"updateAt":1667512692310,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"a4wwsynhjafb4dgbubda18ho3fr","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512692301,"updateAt":1667512692301,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"799cbodnfr3ydfjp53die7egd1e","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667512344379,"updateAt":1667512354748,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7c1iwiqsi1iddpfzqisbkubjxhh","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667512215518,"updateAt":1667512224971,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7ftxm79a1e7nuxpb913aqphoqbo","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667512251753,"updateAt":1667512267186,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7jrwix8rkbtb5bdek79mtat8w1c","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667512204105,"updateAt":1667512287236,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7nze85jfmobfm8j8xfmrbdwyrfa","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667510597027,"updateAt":1667512961521,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7s47d7rzh4pnw5rcnpjysxg6duh","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667510557630,"updateAt":1667512956967,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7s5bgtoajcbnd8rrc5bxzabdcyw","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667510586823,"updateAt":1667512960547,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7s9smhppjff87tndwawqwdmfryo","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667510564441,"updateAt":1667512958081,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7t3ib1amo7fgzbmhg4tkzqustcy","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667510573106,"updateAt":1667512959302,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7tp1rgey147nnfjuose7418oioh","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667512225170,"updateAt":1667512251543,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"acod8woq6zjbzmc1hz8qkfxyi1h","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512186838,"updateAt":1667512192833,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"atgpetmwdubb5jkugcb6jm9pzyo","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512110036,"updateAt":1667512180024,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"71735rqboe3rkxypssssddjykkc","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":false},"createAt":1667513652337,"updateAt":1667513703739,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"74khjujy4hir4zmer4hkj1gcckh","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":false},"createAt":1667513652354,"updateAt":1667513652354,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"768ut9xkqipgf9fk6ub146spu5e","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":false},"createAt":1667513652368,"updateAt":1667513652368,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7c9cy5ohjd3b85xkee539zw9owh","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667513652448,"updateAt":1667513652448,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7dsynp6qf8tdtjpcqsxfyuqyzmo","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667513652464,"updateAt":1667513652464,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7h1zyk7thz7gx3r5degq6qorjay","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667513652495,"updateAt":1667513652495,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7jgf4cownfiy7xpaznxdsnyze9a","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":false},"createAt":1667513652344,"updateAt":1667513652344,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7jryotoo5wig9bdt3kh1fmgm5qw","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":false},"createAt":1667513652384,"updateAt":1667513652384,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7kxzdhjtx8pdazm7bufusybwygo","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667513652486,"updateAt":1667513652486,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7p7hz5ky15jgrirb64533xzsquo","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667513652428,"updateAt":1667513652428,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"atw8pz7bqgp877pd5714jbpqrsh","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n[Enter notes here...]","fields":{},"createAt":1667513652402,"updateAt":1667513741067,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"azwoek6rwfpfqiruig13owyyagr","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667513652416,"updateAt":1667513652416,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"777h45bs9xffj3ecpe9ti9jqdar","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667513758151,"updateAt":1667513758151,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"78sze8whs5i8htgtsuwqc81agjr","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":false},"createAt":1667513758058,"updateAt":1667513758058,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7brzhoqztxpyg8jtqrd7c6dqtie","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667513758161,"updateAt":1667513758161,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7dejdqngn43rp3phmg64ditmyrr","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667513758142,"updateAt":1667513758142,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7dpbn45wo63r97fgb5356od9jyr","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":false},"createAt":1667513758067,"updateAt":1667513758067,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7gku1jfqfppb358a3q6y1b8sb7a","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":false},"createAt":1667513758086,"updateAt":1667513758086,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7p6xqywuxwifsfb67bc5zbhu1ny","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667513758133,"updateAt":1667513758133,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7qfj7p7xb4fgp9y4a8sp13ixiny","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667513758124,"updateAt":1667513758124,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7xrfub5nuxtb5pbuwrwtjbtekdw","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":false},"createAt":1667513758077,"updateAt":1667513758077,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7ynnwzqecx7f8t8yci1htkikude","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":false},"createAt":1667513758096,"updateAt":1667513758096,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"as8xstqmiobdr7ykjc4rb9pfcdh","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667513758114,"updateAt":1667513758114,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"azxp9y8hk33guugjq6iba7whj6h","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n[Enter notes here...]","fields":{},"createAt":1667513758104,"updateAt":1667513758104,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7abw4xifxubn1urheakij9kjc5e","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667512982703,"updateAt":1667512982703,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7bncmywm3h38s7ndpcu9sytfffy","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667512982648,"updateAt":1667512982648,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7jexsw3nutb8m3x6eyqio7gtcxr","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667512982697,"updateAt":1667512982697,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7r47h1d8fjfrpzkfzyxha44wrqe","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667512982712,"updateAt":1667512982712,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7rgodjyks6jrtprbeizusduat4c","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":false},"createAt":1667512982669,"updateAt":1667513178427,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7u5qxs77u57bc8bd33ug5aa91rw","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":false},"createAt":1667512982675,"updateAt":1667513176256,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7xxy1eewp8jdxpcouho8jq7ed4w","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":false},"createAt":1667512982661,"updateAt":1667513177889,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7yhpeqyesfif188x4pabwurnw4o","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667512982725,"updateAt":1667512982725,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7yq4oh69547rm9mp3eqg9zqzoxw","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667512982718,"updateAt":1667512982718,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"7zmoekhdb4i848p313eh1okp78c","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":false},"createAt":1667512982655,"updateAt":1667513179761,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"awugzceoocjdnmn1ab6srb5cc6r","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512982690,"updateAt":1667512982690,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} {"type":"block","data":{"id":"ay46giuhekby5tmrnw4abnz97pw","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512982683,"updateAt":1667512982683,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}} ================================================ FILE: server/assets/templates-boardarchive/bbn1888mprfrm5fjw9f1je9x3xo/board.jsonl ================================================ {"type":"block","data":{"id":"bbn1888mprfrm5fjw9f1je9x3xo","parentId":"","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Personal Tasks (NEW)","fields":{"cardProperties":[{"id":"a9zf59u8x1rf4ywctpcqama7tio","name":"Occurrence","options":[{"color":"propColorBlue","id":"an51dnkenmoog9cetapbc4uyt3y","value":"Daily"},{"color":"propColorOrange","id":"afpy8s7i45frggprmfsqngsocqh","value":"Weekly"},{"color":"propColorPurple","id":"aj4jyekqqssatjcq7r7chmy19ey","value":"Monthly"}],"type":"select"},{"id":"abthng7baedhhtrwsdodeuincqy","name":"Completed","options":[],"type":"checkbox"}],"description":"Use this template to organize your life and track your personal tasks.","icon":"✔️","isTemplate":false,"showDescription":true},"createAt":1640281433899,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c5xamko6rpibhje3bjreenon7ce","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Pay bills","fields":{"contentOrder":["7gwsf4uxtftgjt841zgwydxeere","7j6rbt87htj83bbssod76iumsja","7fjacjgfxjfrf3psxc46wwsgqdo"],"icon":"🔌","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"aj4jyekqqssatjcq7r7chmy19ey","abthng7baedhhtrwsdodeuincqy":"true"}},"createAt":1640366942078,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"co6a88h6og3dm3kkub64kyb71jw","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Buy groceries","fields":{"contentOrder":["amd9sbzwrkpdspkisato6ajmzby","7r749xjm5pfnuib18sefxwezc4o","7zhat99shridtfntr97ek5j7yho","7imjjx8fazty8fcjzkns464nupy","7cbjz6bszwprnby56gfgzqehexc","76x8gh63upjdnm8uso3nja7gjqh","7z6ho1e3dibg6mki7jug84yxpja"],"icon":"🛒","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"afpy8s7i45frggprmfsqngsocqh"}},"createAt":1640365957059,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cr7gz7sempbfqpq7sign4jaeyxc","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Go for a walk","fields":{"contentOrder":["a6b44enuiwpgszm1wt6og1mshqa","aumtoywd8wjy7udm4ntcib4ckpo","75gpszxg6difjmf1j3f5edj3w7a"],"icon":"👣","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"an51dnkenmoog9cetapbc4uyt3y"}},"createAt":1640281433950,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cx7cki81xppd3pdgnyktwbgtzer","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Feed Fluffy","fields":{"contentOrder":["as5kdrix3ibd3jrnqzz94dcqqba"],"icon":"🐱","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"an51dnkenmoog9cetapbc4uyt3y"}},"createAt":1640281433850,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"czowhma7rnpgb3eczbqo3t7fijo","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Gardening","fields":{"contentOrder":[],"icon":"🌳","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"afpy8s7i45frggprmfsqngsocqh"}},"createAt":1640281433750,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vjq4piq89kbds5x5zq39zww7joo","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"List View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":280},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a9zf59u8x1rf4ywctpcqama7tio","abthng7baedhhtrwsdodeuincqy"]},"createAt":1641247999081,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vyeipq97iqbfjtd6fgcbxg6xbme","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board View","fields":{"cardOrder":["co6a88h6og3dm3kkub64kyb71jw","c5xamko6rpibhje3bjreenon7ce","cr7gz7sempbfqpq7sign4jaeyxc","cx7cki81xppd3pdgnyktwbgtzer","czowhma7rnpgb3eczbqo3t7fijo"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"a9zf59u8x1rf4ywctpcqama7tio","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["an51dnkenmoog9cetapbc4uyt3y","afpy8s7i45frggprmfsqngsocqh","aj4jyekqqssatjcq7r7chmy19ey",""],"visiblePropertyIds":["a9zf59u8x1rf4ywctpcqama7tio"]},"createAt":1640281433698,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7fjacjgfxjfrf3psxc46wwsgqdo","parentId":"c5xamko6rpibhje3bjreenon7ce","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Utilities","fields":{"value":true},"createAt":1640367568655,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7gwsf4uxtftgjt841zgwydxeere","parentId":"c5xamko6rpibhje3bjreenon7ce","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Mobile phone","fields":{"value":true},"createAt":1640367517692,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7j6rbt87htj83bbssod76iumsja","parentId":"c5xamko6rpibhje3bjreenon7ce","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Internet","fields":{"value":true},"createAt":1640367560684,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"76x8gh63upjdnm8uso3nja7gjqh","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Cereal","fields":{"value":false},"createAt":1640366017886,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7cbjz6bszwprnby56gfgzqehexc","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Butter","fields":{"value":false},"createAt":1640365985683,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7imjjx8fazty8fcjzkns464nupy","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Bread","fields":{"value":false},"createAt":1640365983209,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7r749xjm5pfnuib18sefxwezc4o","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Milk","fields":{"value":false},"createAt":1640365978720,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7z6ho1e3dibg6mki7jug84yxpja","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Bananas","fields":{"value":false},"createAt":1640367364568,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7zhat99shridtfntr97ek5j7yho","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Eggs","fields":{"value":false},"createAt":1640365980953,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"amd9sbzwrkpdspkisato6ajmzby","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Grocery list","fields":{},"createAt":1640367228497,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"75gpszxg6difjmf1j3f5edj3w7a","parentId":"cr7gz7sempbfqpq7sign4jaeyxc","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"76fwrj36hptg6dywka4k5mt3sph.png"},"createAt":1640368278060,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a6b44enuiwpgszm1wt6og1mshqa","parentId":"cr7gz7sempbfqpq7sign4jaeyxc","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Goal\nWalk at least 10,000 steps every day.","fields":{},"createAt":1640367836067,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aumtoywd8wjy7udm4ntcib4ckpo","parentId":"cr7gz7sempbfqpq7sign4jaeyxc","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Route","fields":{},"createAt":1640368155600,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"as5kdrix3ibd3jrnqzz94dcqqba","parentId":"cx7cki81xppd3pdgnyktwbgtzer","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"","fields":{},"createAt":1640368933239,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} ================================================ FILE: server/assets/templates-boardarchive/bc41mwxg9ybb69pn9j5zna6d36c/board.jsonl ================================================ {"type":"block","data":{"id":"bc41mwxg9ybb69pn9j5zna6d36c","parentId":"","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Project Tasks (NEW)","fields":{"cardProperties":[{"id":"a972dc7a-5f4c-45d2-8044-8c28c69717f1","name":"Status","options":[{"color":"propColorBlue","id":"ayz81h9f3dwp7rzzbdebesc7ute","value":"Not Started"},{"color":"propColorYellow","id":"ar6b8m3jxr3asyxhr8iucdbo6yc","value":"In Progress"},{"color":"propColorRed","id":"afi4o5nhnqc3smtzs1hs3ij34dh","value":"Blocked"},{"color":"propColorGreen","id":"adeo5xuwne3qjue83fcozekz8ko","value":"Completed 🙌"},{"color":"propColorBrown","id":"ahpyxfnnrzynsw3im1psxpkgtpe","value":"Archived"}],"type":"select"},{"id":"d3d682bf-e074-49d9-8df5-7320921c2d23","name":"Priority","options":[{"color":"propColorRed","id":"d3bfb50f-f569-4bad-8a3a-dd15c3f60101","value":"1. High 🔥"},{"color":"propColorYellow","id":"87f59784-b859-4c24-8ebe-17c766e081dd","value":"2. Medium"},{"color":"propColorGray","id":"98a57627-0f76-471d-850d-91f3ed9fd213","value":"3. Low"}],"type":"select"},{"id":"axkhqa4jxr3jcqe4k87g8bhmary","name":"Assignee","options":[],"type":"person"},{"id":"a8daz81s4xjgke1ww6cwik5w7ye","name":"Estimated Hours","options":[],"type":"number"},{"id":"a3zsw7xs8sxy7atj8b6totp3mby","name":"Due Date","options":[],"type":"date"},{"id":"a7gdnz8ff8iyuqmzddjgmgo9ery","name":"Created By","options":[],"type":"createdBy"},{"id":"2a5da320-735c-4093-8787-f56e15cdfeed","name":"Date Created","options":[],"type":"createdTime"}],"description":"Use this template to stay on top of your project tasks and progress.","icon":"🎯","isTemplate":false,"showDescription":true},"createAt":1640281242611,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c68gyx34srjgjxmrs1z8pj7nbce","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Identify dependencies","fields":{"contentOrder":["akqkae666a7bnbgib4ykbexjjey","7b1h5q66pkig4mp948z635dejxy","aepujbmb347ye9j7uikbk3oajqh","76q9tmzey4byqdpimsdxeg1gx3h","79qbaadiuwjgujnz9tgqmmkaaqo","7msorzdb7r3rk3qjncmdxhpqz5o","7izro8efd1irwpepfph4uz56bgh"],"icon":"🔗","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"16","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ayz81h9f3dwp7rzzbdebesc7ute","d3d682bf-e074-49d9-8df5-7320921c2d23":"98a57627-0f76-471d-850d-91f3ed9fd213"}},"createAt":1640364405240,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c6w7rxrootfdw7j4fsftc5gsyoo","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Define project scope","fields":{"contentOrder":["ags74nq3isiywmmkkg8h4tbxcfh","7q7rkcbuqwfffjgrk57yjkydnry","a66dncm7qppd4tjo9886d5bbsaa","7jy54jqerhbnj7r4efpuk3g4cda","716fy9hw4p38a5mf8rq5ap6txoo","7opf3hssh6pn9zyy6toh53r49iw","7g1qskptj9i8gimg1aynyqtnwka"],"icon":"🔬","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"32","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ar6b8m3jxr3asyxhr8iucdbo6yc","d3d682bf-e074-49d9-8df5-7320921c2d23":"87f59784-b859-4c24-8ebe-17c766e081dd"}},"createAt":1640364532461,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cdwqxf4b3utbbxdrgbwtmk9y9eo","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Requirements sign-off","fields":{"contentOrder":["aags5e9sbbfnqtrtf39hoopbxme","7kriyyuos4pgg8k6t8fkcsa7bde","adw7awe3ucp8g781dfq7yw6kfur","7xk7xg6yonbn88fpkihigzn8whr","7b9uyiog56jr1zgonbutxfd7w3c","7r3ua3e7w3jrmpqdngzqs74i1go","76hsxtocpnbnrijxqcfccfkyo1e"],"icon":"🖋️","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"8","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ayz81h9f3dwp7rzzbdebesc7ute","d3d682bf-e074-49d9-8df5-7320921c2d23":"d3bfb50f-f569-4bad-8a3a-dd15c3f60101"}},"createAt":1640281242441,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cfk8kwmuhcfd8m8qicz5aqw4mar","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Project budget approval","fields":{"contentOrder":["a9h4kfaurrprepefrw95i1raoxr","7btyuex8nji8jxn9yieaxgwoe6h","a34hy46bu8bngxcxpz9woui4afa","7ekrgkgq67fdofn9gskpe19bkrc","7ygi1kq3683ya5ydfttuc5rhasr","7qmjyww91rj8a38dsgu5b5wu7hr","7qmmpepfm4byqjqo9m16yp7m3no"],"icon":"💵","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"16","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ayz81h9f3dwp7rzzbdebesc7ute","d3d682bf-e074-49d9-8df5-7320921c2d23":"d3bfb50f-f569-4bad-8a3a-dd15c3f60101"}},"createAt":1640281242677,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ckcntrrmcjbywpciau57gw5suoo","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Conduct market analysis","fields":{"contentOrder":["a6gowxxpgijgip8qzrsp5rmjwqy","771bq4ja3ejfwbgaq78cdpgmjih","asdoj8ffhcirh3x3iys3joeox9o","7k975b49ni7yrfn3nqg7q4x4wde","7e9aj57zouidozb8sf8e1wybywe","71dm4jiu43byubx7pukjiy19pay","719y6x4tkiigd9nwarn1e6ek7ic"],"icon":"📈","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"40","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ar6b8m3jxr3asyxhr8iucdbo6yc","d3d682bf-e074-49d9-8df5-7320921c2d23":"87f59784-b859-4c24-8ebe-17c766e081dd"}},"createAt":1640281242851,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vcuoise4b8jn1ffzujfuacymmmr","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Project Priorities","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"d3d682bf-e074-49d9-8df5-7320921c2d23","hiddenOptionIds":[],"kanbanCalculations":{"":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"87f59784-b859-4c24-8ebe-17c766e081dd":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"98a57627-0f76-471d-850d-91f3ed9fd213":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"d3bfb50f-f569-4bad-8a3a-dd15c3f60101":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"}},"sortOptions":[],"viewType":"board","visibleOptionIds":["d3bfb50f-f569-4bad-8a3a-dd15c3f60101","87f59784-b859-4c24-8ebe-17c766e081dd","98a57627-0f76-471d-850d-91f3ed9fd213",""],"visiblePropertyIds":["a972dc7a-5f4c-45d2-8044-8c28c69717f1","a8daz81s4xjgke1ww6cwik5w7ye"]},"createAt":1640281242551,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vey61xzc6u38ptnpjqaik6ap91e","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Progress Tracker","fields":{"cardOrder":["cfk8kwmuhcfd8m8qicz5aqw4mar","cdwqxf4b3utbbxdrgbwtmk9y9eo","c68gyx34srjgjxmrs1z8pj7nbce","ckcntrrmcjbywpciau57gw5suoo","c6w7rxrootfdw7j4fsftc5gsyoo","coxnjt3ro1in19dd1e3awdt338r"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"a972dc7a-5f4c-45d2-8044-8c28c69717f1","hiddenOptionIds":[],"kanbanCalculations":{"":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"adeo5xuwne3qjue83fcozekz8ko":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"afi4o5nhnqc3smtzs1hs3ij34dh":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"ahpyxfnnrzynsw3im1psxpkgtpe":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"ar6b8m3jxr3asyxhr8iucdbo6yc":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"ayz81h9f3dwp7rzzbdebesc7ute":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"}},"sortOptions":[],"viewType":"board","visibleOptionIds":["ayz81h9f3dwp7rzzbdebesc7ute","ar6b8m3jxr3asyxhr8iucdbo6yc","afi4o5nhnqc3smtzs1hs3ij34dh","adeo5xuwne3qjue83fcozekz8ko","ahpyxfnnrzynsw3im1psxpkgtpe",""],"visiblePropertyIds":["d3d682bf-e074-49d9-8df5-7320921c2d23","a8daz81s4xjgke1ww6cwik5w7ye"]},"createAt":1640281242788,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vfztxwjnegbdh38nfccu3bq1auc","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Task Overview","fields":{"cardOrder":["c6w7rxrootfdw7j4fsftc5gsyoo","ckcntrrmcjbywpciau57gw5suoo","c68gyx34srjgjxmrs1z8pj7nbce","cfk8kwmuhcfd8m8qicz5aqw4mar","cdwqxf4b3utbbxdrgbwtmk9y9eo","cz8p8gofakfby8kzz83j97db8ph","ce1jm5q5i54enhuu4h3kkay1hcc"],"collapsedOptionIds":[],"columnCalculations":{"a8daz81s4xjgke1ww6cwik5w7ye":"sum"},"columnWidths":{"2a5da320-735c-4093-8787-f56e15cdfeed":196,"__title":280,"a8daz81s4xjgke1ww6cwik5w7ye":139,"a972dc7a-5f4c-45d2-8044-8c28c69717f1":141,"d3d682bf-e074-49d9-8df5-7320921c2d23":110},"defaultTemplateId":"czw9es1e89fdpjr7cqptr1xq7qh","filter":{"filters":[],"operation":"and"},"groupById":"","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a972dc7a-5f4c-45d2-8044-8c28c69717f1","d3d682bf-e074-49d9-8df5-7320921c2d23","2a5da320-735c-4093-8787-f56e15cdfeed","a3zsw7xs8sxy7atj8b6totp3mby","axkhqa4jxr3jcqe4k87g8bhmary","a7gdnz8ff8iyuqmzddjgmgo9ery","a8daz81s4xjgke1ww6cwik5w7ye"]},"createAt":1640281242734,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vi49i1138jpnbiqhyd81beme9zy","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Task Calendar","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"a3zsw7xs8sxy7atj8b6totp3mby","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1640361708030,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"76q9tmzey4byqdpimsdxeg1gx3h","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247437494,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"79qbaadiuwjgujnz9tgqmmkaaqo","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247440946,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7b1h5q66pkig4mp948z635dejxy","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247334696,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7izro8efd1irwpepfph4uz56bgh","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247447937,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7msorzdb7r3rk3qjncmdxhpqz5o","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247445214,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aepujbmb347ye9j7uikbk3oajqh","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247378401,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"akqkae666a7bnbgib4ykbexjjey","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247332262,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"716fy9hw4p38a5mf8rq5ap6txoo","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247170396,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7g1qskptj9i8gimg1aynyqtnwka","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247182126,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7jy54jqerhbnj7r4efpuk3g4cda","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247156773,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7opf3hssh6pn9zyy6toh53r49iw","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247176917,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7q7rkcbuqwfffjgrk57yjkydnry","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247131586,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a66dncm7qppd4tjo9886d5bbsaa","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247135038,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ags74nq3isiywmmkkg8h4tbxcfh","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247112211,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"76hsxtocpnbnrijxqcfccfkyo1e","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247486848,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7b9uyiog56jr1zgonbutxfd7w3c","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247480724,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7kriyyuos4pgg8k6t8fkcsa7bde","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247352753,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7r3ua3e7w3jrmpqdngzqs74i1go","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247483695,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7xk7xg6yonbn88fpkihigzn8whr","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247478297,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aags5e9sbbfnqtrtf39hoopbxme","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247350239,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"adw7awe3ucp8g781dfq7yw6kfur","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247399161,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7btyuex8nji8jxn9yieaxgwoe6h","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247342345,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7ekrgkgq67fdofn9gskpe19bkrc","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247459230,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7qmjyww91rj8a38dsgu5b5wu7hr","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247464903,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7qmmpepfm4byqjqo9m16yp7m3no","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247468228,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7ygi1kq3683ya5ydfttuc5rhasr","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247461754,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a34hy46bu8bngxcxpz9woui4afa","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247389505,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a9h4kfaurrprepefrw95i1raoxr","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247339781,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"719y6x4tkiigd9nwarn1e6ek7ic","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247428974,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"71dm4jiu43byubx7pukjiy19pay","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247425545,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"771bq4ja3ejfwbgaq78cdpgmjih","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247327922,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7e9aj57zouidozb8sf8e1wybywe","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247421647,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7k975b49ni7yrfn3nqg7q4x4wde","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247417179,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a6gowxxpgijgip8qzrsp5rmjwqy","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247325247,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"asdoj8ffhcirh3x3iys3joeox9o","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247365651,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"73a715h3xkiye9jj9px3daujgpa","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247243580,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"75afimcsuqby6xxq39wiae9obme","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247239940,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7dodh1pgw73yq78pgtmk3ckc9fr","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247212754,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7ttgtruigcbfzdmxkhmzt6kp6dh","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247226415,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7u7mmiit57b8i8gsp6mc6x7h9he","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247248372,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"adxx8y691qf8btg7w8mx6x78w9y","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247210152,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"afatxnq346jbcin9iisryo38grr","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247215942,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} ================================================ FILE: server/assets/templates-boardarchive/bcm39o11e4ib8tye8mt6iyuec9o/board.jsonl ================================================ {"type":"board","data":{"id":"bcm39o11e4ib8tye8mt6iyuec9o","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Company Goals \u0026 OKRs","description":"Use this template to plan your company goals and OKRs more efficiently.","icon":"⛳","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"a6amddgmrzakw66cidqzgk6p4ge","name":"Objective","options":[{"color":"propColorGreen","id":"auw3afh3kfhrfgmjr8muiz137jy","value":"Grow Revenue"},{"color":"propColorOrange","id":"apqfjst8massbjjhpcsjs3y1yqa","value":"Delight Customers"},{"color":"propColorPurple","id":"ao9b5pxyt7tkgdohzh9oaustdhr","value":"Drive Product Adoption"}],"type":"select"},{"id":"a17ryhi1jfsboxkwkztwawhmsxe","name":"Status","options":[{"color":"propColorGray","id":"a6robxx81diugpjq5jkezz3j1fo","value":"Not Started"},{"color":"propColorBlue","id":"a8nukezwwmknqwjsygg7eaxs9te","value":"In Progress"},{"color":"propColorYellow","id":"apnt1f7na9rzgk1rt49keg7xbiy","value":"At Risk"},{"color":"propColorRed","id":"axbz3m1amss335wzwf9s7pqjzxr","value":"Missed"},{"color":"propColorGreen","id":"abzfwnn6rmtfzyq5hg8uqmpsncy","value":"Complete 🙌"}],"type":"select"},{"id":"azzbawji5bksj69sekcs4srm1ky","name":"Department","options":[{"color":"propColorBrown","id":"aw5i7hmpadn6mbwbz955ubarhme","value":"Engineering"},{"color":"propColorBlue","id":"afkxpcjqjypu7hhar7banxau91h","value":"Product"},{"color":"propColorOrange","id":"aehoa17cz18rqnrf75g7dwhphpr","value":"Marketing"},{"color":"propColorGreen","id":"agrfeaoj7d8p5ianw5iaf3191ae","value":"Sales"},{"color":"propColorYellow","id":"agm9p6gcq15ueuzqq3wd4be39wy","value":"Support"},{"color":"propColorPink","id":"aucop7kw6xwodcix6zzojhxih6r","value":"Design"},{"color":"propColorPurple","id":"afust91f3g8ht368mkn5x9tgf1o","value":"Finance"},{"color":"propColorGray","id":"acocxxwjurud1jixhp7nowdig7y","value":"Human Resources"}],"type":"select"},{"id":"adp5ft3kgz7r5iqq3tnwg551der","name":"Priority","options":[{"color":"propColorRed","id":"a8zg3rjtf4swh7smsjxpsn743rh","value":"P1 🔥"},{"color":"propColorYellow","id":"as555ipyzopjjpfb5rjtssecw5e","value":"P2"},{"color":"propColorGray","id":"a1ts3ftyr8nocsicui98c89uxjy","value":"P3"}],"type":"select"},{"id":"aqxyzkdrs4egqf7yk866ixkaojc","name":"Quarter","options":[{"color":"propColorBlue","id":"ahfbn1jsmhydym33ygxwg5jt3kh","value":"Q1"},{"color":"propColorBrown","id":"awfu37js3fomfkkczm1zppac57a","value":"Q2"},{"color":"propColorGreen","id":"anruuoyez51r3yjxuoc8zoqnwaw","value":"Q3"},{"color":"propColorPurple","id":"acb6dqqs6yson7bbzx6jk9bghjh","value":"Q4"}],"type":"select"},{"id":"adu6mebzpibq6mgcswk69xxmnqe","name":"Due Date","options":[],"type":"date"},{"id":"asope3bddhm4gpsng5cfu4hf6rh","name":"Assignee","options":[],"type":"multiPerson"},{"id":"ajwxp866f9obs1kutfwaa5ru7fe","name":"Target","options":[],"type":"number"},{"id":"azqnyswk6s1boiwuthscm78qwuo","name":"Actual","options":[],"type":"number"},{"id":"ahz3fmjnaguec8hce7xq3h5cjdr","name":"Completion (%)","options":[],"type":"text"},{"id":"a17bfcgnzmkwhziwa4tr38kiw5r","name":"Note","options":[],"type":"text"}],"createAt":1667430124226,"updateAt":1667431508571,"deleteAt":0}} {"type":"block","data":{"id":"vangk4cpd5fgpbr7635tx6oxg7c","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Quarter","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":452,"a17ryhi1jfsboxkwkztwawhmsxe":148,"a6amddgmrzakw66cidqzgk6p4ge":230,"azzbawji5bksj69sekcs4srm1ky":142},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"aqxyzkdrs4egqf7yk866ixkaojc","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a6amddgmrzakw66cidqzgk6p4ge","a17ryhi1jfsboxkwkztwawhmsxe","azzbawji5bksj69sekcs4srm1ky","adp5ft3kgz7r5iqq3tnwg551der","aqxyzkdrs4egqf7yk866ixkaojc","adu6mebzpibq6mgcswk69xxmnqe","asope3bddhm4gpsng5cfu4hf6rh","ajwxp866f9obs1kutfwaa5ru7fe","azqnyswk6s1boiwuthscm78qwuo","ahz3fmjnaguec8hce7xq3h5cjdr","a17bfcgnzmkwhziwa4tr38kiw5r"]},"createAt":1667431291178,"updateAt":1667431333436,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"vr1jnxkxi8pf9z83fhr4qbsbxao","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Objectives","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":387,"a17ryhi1jfsboxkwkztwawhmsxe":134,"a6amddgmrzakw66cidqzgk6p4ge":183,"aqxyzkdrs4egqf7yk866ixkaojc":100},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"a6amddgmrzakw66cidqzgk6p4ge","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a6amddgmrzakw66cidqzgk6p4ge","a17ryhi1jfsboxkwkztwawhmsxe","azzbawji5bksj69sekcs4srm1ky","adp5ft3kgz7r5iqq3tnwg551der","aqxyzkdrs4egqf7yk866ixkaojc","adu6mebzpibq6mgcswk69xxmnqe","asope3bddhm4gpsng5cfu4hf6rh","ajwxp866f9obs1kutfwaa5ru7fe","azqnyswk6s1boiwuthscm78qwuo","ahz3fmjnaguec8hce7xq3h5cjdr","a17bfcgnzmkwhziwa4tr38kiw5r"]},"createAt":1667431221976,"updateAt":1667431420460,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"c3m6mgymw978wjecydz16io868h","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Improve customer NPS score","fields":{"contentOrder":[],"icon":"💯","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a8nukezwwmknqwjsygg7eaxs9te","a6amddgmrzakw66cidqzgk6p4ge":"apqfjst8massbjjhpcsjs3y1yqa","adp5ft3kgz7r5iqq3tnwg551der":"as555ipyzopjjpfb5rjtssecw5e","ahz3fmjnaguec8hce7xq3h5cjdr":"82%","ajwxp866f9obs1kutfwaa5ru7fe":"8.5","aqxyzkdrs4egqf7yk866ixkaojc":"anruuoyez51r3yjxuoc8zoqnwaw","azqnyswk6s1boiwuthscm78qwuo":"7","azzbawji5bksj69sekcs4srm1ky":"agm9p6gcq15ueuzqq3wd4be39wy"}},"createAt":1667430924551,"updateAt":1667430962900,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"ce9u86wofitrb5ns4qp5w1ij1nh","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Generate more Marketing Qualified Leads (MQLs)","fields":{"contentOrder":[],"icon":"🛣️","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a8nukezwwmknqwjsygg7eaxs9te","a6amddgmrzakw66cidqzgk6p4ge":"auw3afh3kfhrfgmjr8muiz137jy","adp5ft3kgz7r5iqq3tnwg551der":"as555ipyzopjjpfb5rjtssecw5e","ahz3fmjnaguec8hce7xq3h5cjdr":"65%","ajwxp866f9obs1kutfwaa5ru7fe":"100","aqxyzkdrs4egqf7yk866ixkaojc":"ahfbn1jsmhydym33ygxwg5jt3kh","azqnyswk6s1boiwuthscm78qwuo":"65","azzbawji5bksj69sekcs4srm1ky":"aehoa17cz18rqnrf75g7dwhphpr"}},"createAt":1667430791375,"updateAt":1667430832892,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"cjkscjjex6fg8i8aa3umxof9wfc","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Increase customer retention","fields":{"contentOrder":[],"icon":"😀","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a8nukezwwmknqwjsygg7eaxs9te","a6amddgmrzakw66cidqzgk6p4ge":"apqfjst8massbjjhpcsjs3y1yqa","adp5ft3kgz7r5iqq3tnwg551der":"a8zg3rjtf4swh7smsjxpsn743rh","ahz3fmjnaguec8hce7xq3h5cjdr":"66%","ajwxp866f9obs1kutfwaa5ru7fe":"90% customer retention rate","aqxyzkdrs4egqf7yk866ixkaojc":"acb6dqqs6yson7bbzx6jk9bghjh","azqnyswk6s1boiwuthscm78qwuo":"60%","azzbawji5bksj69sekcs4srm1ky":"afkxpcjqjypu7hhar7banxau91h"}},"createAt":1667430973987,"updateAt":1667431007817,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"ckxdhpf5bhf8i7n13fgbs4155ec","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Hit company global sales target","fields":{"contentOrder":[],"icon":"💰","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a6robxx81diugpjq5jkezz3j1fo","a6amddgmrzakw66cidqzgk6p4ge":"auw3afh3kfhrfgmjr8muiz137jy","adp5ft3kgz7r5iqq3tnwg551der":"a8zg3rjtf4swh7smsjxpsn743rh","ahz3fmjnaguec8hce7xq3h5cjdr":"15%","ajwxp866f9obs1kutfwaa5ru7fe":"50MM","aqxyzkdrs4egqf7yk866ixkaojc":"awfu37js3fomfkkczm1zppac57a","azqnyswk6s1boiwuthscm78qwuo":"7.5MM","azzbawji5bksj69sekcs4srm1ky":"agrfeaoj7d8p5ianw5iaf3191ae"}},"createAt":1667430875599,"updateAt":1667430909496,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"cn1x4niym7tnpjg61jf1su67wcr","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Increase user signups by 30%","fields":{"contentOrder":[],"icon":"💳","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a6robxx81diugpjq5jkezz3j1fo","a6amddgmrzakw66cidqzgk6p4ge":"ao9b5pxyt7tkgdohzh9oaustdhr","adp5ft3kgz7r5iqq3tnwg551der":"as555ipyzopjjpfb5rjtssecw5e","ahz3fmjnaguec8hce7xq3h5cjdr":"0%","ajwxp866f9obs1kutfwaa5ru7fe":"1,000","aqxyzkdrs4egqf7yk866ixkaojc":"acb6dqqs6yson7bbzx6jk9bghjh","azqnyswk6s1boiwuthscm78qwuo":"0","azzbawji5bksj69sekcs4srm1ky":"afkxpcjqjypu7hhar7banxau91h"}},"createAt":1667431085923,"updateAt":1667431132757,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"cpa534b5natgmunis8u1ixb55pw","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Add 10 new customers in the EU","fields":{"contentOrder":[],"icon":"🌍","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"apnt1f7na9rzgk1rt49keg7xbiy","a6amddgmrzakw66cidqzgk6p4ge":"auw3afh3kfhrfgmjr8muiz137jy","adp5ft3kgz7r5iqq3tnwg551der":"a1ts3ftyr8nocsicui98c89uxjy","ahz3fmjnaguec8hce7xq3h5cjdr":"30%","ajwxp866f9obs1kutfwaa5ru7fe":"10","aqxyzkdrs4egqf7yk866ixkaojc":"acb6dqqs6yson7bbzx6jk9bghjh","azqnyswk6s1boiwuthscm78qwuo":"3","azzbawji5bksj69sekcs4srm1ky":"agrfeaoj7d8p5ianw5iaf3191ae"}},"createAt":1667430190782,"updateAt":1667430844747,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"cq4krpnzqqfne3khfyhnn3c6r5r","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Launch 3 key features","fields":{"contentOrder":[],"icon":"🚀","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"apnt1f7na9rzgk1rt49keg7xbiy","a6amddgmrzakw66cidqzgk6p4ge":"ao9b5pxyt7tkgdohzh9oaustdhr","adp5ft3kgz7r5iqq3tnwg551der":"a8zg3rjtf4swh7smsjxpsn743rh","ahz3fmjnaguec8hce7xq3h5cjdr":"33%","ajwxp866f9obs1kutfwaa5ru7fe":"3","aqxyzkdrs4egqf7yk866ixkaojc":"anruuoyez51r3yjxuoc8zoqnwaw","azqnyswk6s1boiwuthscm78qwuo":"1","azzbawji5bksj69sekcs4srm1ky":"aw5i7hmpadn6mbwbz955ubarhme"}},"createAt":1667431144882,"updateAt":1667431177540,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"cugiq6j98utg1zdekbpjpufo51y","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Reduce bug backlog by 50%","fields":{"contentOrder":[],"icon":"🐞","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"abzfwnn6rmtfzyq5hg8uqmpsncy","a6amddgmrzakw66cidqzgk6p4ge":"apqfjst8massbjjhpcsjs3y1yqa","adp5ft3kgz7r5iqq3tnwg551der":"a1ts3ftyr8nocsicui98c89uxjy","ahz3fmjnaguec8hce7xq3h5cjdr":"100%","ajwxp866f9obs1kutfwaa5ru7fe":"75","aqxyzkdrs4egqf7yk866ixkaojc":"awfu37js3fomfkkczm1zppac57a","azqnyswk6s1boiwuthscm78qwuo":"75","azzbawji5bksj69sekcs4srm1ky":"aw5i7hmpadn6mbwbz955ubarhme"}},"createAt":1667431018282,"updateAt":1667431070950,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} {"type":"block","data":{"id":"vx4ng6gtakbntt8k98znkzszc1a","parentId":"bm4ubx56krp4zwyfcqh7nxiigbr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Departments","fields":{"cardOrder":["cpa534b5natgmunis8u1ixb55pw"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"azzbawji5bksj69sekcs4srm1ky","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["aw5i7hmpadn6mbwbz955ubarhme","afkxpcjqjypu7hhar7banxau91h","aehoa17cz18rqnrf75g7dwhphpr","agrfeaoj7d8p5ianw5iaf3191ae","agm9p6gcq15ueuzqq3wd4be39wy","aucop7kw6xwodcix6zzojhxih6r","afust91f3g8ht368mkn5x9tgf1o","acocxxwjurud1jixhp7nowdig7y"],"visiblePropertyIds":[]},"createAt":1667430124232,"updateAt":1667431286030,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}} ================================================ FILE: server/assets/templates-boardarchive/bd65qbzuqupfztpg31dgwgwm5ga/board.jsonl ================================================ {"type":"block","data":{"id":"bd65qbzuqupfztpg31dgwgwm5ga","parentId":"","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Personal Goals (NEW)","fields":{"cardProperties":[{"id":"af6fcbb8-ca56-4b73-83eb-37437b9a667d","name":"Status","options":[{"color":"propColorRed","id":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","value":"To Do"},{"color":"propColorYellow","id":"77c539af-309c-4db1-8329-d20ef7e9eacd","value":"Doing"},{"color":"propColorGreen","id":"98bdea27-0cce-4cde-8dc6-212add36e63a","value":"Done 🙌"}],"type":"select"},{"id":"d9725d14-d5a8-48e5-8de1-6f8c004a9680","name":"Category","options":[{"color":"propColorPurple","id":"3245a32d-f688-463b-87f4-8e7142c1b397","value":"Life Skills"},{"color":"propColorGreen","id":"80be816c-fc7a-4928-8489-8b02180f4954","value":"Finance"},{"color":"propColorOrange","id":"ffb3f951-b47f-413b-8f1d-238666728008","value":"Health"}],"type":"select"},{"id":"d6b1249b-bc18-45fc-889e-bec48fce80ef","name":"Target","options":[{"color":"propColorBlue","id":"9a090e33-b110-4268-8909-132c5002c90e","value":"Q1"},{"color":"propColorBrown","id":"0a82977f-52bf-457b-841b-e2b7f76fb525","value":"Q2"},{"color":"propColorGreen","id":"6e7139e4-5358-46bb-8c01-7b029a57b80a","value":"Q3"},{"color":"propColorPurple","id":"d5371c63-66bf-4468-8738-c4dc4bea4843","value":"Q4"}],"type":"select"},{"id":"ajy6xbebzopojaenbnmfpgtdwso","name":"Due Date","options":[],"type":"date"}],"description":"Use this template to set and accomplish new personal goals.","icon":"⛰️","isTemplate":false,"showDescription":true},"createAt":1641246775089,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c76haqhzin78q5dkfko7kwhbjjh","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Start a daily journal","fields":{"contentOrder":[],"icon":"✍️","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","d6b1249b-bc18-45fc-889e-bec48fce80ef":"0a82977f-52bf-457b-841b-e2b7f76fb525","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"3245a32d-f688-463b-87f4-8e7142c1b397"}},"createAt":1641246774828,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ca3byfg7iq3g8zjpg1t8hwa6ekh","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Run 3 times a week","fields":{"contentOrder":[],"icon":"🏃","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","d6b1249b-bc18-45fc-889e-bec48fce80ef":"6e7139e4-5358-46bb-8c01-7b029a57b80a","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"ffb3f951-b47f-413b-8f1d-238666728008"}},"createAt":1641246775039,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ckng5n1ag5f8m5gfdifn7ijof9y","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Learn to paint","fields":{"contentOrder":[],"icon":"🎨","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"77c539af-309c-4db1-8329-d20ef7e9eacd","d6b1249b-bc18-45fc-889e-bec48fce80ef":"9a090e33-b110-4268-8909-132c5002c90e","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"3245a32d-f688-463b-87f4-8e7142c1b397"}},"createAt":1641246774928,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cw9zofoi6dj8x7x8r6ypebpwpuc","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Open retirement account","fields":{"contentOrder":[],"icon":"🏦","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","d6b1249b-bc18-45fc-889e-bec48fce80ef":"0a82977f-52bf-457b-841b-e2b7f76fb525","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"80be816c-fc7a-4928-8489-8b02180f4954"}},"createAt":1641246774987,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"v9sj7oekk1jr1pemtf9rps7fate","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"af6fcbb8-ca56-4b73-83eb-37437b9a667d","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","77c539af-309c-4db1-8329-d20ef7e9eacd","98bdea27-0cce-4cde-8dc6-212add36e63a",""],"visiblePropertyIds":["d9725d14-d5a8-48e5-8de1-6f8c004a9680","d6b1249b-bc18-45fc-889e-bec48fce80ef"]},"createAt":1641246774878,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vrpmc8r6nj7fcmdkp18cpcekzco","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Calendar View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"ajy6xbebzopojaenbnmfpgtdwso","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641247726340,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vw9mbn66j97dwb8jhqiq7zuum5e","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Date","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"d6b1249b-bc18-45fc-889e-bec48fce80ef","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["9a090e33-b110-4268-8909-132c5002c90e","0a82977f-52bf-457b-841b-e2b7f76fb525","6e7139e4-5358-46bb-8c01-7b029a57b80a","d5371c63-66bf-4468-8738-c4dc4bea4843",""],"visiblePropertyIds":["d9725d14-d5a8-48e5-8de1-6f8c004a9680"]},"createAt":1641246775139,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} ================================================ FILE: server/assets/templates-boardarchive/bgi1yqiis8t8xdqxgnet8ebutky/board.jsonl ================================================ {"type":"board","data":{"id":"bgi1yqiis8t8xdqxgnet8ebutky","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Sprint Planner ","description":"Use this template to plan your sprints and manage your releases more efficiently.","icon":"🗓️","showDescription":true,"isTemplate":false,"templateVersion":4,"properties":{},"cardProperties":[{"id":"50117d52-bcc7-4750-82aa-831a351c44a0","name":"Status","options":[{"color":"propColorGray","id":"aft5bzo7h9aspqgrx3jpy5tzrer","value":"Not Started"},{"color":"propColorOrange","id":"abrfos7e7eczk9rqw6y5abadm1y","value":"Next Up"},{"color":"propColorBlue","id":"ax8wzbka5ahs3zziji3pp4qp9mc","value":"In Progress"},{"color":"propColorYellow","id":"atabdfbdmjh83136d5e5oysxybw","value":"In Review"},{"color":"propColorPink","id":"ace1bzypd586kkyhcht5qqd9eca","value":"Approved"},{"color":"propColorRed","id":"aay656c9m1hzwxc9ch5ftymh3nw","value":"Blocked"},{"color":"propColorGreen","id":"a6ghze4iy441qhsh3eijnc8hwze","value":"Complete 🙌"}],"type":"select"},{"id":"20717ad3-5741-4416-83f1-6f133fff3d11","name":"Type","options":[{"color":"propColorYellow","id":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","value":"Epic ⛰"},{"color":"propColorGray","id":"a5yxq8rbubrpnoommfwqmty138h","value":"Feature 🏗"},{"color":"propColorOrange","id":"apht1nt5ryukdmxkh6fkfn6rgoy","value":"User Story 📖"},{"color":"propColorGreen","id":"aiycbuo3dr5k4xxbfr7coem8ono","value":"Task ⛏"},{"color":"propColorRed","id":"aomnawq4551cbbzha9gxnmb3z5w","value":"Bug 🐞"}],"type":"select"},{"id":"60985f46-3e41-486e-8213-2b987440ea1c","name":"Sprint","options":[{"color":"propColorBrown","id":"c01676ca-babf-4534-8be5-cce2287daa6c","value":"Sprint 1"},{"color":"propColorPurple","id":"ed4a5340-460d-461b-8838-2c56e8ee59fe","value":"Sprint 2"},{"color":"propColorBlue","id":"14892380-1a32-42dd-8034-a0cea32bc7e6","value":"Sprint 3"}],"type":"select"},{"id":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","name":"Priority","options":[{"color":"propColorRed","id":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9","value":"P1 🔥"},{"color":"propColorYellow","id":"e6a7f297-4440-4783-8ab3-3af5ba62ca11","value":"P2"},{"color":"propColorGray","id":"c62172ea-5da7-4dec-8186-37267d8ee9a7","value":"P3"}],"type":"select"},{"id":"aphg37f7zbpuc3bhwhp19s1ribh","name":"Assignee","options":[],"type":"multiPerson"},{"id":"a4378omyhmgj3bex13sj4wbpfiy","name":"Due Date","options":[],"type":"date"},{"id":"ai7ajsdk14w7x5s8up3dwir77te","name":"Story Points","options":[],"type":"number"},{"id":"a1g6i613dpe9oryeo71ex3c86hy","name":"Design Link","options":[],"type":"url"},{"id":"aeomttrbhhsi8bph31jn84sto6h","name":"Created Time","options":[],"type":"createdTime"},{"id":"ax9f8so418s6s65hi5ympd93i6a","name":"Created By","options":[],"type":"createdBy"}],"createAt":1657660691136,"updateAt":1667496289175,"deleteAt":0}} {"type":"block","data":{"id":"vdusd7mmojjy7dqtcews89kbawe","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Sprint","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{"ai7ajsdk14w7x5s8up3dwir77te":"count"},"columnWidths":{"20717ad3-5741-4416-83f1-6f133fff3d11":128,"50117d52-bcc7-4750-82aa-831a351c44a0":126,"__title":280,"a1g6i613dpe9oryeo71ex3c86hy":159,"aeomttrbhhsi8bph31jn84sto6h":141,"ax9f8so418s6s65hi5ympd93i6a":183,"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":100},"defaultTemplateId":"c9pwabyseiibumq71b9ykxsotqe","filter":{"filters":[],"operation":"and"},"groupById":"60985f46-3e41-486e-8213-2b987440ea1c","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["50117d52-bcc7-4750-82aa-831a351c44a0","20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","aphg37f7zbpuc3bhwhp19s1ribh","a4378omyhmgj3bex13sj4wbpfiy","ai7ajsdk14w7x5s8up3dwir77te","a1g6i613dpe9oryeo71ex3c86hy","aeomttrbhhsi8bph31jn84sto6h","ax9f8so418s6s65hi5ympd93i6a"]},"createAt":1667495373961,"updateAt":1667507826320,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"c9pwabyseiibumq71b9ykxsotqe","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"User Story","fields":{"contentOrder":["anfmjd4qmxffj3bckd9nei61ioe"],"icon":"📖","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"apht1nt5ryukdmxkh6fkfn6rgoy","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer"}},"createAt":1667496557683,"updateAt":1667496593762,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"c9rh5kubchfy1tejtiwbsw6z5xr","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Horizontal scroll issue","fields":{"contentOrder":["aiazua9893f8tmgn5jcn476ieay","ayko7csybxpgg7ejnybqoimp6co","7n75owjmi1bfnbcdswmscqpon5r"],"icon":"〰️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"aomnawq4551cbbzha9gxnmb3z5w","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","ai7ajsdk14w7x5s8up3dwir77te":"1","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691350,"updateAt":1667495472233,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"cc98t3whwhbnd5mx4qehmg43wpy","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Login screen not loading","fields":{"contentOrder":["ahaytdn7aajy63dsca6dhmzew6e","awcedibyeufyazxdy6x83wiqtne","73u5teq68rbrsfensjkigjfsk3h"],"icon":"🖥️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"aomnawq4551cbbzha9gxnmb3z5w","50117d52-bcc7-4750-82aa-831a351c44a0":"abrfos7e7eczk9rqw6y5abadm1y","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","ai7ajsdk14w7x5s8up3dwir77te":"1","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9"}},"createAt":1657660691556,"updateAt":1667495472221,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"cfb7jed1iz3ntx8rrcc5pphaixc","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Move cards across boards","fields":{"contentOrder":["aqm83zjjchi8a8nramuatg88cer"],"icon":"🚚","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"abrfos7e7eczk9rqw6y5abadm1y","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"2","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691670,"updateAt":1667495472240,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"cg1ausqdw9bdpbgx1aaoas6umaa","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Cross-team collaboration","fields":{"contentOrder":["aanatt8ay8iyj7n4gxstxijiber"],"icon":"🤝","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","60985f46-3e41-486e-8213-2b987440ea1c":"14892380-1a32-42dd-8034-a0cea32bc7e6","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"3","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"c62172ea-5da7-4dec-8186-37267d8ee9a7"}},"createAt":1657660691791,"updateAt":1667495472239,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"ci1zytcoz1jrj3qncas3fjc9ruo","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Bug","fields":{"contentOrder":["ajgyboqst3fy1zb989wkuqiaz5o","akzfz1eh8uj87mfkgucgmtdzwzw","7zbiceo9toidtbjya5xxo6fcsow"],"icon":"🐞","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"aomnawq4551cbbzha9gxnmb3z5w","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer"}},"createAt":1667507786809,"updateAt":1667507806029,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"cs7rqsonyr7gofepxn84ui8niyy","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Standard properties","fields":{"contentOrder":["ax5npjmoqo7b87fzjo518ahfdkc"],"icon":"🏷️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","60985f46-3e41-486e-8213-2b987440ea1c":"14892380-1a32-42dd-8034-a0cea32bc7e6","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"3","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691454,"updateAt":1667495472304,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"cxi14orfaajfsjjpgok167kc78y","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Epic","fields":{"contentOrder":["aoer81hcfmt818d1awj3bnntkzh"],"icon":"🤝","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"3","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"c62172ea-5da7-4dec-8186-37267d8ee9a7"}},"createAt":1667496390689,"updateAt":1667496493419,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"cxmfbp7wdoifdzdztkrurxe3pgh","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Global templates","fields":{"contentOrder":["a6r3jdde39ibbury8s8zib5prjy"],"icon":"🖼️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"a6ghze4iy441qhsh3eijnc8hwze","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"2","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691245,"updateAt":1667496491020,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"cxom5chmr5tna9ru4na34dbhmur","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Feature","fields":{"contentOrder":["ad9bf7wpdwbnwbebkptg3puwu4c"],"icon":"🏗️","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer"}},"createAt":1667496496593,"updateAt":1667496522591,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"v4fpda1kk3jgy8ctqyw9ey4fwye","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":["cxmfbp7wdoifdzdztkrurxe3pgh","c9rh5kubchfy1tejtiwbsw6z5xr","cfb7jed1iz3ntx8rrcc5pphaixc","cc98t3whwhbnd5mx4qehmg43wpy","cs7rqsonyr7gofepxn84ui8niyy","cg1ausqdw9bdpbgx1aaoas6umaa"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cidz4imnqhir48brz6e8hxhfrhy","filter":{"filters":[],"operation":"and"},"groupById":"50117d52-bcc7-4750-82aa-831a351c44a0","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["aft5bzo7h9aspqgrx3jpy5tzrer","abrfos7e7eczk9rqw6y5abadm1y","ax8wzbka5ahs3zziji3pp4qp9mc","atabdfbdmjh83136d5e5oysxybw","ace1bzypd586kkyhcht5qqd9eca","aay656c9m1hzwxc9ch5ftymh3nw","a6ghze4iy441qhsh3eijnc8hwze"],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1657660691994,"updateAt":1667496285840,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"vj3bern6637nt7c5edfx8qx6b6h","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Type","fields":{"cardOrder":["cc98t3whwhbnd5mx4qehmg43wpy","cfb7jed1iz3ntx8rrcc5pphaixc","cs7rqsonyr7gofepxn84ui8niyy","c9rh5kubchfy1tejtiwbsw6z5xr","cxmfbp7wdoifdzdztkrurxe3pgh","cg1ausqdw9bdpbgx1aaoas6umaa"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cidz4imnqhir48brz6e8hxhfrhy","filter":{"filters":[],"operation":"and"},"groupById":"20717ad3-5741-4416-83f1-6f133fff3d11","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["424ea5e3-9aa1-4075-8c5c-01b44b66e634","a5yxq8rbubrpnoommfwqmty138h","apht1nt5ryukdmxkh6fkfn6rgoy","aiycbuo3dr5k4xxbfr7coem8ono","aomnawq4551cbbzha9gxnmb3z5w"],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1657660691890,"updateAt":1667496327144,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"anfmjd4qmxffj3bckd9nei61ioe","parentId":"c9pwabyseiibumq71b9ykxsotqe","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1667496557692,"updateAt":1667496557692,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"7n75owjmi1bfnbcdswmscqpon5r","parentId":"c9rh5kubchfy1tejtiwbsw6z5xr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7tmfu5iqju3n1mdfwi5gru89qmw.png"},"createAt":1657660690017,"updateAt":1657660690017,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"aiazua9893f8tmgn5jcn476ieay","parentId":"c9rh5kubchfy1tejtiwbsw6z5xr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1657660691037,"updateAt":1657660691037,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"ayko7csybxpgg7ejnybqoimp6co","parentId":"c9rh5kubchfy1tejtiwbsw6z5xr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1657660690831,"updateAt":1657660690831,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"73u5teq68rbrsfensjkigjfsk3h","parentId":"cc98t3whwhbnd5mx4qehmg43wpy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7b9xk9boj3fbqfm3umeaaizp8qr.png"},"createAt":1657660690116,"updateAt":1657660690116,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"ahaytdn7aajy63dsca6dhmzew6e","parentId":"cc98t3whwhbnd5mx4qehmg43wpy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1657660690422,"updateAt":1657660690422,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"awcedibyeufyazxdy6x83wiqtne","parentId":"cc98t3whwhbnd5mx4qehmg43wpy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1657660690318,"updateAt":1657660690318,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"aqm83zjjchi8a8nramuatg88cer","parentId":"cfb7jed1iz3ntx8rrcc5pphaixc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1657660690521,"updateAt":1657660690521,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"aumokx4tdmjrgxgy4o8s3jow8ha","parentId":"cfmk7771httynm8r7rm8cbrmrya","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1657729295838,"updateAt":1657729295838,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"aydywea4hq3rytf3k7a9y4iqtbe","parentId":"cfmk7771httynm8r7rm8cbrmrya","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1657729295724,"updateAt":1657729295724,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"aanatt8ay8iyj7n4gxstxijiber","parentId":"cg1ausqdw9bdpbgx1aaoas6umaa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n\n## Motivation\n*[Brief description on why this is needed]*\n\n## Acceptance Criteria\n - *[Criteron 1]*\n - *[Criteron 2]*\n - ...\n\n## Personas\n - *[Persona A]*\n - *[Persona B]*\n - ...\n\n## Reference Materials\n - *[Links to other relevant documents as needed]*\n - ...","fields":{},"createAt":1657660690218,"updateAt":1657660690218,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"7zbiceo9toidtbjya5xxo6fcsow","parentId":"ci1zytcoz1jrj3qncas3fjc9ruo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7tmfu5iqju3n1mdfwi5gru89qmw.png"},"createAt":1667507786817,"updateAt":1667507786817,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"ajgyboqst3fy1zb989wkuqiaz5o","parentId":"ci1zytcoz1jrj3qncas3fjc9ruo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1667507786830,"updateAt":1667507786830,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"akzfz1eh8uj87mfkgucgmtdzwzw","parentId":"ci1zytcoz1jrj3qncas3fjc9ruo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1667507786823,"updateAt":1667507786823,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"ax5npjmoqo7b87fzjo518ahfdkc","parentId":"cs7rqsonyr7gofepxn84ui8niyy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1657660690723,"updateAt":1657660690723,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"aoer81hcfmt818d1awj3bnntkzh","parentId":"cxi14orfaajfsjjpgok167kc78y","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n\n## Motivation\n*[Brief description on why this is needed]*\n\n## Acceptance Criteria\n - *[Criteron 1]*\n - *[Criteron 2]*\n - ...\n\n## Personas\n - *[Persona A]*\n - *[Persona B]*\n - ...\n\n## Reference Materials\n - *[Links to other relevant documents as needed]*\n - ...","fields":{},"createAt":1667496390699,"updateAt":1667496390699,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"a6r3jdde39ibbury8s8zib5prjy","parentId":"cxmfbp7wdoifdzdztkrurxe3pgh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1657660690935,"updateAt":1657660690935,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} {"type":"block","data":{"id":"ad9bf7wpdwbnwbebkptg3puwu4c","parentId":"cxom5chmr5tna9ru4na34dbhmur","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1667496496600,"updateAt":1667496496600,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}} ================================================ FILE: server/assets/templates-boardarchive/bh4pkixqsjift58e1qy6htrgeay/board.jsonl ================================================ {"type":"board","data":{"id":"bh4pkixqsjift58e1qy6htrgeay","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"User Research Sessions","description":"Use this template to manage and keep track of all your user research sessions.","icon":"🔬","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"aaebj5fyx493eezx6ukxiwydgty","name":"Status","options":[{"color":"propColorGray","id":"af6hjb3ysuaxbwnfqpby4wwnkdr","value":"Backlog 📒"},{"color":"propColorYellow","id":"aotxum1p5bw3xuzqz3ctjw66yww","value":"Contacted 📞"},{"color":"propColorBlue","id":"a7yq89whddzob1futao4rxk3yzc","value":"Scheduled 📅"},{"color":"propColorRed","id":"aseqq9hrsua56r3s6nbuirj9eec","value":"Cancelled 🚫"},{"color":"propColorGreen","id":"ap93ysuzy1xa7z818r6myrn4h4y","value":"Completed ✔️"}],"type":"select"},{"id":"akrxgi7p7w14fym3gbynb98t9fh","name":"Interview Date","options":[],"type":"date"},{"id":"atg9qu6oe4bjm8jczzsn71ff5me","name":"Product Area","options":[{"color":"propColorGreen","id":"ahn89mqg9u4igk6pdm7333t8i5h","value":"Desktop App"},{"color":"propColorPurple","id":"aehc83ffays3gh8myz16a8j7k4e","value":"Web App"},{"color":"propColorBlue","id":"a1sxagjgaadym5yrjak6tcup1oa","value":"Mobile App"}],"type":"select"},{"id":"acjq4t5ymytu8x1f68wkggm7ypc","name":"Email","options":[],"type":"email"},{"id":"aphio1s5gkmpdbwoxynim7acw3e","name":"Interviewer","options":[],"type":"multiPerson"},{"id":"aqafzdeekpyncwz7m7i54q3iqqy","name":"Recording URL","options":[],"type":"url"},{"id":"aify3r761b9w43bqjtskrzi68tr","name":"Passcode","options":[],"type":"text"}],"createAt":1667410119064,"updateAt":1667504168497,"deleteAt":0}} {"type":"block","data":{"id":"vtibhoxpq67f1xmh8a8kxh39nka","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"All Users","fields":{"cardOrder":["ccsa77z7ubbbhbd3jq8xyx4hq8r"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":280,"aaebj5fyx493eezx6ukxiwydgty":146,"acjq4t5ymytu8x1f68wkggm7ypc":222,"akrxgi7p7w14fym3gbynb98t9fh":131,"atg9qu6oe4bjm8jczzsn71ff5me":131},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"akrxgi7p7w14fym3gbynb98t9fh","reversed":false}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["aaebj5fyx493eezx6ukxiwydgty","akrxgi7p7w14fym3gbynb98t9fh","atg9qu6oe4bjm8jczzsn71ff5me","acjq4t5ymytu8x1f68wkggm7ypc","aphio1s5gkmpdbwoxynim7acw3e","aqafzdeekpyncwz7m7i54q3iqqy","aify3r761b9w43bqjtskrzi68tr"]},"createAt":1667410162023,"updateAt":1667410900827,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"ccsa77z7ubbbhbd3jq8xyx4hq8r","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Frank Nash","fields":{"contentOrder":["aiqaqwzhe1tn9umunms95414kzo"],"icon":"👨‍💼","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"ap93ysuzy1xa7z818r6myrn4h4y","acjq4t5ymytu8x1f68wkggm7ypc":"frank.nash@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1669896000000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"aehc83ffays3gh8myz16a8j7k4e"}},"createAt":1667410176348,"updateAt":1667410539559,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"cdus79hea7ib6tb6nhic4bzcjbc","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Richard Parsons","fields":{"contentOrder":["aodtggcnfuby6fq8ehxg4koafgr"],"icon":"👨‍🦱","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"a7yq89whddzob1futao4rxk3yzc","acjq4t5ymytu8x1f68wkggm7ypc":"richard.parsons@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1671019200000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"a1sxagjgaadym5yrjak6tcup1oa"}},"createAt":1667410640657,"updateAt":1667417523845,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"cewmyr3nbybdombzmi83arq3koo","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Claire Hart","fields":{"contentOrder":["ahkkrf9xn8tfq9y98to8pbt6qnw"],"icon":"👩‍🦰","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"aseqq9hrsua56r3s6nbuirj9eec","acjq4t5ymytu8x1f68wkggm7ypc":"claire.hart@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1670500800000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"ahn89mqg9u4igk6pdm7333t8i5h"}},"createAt":1667410785750,"updateAt":1667410805030,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"cix9xfgh48ir55y7fdjftuje3za","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Olivia Alsop","fields":{"contentOrder":["a8xz4ead8k7budxknwhjxm9n3uc"],"icon":"👩‍💼","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"a7yq89whddzob1futao4rxk3yzc","acjq4t5ymytu8x1f68wkggm7ypc":"olivia.alsop@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1671192000000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"a1sxagjgaadym5yrjak6tcup1oa"}},"createAt":1667410730577,"updateAt":1667410775912,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"cn3skudjd9tbp5md9bocifnazpw","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Bernadette Powell","fields":{"contentOrder":["au67hjd7es7y6jkumo4ysrudwfa"],"icon":"🧑‍💼","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"af6hjb3ysuaxbwnfqpby4wwnkdr","acjq4t5ymytu8x1f68wkggm7ypc":"bernadette.powell@email.com"}},"createAt":1667410584181,"updateAt":1667410629860,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"vqi9zpn3h43bkbfc8c8jc7ci1hr","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Date","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"akrxgi7p7w14fym3gbynb98t9fh","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1667410845935,"updateAt":1667410849497,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"vdbpwgay6bbn8581n39yjiyxrxo","parentId":"bixohg18tt11in4qbtinimk974y","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["af6hjb3ysuaxbwnfqpby4wwnkdr","aotxum1p5bw3xuzqz3ctjw66yww","a7yq89whddzob1futao4rxk3yzc","aseqq9hrsua56r3s6nbuirj9eec","ap93ysuzy1xa7z818r6myrn4h4y"],"visiblePropertyIds":[]},"createAt":1667410119073,"updateAt":1667417470776,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"aiqaqwzhe1tn9umunms95414kzo","parentId":"ccsa77z7ubbbhbd3jq8xyx4hq8r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410410824,"updateAt":1667410422875,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"aodtggcnfuby6fq8ehxg4koafgr","parentId":"cdus79hea7ib6tb6nhic4bzcjbc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410640663,"updateAt":1667410640663,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"ahkkrf9xn8tfq9y98to8pbt6qnw","parentId":"cewmyr3nbybdombzmi83arq3koo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410785755,"updateAt":1667410785755,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"a8xz4ead8k7budxknwhjxm9n3uc","parentId":"cix9xfgh48ir55y7fdjftuje3za","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410730582,"updateAt":1667410730582,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} {"type":"block","data":{"id":"au67hjd7es7y6jkumo4ysrudwfa","parentId":"cn3skudjd9tbp5md9bocifnazpw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410584187,"updateAt":1667410584187,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}} ================================================ FILE: server/assets/templates-boardarchive/bkqk6hpfx7pbsucue7jan5n1o1o/board.jsonl ================================================ {"type":"board","data":{"id":"bkqk6hpfx7pbsucue7jan5n1o1o","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Competitive Analysis","description":"Use this template to track and stay ahead of the competition.","icon":"🗂️","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"ahzspe59iux8wigra8bg6cg18nc","name":"Website","options":[],"type":"url"},{"id":"aozntq4go4nkab688j1s7stqtfc","name":"Location","options":[],"type":"text"},{"id":"aiefo7nh9jwisn8b4cgakowithy","name":"Revenue","options":[],"type":"text"},{"id":"a6cwaq79b1pdpb97wkanmeyy4er","name":"Employees","options":[],"type":"number"},{"id":"an1eerzscfxn6awdfajbg41uz3h","name":"Founded","options":[],"type":"text"},{"id":"a1semdhszu1rq17d7et5ydrqqio","name":"Market Position","options":[{"color":"propColorYellow","id":"arfjpz9by5car71tz3behba8yih","value":"Leader"},{"color":"propColorRed","id":"abajmr34b8g1916w495xjb35iko","value":"Challenger"},{"color":"propColorBlue","id":"abt79uxg5edqojsrrefcnr4eruo","value":"Follower"},{"color":"propColorBrown","id":"aipf3qfgjtkheiayjuxrxbpk9wa","value":"Nicher"}],"type":"select"},{"id":"aapogff3xoa8ym7xf56s87kysda","name":"Last updated time","options":[],"type":"updatedTime"},{"id":"az3jkw3ynd3mqmart7edypey15e","name":"Last updated by","options":[],"type":"updatedBy"}],"createAt":1667337304886,"updateAt":1667352513150,"deleteAt":0}} {"type":"block","data":{"id":"vfzq8kedf3bnt7qkrsom658j6io","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Competitor List","fields":{"cardOrder":["c96bjeqk6zjrm5qtyoenexh3f8e","chg7cdun9hjbf5pue6zc1gxm8rw","cn9chs8a4zjyqzqez7qor63s8uc","ctkqr4ce3zjrzur3q4mn47eeuuc","cnam59x954idsxpbbfp8bmsigtr"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":210,"a1semdhszu1rq17d7et5ydrqqio":121,"aapogff3xoa8ym7xf56s87kysda":194,"ahzspe59iux8wigra8bg6cg18nc":156,"aiefo7nh9jwisn8b4cgakowithy":155,"aozntq4go4nkab688j1s7stqtfc":151,"az3jkw3ynd3mqmart7edypey15e":145},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["ahzspe59iux8wigra8bg6cg18nc","aozntq4go4nkab688j1s7stqtfc","aiefo7nh9jwisn8b4cgakowithy","a6cwaq79b1pdpb97wkanmeyy4er","an1eerzscfxn6awdfajbg41uz3h","a1semdhszu1rq17d7et5ydrqqio","aapogff3xoa8ym7xf56s87kysda","az3jkw3ynd3mqmart7edypey15e"]},"createAt":1667339411936,"updateAt":1667399926321,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"vr158bbbsetn5ffm1gebhduhx5a","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Market Position","fields":{"cardOrder":["cip8b4jcomfr7by9gtizebikfke","cacs91js1hb887ds41r6dwnd88c","ca3u8edwrof89i8obxffnz4xw3a"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["arfjpz9by5car71tz3behba8yih","abajmr34b8g1916w495xjb35iko","abt79uxg5edqojsrrefcnr4eruo","aipf3qfgjtkheiayjuxrxbpk9wa"],"visiblePropertyIds":[]},"createAt":1667351648812,"updateAt":1667352684324,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"c96bjeqk6zjrm5qtyoenexh3f8e","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Liminary Corp.","fields":{"contentOrder":["ainmysbw6xpyczm3p5xayocpm3e"],"icon":"🌧","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"abt79uxg5edqojsrrefcnr4eruo","a6cwaq79b1pdpb97wkanmeyy4er":"300","ahzspe59iux8wigra8bg6cg18nc":"liminarycorp.com","aiefo7nh9jwisn8b4cgakowithy":"$25,000,000","an1eerzscfxn6awdfajbg41uz3h":"2017","aozntq4go4nkab688j1s7stqtfc":"Toronto, Canada"}},"createAt":1667338157613,"updateAt":1667351630721,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"chg7cdun9hjbf5pue6zc1gxm8rw","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Helx Industries","fields":{"contentOrder":["an5k8g7ntz7bgppfx9cuk9oyaja"],"icon":"📦","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"abt79uxg5edqojsrrefcnr4eruo","a6cwaq79b1pdpb97wkanmeyy4er":"650","ahzspe59iux8wigra8bg6cg18nc":"helxindustries.com","aiefo7nh9jwisn8b4cgakowithy":"$50,000,000","an1eerzscfxn6awdfajbg41uz3h":"2009","aozntq4go4nkab688j1s7stqtfc":"New York, NY"}},"createAt":1667338444580,"updateAt":1667351626493,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"cn9chs8a4zjyqzqez7qor63s8uc","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Kadera Global","fields":{"contentOrder":["aup87xiwr9bye8fpshibuh156ih"],"icon":"🛡","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"aipf3qfgjtkheiayjuxrxbpk9wa","a6cwaq79b1pdpb97wkanmeyy4er":"150","ahzspe59iux8wigra8bg6cg18nc":"kaderaglobal.com","aiefo7nh9jwisn8b4cgakowithy":"$12,000,000","an1eerzscfxn6awdfajbg41uz3h":"2015","aozntq4go4nkab688j1s7stqtfc":"Seattle, OR"}},"createAt":1667338227718,"updateAt":1667351623847,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"cnam59x954idsxpbbfp8bmsigtr","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Ositions Inc.","fields":{"contentOrder":["aukdnjj7mw3ggudoe88wmmpgore"],"icon":"🌃","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"abajmr34b8g1916w495xjb35iko","a6cwaq79b1pdpb97wkanmeyy4er":"2,700","ahzspe59iux8wigra8bg6cg18nc":"ositionsinc.com","aiefo7nh9jwisn8b4cgakowithy":"$125,000,000","an1eerzscfxn6awdfajbg41uz3h":"2004","aozntq4go4nkab688j1s7stqtfc":"Berlin, Germany"}},"createAt":1667337634942,"updateAt":1667351619186,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"ctkqr4ce3zjrzur3q4mn47eeuuc","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Afformance Ltd.","fields":{"contentOrder":["a1c857fph4byaxdqf88kw1wrwyo"],"icon":"⚡","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"arfjpz9by5car71tz3behba8yih","a6cwaq79b1pdpb97wkanmeyy4er":"1,800","ahzspe59iux8wigra8bg6cg18nc":"afformanceltd.com","aiefo7nh9jwisn8b4cgakowithy":"$200,000,000","an1eerzscfxn6awdfajbg41uz3h":"2002","aozntq4go4nkab688j1s7stqtfc":"Palo Alto, CA"}},"createAt":1667338608746,"updateAt":1667351615526,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"ainmysbw6xpyczm3p5xayocpm3e","parentId":"c96bjeqk6zjrm5qtyoenexh3f8e","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339042969,"updateAt":1667340672945,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"an5k8g7ntz7bgppfx9cuk9oyaja","parentId":"chg7cdun9hjbf5pue6zc1gxm8rw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339057076,"updateAt":1667340666993,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"aup87xiwr9bye8fpshibuh156ih","parentId":"cn9chs8a4zjyqzqez7qor63s8uc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339054835,"updateAt":1667340086915,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"aukdnjj7mw3ggudoe88wmmpgore","parentId":"cnam59x954idsxpbbfp8bmsigtr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n\n## Weaknesses\n- ...\n- ...\n\n## Opportunities\n- ...\n- ...\n\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339032796,"updateAt":1667340679120,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} {"type":"block","data":{"id":"a1c857fph4byaxdqf88kw1wrwyo","parentId":"ctkqr4ce3zjrzur3q4mn47eeuuc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339061925,"updateAt":1667340648719,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}} ================================================ FILE: server/assets/templates-boardarchive/brs9cdimfw7fodyi7erqt747rhc/board.jsonl ================================================ {"type":"block","data":{"id":"brs9cdimfw7fodyi7erqt747rhc","parentId":"","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Content Calendar (NEW)","fields":{"cardProperties":[{"id":"ae9ar615xoknd8hw8py7mbyr7zo","name":"Status","options":[{"color":"propColorGray","id":"awna1nuarjca99m9s4uiy9kwj5h","value":"Idea 💡"},{"color":"propColorOrange","id":"a9ana1e9w673o5cp8md4xjjwfto","value":"Draft"},{"color":"propColorPurple","id":"apy9dcd7zmand615p3h53zjqxjh","value":"In Review"},{"color":"propColorBlue","id":"acri4cm3bmay55f7ksztphmtnga","value":"Ready to Publish"},{"color":"propColorGreen","id":"amsowcd9a8e1kid317r7ttw6uzh","value":"Published 🎉"}],"type":"select"},{"id":"aysx3atqexotgwp5kx6h5i5ancw","name":"Type","options":[{"color":"propColorOrange","id":"aywiofmmtd3ofgzj95ysky4pjga","value":"Press Release"},{"color":"propColorGreen","id":"apqdgjrmsmx8ngmp7zst51647de","value":"Sponsored Post"},{"color":"propColorPurple","id":"a3woynbjnb7j16e74uw3pubrytw","value":"Customer Story"},{"color":"propColorRed","id":"aq36k5pkpfcypqb3idw36xdi1fh","value":"Product Release"},{"color":"propColorGray","id":"azn66pmk34adygnizjqhgiac4ia","value":"Partnership"},{"color":"propColorBlue","id":"aj8y675weso8kpb6eceqbpj4ruw","value":"Feature Announcement"},{"color":"propColorYellow","id":"a3xky7ygn14osr1mokerbfah5cy","value":"Article"}],"type":"select"},{"id":"ab6mbock6styfe6htf815ph1mhw","name":"Channel","options":[{"color":"propColorBrown","id":"a8xceonxiu4n3c43szhskqizicr","value":"Website"},{"color":"propColorGreen","id":"a3pdzi53kpbd4okzdkz6khi87zo","value":"Blog"},{"color":"propColorOrange","id":"a3d9ux4fmi3anyd11kyipfbhwde","value":"Email"},{"color":"propColorRed","id":"a8cbbfdwocx73zn3787cx6gacsh","value":"Podcast"},{"color":"propColorPink","id":"aigjtpcaxdp7d6kmctrwo1ztaia","value":"Print"},{"color":"propColorBlue","id":"af1wsn13muho59e7ghwaogxy5ey","value":"Facebook"},{"color":"propColorGray","id":"a47zajfxwhsg6q8m7ppbiqs7jge","value":"LinkedIn"},{"color":"propColorYellow","id":"az8o8pfe9hq6s7xaehoqyc3wpyc","value":"Twitter"}],"type":"multiSelect"},{"id":"ao44fz8nf6z6tuj1x31t9yyehcc","name":"Assignee","options":[],"type":"person"},{"id":"a39x5cybshwrbjpc3juaakcyj6e","name":"Due Date","options":[],"type":"date"},{"id":"agqsoiipowmnu9rdwxm57zrehtr","name":"Publication Date","options":[],"type":"date"},{"id":"ap4e7kdg7eip7j3c3oyiz39eaoc","name":"Link","options":[],"type":"url"}],"description":"Use this template to plan and organize your editorial content.","icon":"📅","isTemplate":false,"showDescription":true},"createAt":1641618112737,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c3pxiqf156fnhjfazwwpo79rt6w","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"New Project and Workflow Management Solutions for Developers","fields":{"contentOrder":["71qhnzuec6esdi6fnynwpze4xya","aianjmrimwfyr7jiiju1oi77kiw"],"icon":"🎯","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1645790400000}","ab6mbock6styfe6htf815ph1mhw":["a8xceonxiu4n3c43szhskqizicr","a3pdzi53kpbd4okzdkz6khi87zo","a3d9ux4fmi3anyd11kyipfbhwde"],"ae9ar615xoknd8hw8py7mbyr7zo":"awna1nuarjca99m9s4uiy9kwj5h","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://mattermost.com/newsroom/press-releases/mattermost-launches-new-project-and-workflow-management-solutions-for-developers/","aysx3atqexotgwp5kx6h5i5ancw":"aywiofmmtd3ofgzj95ysky4pjga"}},"createAt":1641618113009,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cemyj9s9nwtgzieowpufrd1oo5h","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"[Tweet] Mattermost v6.1 includes card @-mention notifications in Boards","fields":{"contentOrder":["7i96m7nbsdsex8n6hzuzrmdfjuy","7ed5bwp3gr8yax3mhtuwiaa9gjy","a8egmu8gsqp8dzfk9pgpq5mm4ta","awyawmyjtj3nfffu4aphaqy9bgy","abdasiyq4k7ndtfrdadrias8sjy","71ppnm4bcmbrbpn73nefjkao17r"],"icon":"🐤","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1639051200000}","ab6mbock6styfe6htf815ph1mhw":["az8o8pfe9hq6s7xaehoqyc3wpyc"],"ae9ar615xoknd8hw8py7mbyr7zo":"a9ana1e9w673o5cp8md4xjjwfto","agqsoiipowmnu9rdwxm57zrehtr":"{\"from\":1637668800000}","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://twitter.com/Mattermost/status/1463145633162969097?s=20","aysx3atqexotgwp5kx6h5i5ancw":"aj8y675weso8kpb6eceqbpj4ruw"}},"createAt":1641618112896,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cp963ioyx63rz98q8gs19nxxm7w","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Top 10 Must-Have DevOps Tools in 2021","fields":{"contentOrder":["7fo1utqc8x1z1z6hzg33hes1ktc","ajm6ykd3633dbxdq6j76wtthbia"],"icon":"🛠️","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1636113600000}","ab6mbock6styfe6htf815ph1mhw":["a8xceonxiu4n3c43szhskqizicr"],"ae9ar615xoknd8hw8py7mbyr7zo":"a9ana1e9w673o5cp8md4xjjwfto","agqsoiipowmnu9rdwxm57zrehtr":"{\"from\":1637323200000}","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://www.toolbox.com/tech/devops/articles/best-devops-tools/","aysx3atqexotgwp5kx6h5i5ancw":"a3xky7ygn14osr1mokerbfah5cy"}},"createAt":1641618112796,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"crrwzx9z4dfbsiki6suzwj3mqfw","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Unblocking Workflows: The Guide to Developer Productivity","fields":{"contentOrder":["77tz16jtz5x73ncs3dxc3fp1d7h","asmp1ztc1gjyh3k8og8yyizu5jy"],"icon":"💻","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1638532800000}","ab6mbock6styfe6htf815ph1mhw":["a3pdzi53kpbd4okzdkz6khi87zo"],"ae9ar615xoknd8hw8py7mbyr7zo":"apy9dcd7zmand615p3h53zjqxjh","agqsoiipowmnu9rdwxm57zrehtr":"{\"from\":1639483200000}","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://mattermost.com/newsroom/press-releases/mattermost-unveils-definitive-report-on-the-state-of-developer-productivity-unblocking-workflows-the-guide-to-developer-productivity-2022-edition/","aysx3atqexotgwp5kx6h5i5ancw":"a3xky7ygn14osr1mokerbfah5cy"}},"createAt":1641618112846,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vaiuu5bg4ofdn8j4whttdgtus4w","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":[null,"cdbfkd15d6iy18rgx1tskmfsr6c","cn8yofg9rtkgmzgmb5xdi56p3ic","csgsnnywpuqzs5jgq87snk9x17e","cqwaytore5y487wdu8zffppqnea",null],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["awna1nuarjca99m9s4uiy9kwj5h","a9ana1e9w673o5cp8md4xjjwfto","apy9dcd7zmand615p3h53zjqxjh","acri4cm3bmay55f7ksztphmtnga","amsowcd9a8e1kid317r7ttw6uzh",""],"visiblePropertyIds":["ab6mbock6styfe6htf815ph1mhw"]},"createAt":1641618113176,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vgbzazskupjrq7gnrwqqk51adsh","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Due Date Calendar","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"a39x5cybshwrbjpc3juaakcyj6e","defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641618113068,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vkk4dm1tnzb8fbmr5gxhibr63te","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Publication Calendar","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"agqsoiipowmnu9rdwxm57zrehtr","defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641618113123,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vpsefkithi7gq3rfyignqxa9cze","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Content List","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":322,"ab6mbock6styfe6htf815ph1mhw":229,"aysx3atqexotgwp5kx6h5i5ancw":208},"defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"a39x5cybshwrbjpc3juaakcyj6e","reversed":false}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["ae9ar615xoknd8hw8py7mbyr7zo","aysx3atqexotgwp5kx6h5i5ancw","ab6mbock6styfe6htf815ph1mhw","ao44fz8nf6z6tuj1x31t9yyehcc","a39x5cybshwrbjpc3juaakcyj6e","agqsoiipowmnu9rdwxm57zrehtr","ap4e7kdg7eip7j3c3oyiz39eaoc"]},"createAt":1641618243042,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aianjmrimwfyr7jiiju1oi77kiw","parentId":"c3pxiqf156fnhjfazwwpo79rt6w","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618141074,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"71ppnm4bcmbrbpn73nefjkao17r","parentId":"cemyj9s9nwtgzieowpufrd1oo5h","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7y5kr8x8ybpnwdykjfuz57rggrh.png"},"createAt":1641618185785,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a8egmu8gsqp8dzfk9pgpq5mm4ta","parentId":"cemyj9s9nwtgzieowpufrd1oo5h","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618157625,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"awyawmyjtj3nfffu4aphaqy9bgy","parentId":"cemyj9s9nwtgzieowpufrd1oo5h","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Media","fields":{},"createAt":1641618160634,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a4uyug1msrtrkdfy5fwu8shf7so","parentId":"cff1jmrxfrirgbeebhr9qd7nida","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618338368,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"abztjcgndkffd3gybef6phr14so","parentId":"cff1jmrxfrirgbeebhr9qd7nida","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n- ...\n\n## Notes\n- ...\n- ...\n- ...","fields":{},"createAt":1641618112322,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"azczyg4pfj3ysjpxf4hjtu666ne","parentId":"cff1jmrxfrirgbeebhr9qd7nida","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618112527,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ajm6ykd3633dbxdq6j76wtthbia","parentId":"cp963ioyx63rz98q8gs19nxxm7w","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618208454,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"asmp1ztc1gjyh3k8og8yyizu5jy","parentId":"crrwzx9z4dfbsiki6suzwj3mqfw","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618224780,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} ================================================ FILE: server/assets/templates-boardarchive/bsjd59qtpbf888mqez3ge77domw/board.jsonl ================================================ {"type":"board","data":{"id":"bsjd59qtpbf888mqez3ge77domw","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Team Retrospective","description":"Use this template at the end of your project or sprint to identify what worked well and what can be improved for the future.","icon":"🧭","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"adjckpdotpgkz7c6wixzw9ipb1e","name":"Category","options":[{"color":"propColorGray","id":"aok6pgecm85qe9k5kcphzoe63ma","value":"To Discuss 📣"},{"color":"propColorGreen","id":"aq1dwbf661yx337hjcd5q3sbxwa","value":"Went Well 👍"},{"color":"propColorRed","id":"ar87yh5xmsswqkxmjq1ipfftfpc","value":"Didn't Go Well 🚫"},{"color":"propColorBlue","id":"akj3fkmxq7idma55mdt8sqpumyw","value":"Action Items ✅"}],"type":"select"},{"id":"aspaay76a5wrnuhtqgm97tt3rer","name":"Details","options":[],"type":"text"},{"id":"arzsm76s376y7suuhao3tu6efoc","name":"Created By","options":[],"type":"createdBy"},{"id":"a8anbe5fpa668sryatcdsuuyh8a","name":"Created Time","options":[],"type":"createdTime"}],"createAt":1667494395151,"updateAt":1667508014713,"deleteAt":0}} {"type":"block","data":{"id":"v56n9ixhhbpn79m6eb4xwpo6dih","parentId":"bjbhs6bos3m8zjouf78xceg9nqw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board view","fields":{"cardOrder":["cniwb8xwcqtbstbcm3sdfrr854h","cs4qwpzr65fgttd7364dicskanh","c9s78pzbdg3g4jkcdjqahtnfejc","c8utmazns878jtfgtf7exyi9pee","cnobejmb6bf8e3c1w7em5z4pwyh"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["aok6pgecm85qe9k5kcphzoe63ma","aq1dwbf661yx337hjcd5q3sbxwa","ar87yh5xmsswqkxmjq1ipfftfpc","akj3fkmxq7idma55mdt8sqpumyw"],"visiblePropertyIds":["aspaay76a5wrnuhtqgm97tt3rer"]},"createAt":1667494395162,"updateAt":1667508040536,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}} {"type":"block","data":{"id":"c8utmazns878jtfgtf7exyi9pee","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Tight deadline","fields":{"contentOrder":[],"icon":"📅","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"ar87yh5xmsswqkxmjq1ipfftfpc"}},"createAt":1667495008197,"updateAt":1667495012284,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}} {"type":"block","data":{"id":"c9s78pzbdg3g4jkcdjqahtnfejc","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Team communication","fields":{"contentOrder":[],"icon":"💬","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"aq1dwbf661yx337hjcd5q3sbxwa"}},"createAt":1667494992121,"updateAt":1667494995438,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}} {"type":"block","data":{"id":"cniwb8xwcqtbstbcm3sdfrr854h","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Reschedule planning meeting","fields":{"contentOrder":[],"icon":"🗓️","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"aok6pgecm85qe9k5kcphzoe63ma"}},"createAt":1667494810631,"updateAt":1667495048934,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}} {"type":"block","data":{"id":"cnobejmb6bf8e3c1w7em5z4pwyh","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Schedule more time for testing","fields":{"contentOrder":[],"icon":"🧪","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"akj3fkmxq7idma55mdt8sqpumyw"}},"createAt":1667495025505,"updateAt":1667495032672,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}} {"type":"block","data":{"id":"cs4qwpzr65fgttd7364dicskanh","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Positive user feedback","fields":{"contentOrder":[],"icon":"🥰","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"aq1dwbf661yx337hjcd5q3sbxwa"}},"createAt":1667494972061,"updateAt":1667494978637,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}} ================================================ FILE: server/assets/templates-boardarchive/bui5izho7dtn77xg3thkiqprc9r/board.jsonl ================================================ {"type":"block","data":{"id":"bui5izho7dtn77xg3thkiqprc9r","parentId":"","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Roadmap (NEW)","fields":{"cardProperties":[{"id":"50117d52-bcc7-4750-82aa-831a351c44a0","name":"Status","options":[{"color":"propColorGray","id":"8c557f69-b0ed-46ec-83a3-8efab9d47ef5","value":"Not Started"},{"color":"propColorYellow","id":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","value":"In Progress"},{"color":"propColorGreen","id":"849766ba-56a5-48d1-886f-21672f415395","value":"Complete 🙌"}],"type":"select"},{"id":"20717ad3-5741-4416-83f1-6f133fff3d11","name":"Type","options":[{"color":"propColorYellow","id":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","value":"Epic ⛰"},{"color":"propColorGreen","id":"6eea96c9-4c61-4968-8554-4b7537e8f748","value":"Task 🔨"},{"color":"propColorRed","id":"1fdbb515-edd2-4af5-80fc-437ed2211a49","value":"Bug 🐞"}],"type":"select"},{"id":"60985f46-3e41-486e-8213-2b987440ea1c","name":"Sprint","options":[{"color":"propColorBrown","id":"c01676ca-babf-4534-8be5-cce2287daa6c","value":"Sprint 1"},{"color":"propColorPurple","id":"ed4a5340-460d-461b-8838-2c56e8ee59fe","value":"Sprint 2"},{"color":"propColorBlue","id":"14892380-1a32-42dd-8034-a0cea32bc7e6","value":"Sprint 3"}],"type":"select"},{"id":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","name":"Priority","options":[{"color":"propColorRed","id":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9","value":"P1 🔥"},{"color":"propColorYellow","id":"e6a7f297-4440-4783-8ab3-3af5ba62ca11","value":"P2"},{"color":"propColorGray","id":"c62172ea-5da7-4dec-8186-37267d8ee9a7","value":"P3"}],"type":"select"},{"id":"aphg37f7zbpuc3bhwhp19s1ribh","name":"Assignee","options":[],"type":"person"},{"id":"a4378omyhmgj3bex13sj4wbpfiy","name":"Due Date","options":[],"type":"date"},{"id":"a36o9q1yik6nmar6ri4q4uca7ey","name":"Created Date","options":[],"type":"createdTime"},{"id":"ai7ajsdk14w7x5s8up3dwir77te","name":"Design Link","options":[],"type":"url"}],"description":"Use this template to plan your roadmap and manage your releases more efficiently.","icon":"🗺️","isTemplate":false,"showDescription":true},"createAt":1640363551156,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c3jawn6e4fbr3jctthy9xxkdsqe","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"App crashing","fields":{"contentOrder":["79t7rkiuspeneqi9xurou9tqzwh","a4d68ftemrbfsfykur6eh6nrogh","ae54fbyywubnbtr3s4yhgns4nye","7o9ktgofg37yc7gma9s3jd9bd3a"],"icon":"📉","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"1fdbb515-edd2-4af5-80fc-437ed2211a49","50117d52-bcc7-4750-82aa-831a351c44a0":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9"}},"createAt":1641589357560,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c5trb4319wi8n3x4r4f7f83ytdc","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Calendar view","fields":{"contentOrder":["7df11783ny67mdnognqae31ax6y","ag9rxpgbwqid1mm5hgg8b9yhf6o"],"icon":"📆","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"6eea96c9-4c61-4968-8554-4b7537e8f748","50117d52-bcc7-4750-82aa-831a351c44a0":"849766ba-56a5-48d1-886f-21672f415395","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1641590072588,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c9p4bdasriifc7qgihzhjm63ugy","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Standard templates","fields":{"contentOrder":["7uonmjk41nipnrsi6tz8wau5ssh","afz66z155b7fhik9p6opysjneha"],"icon":"🗺️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"6eea96c9-4c61-4968-8554-4b7537e8f748","50117d52-bcc7-4750-82aa-831a351c44a0":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1641589960934,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"chfrdo1nb3p8ofnbftyinr6949o","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Import / Export","fields":{"contentOrder":["aw66wjze7qfr1ukqs8gw53qa5qw"],"icon":"🚢","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"6eea96c9-4c61-4968-8554-4b7537e8f748","50117d52-bcc7-4750-82aa-831a351c44a0":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1640363550923,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cp1m1wrpfatdxikhwkf58oo5k3o","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Review API design","fields":{"contentOrder":["ahsamufik97nsfxjgx9cs6cmzme"],"icon":"🛣️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","50117d52-bcc7-4750-82aa-831a351c44a0":"8c557f69-b0ed-46ec-83a3-8efab9d47ef5","60985f46-3e41-486e-8213-2b987440ea1c":"14892380-1a32-42dd-8034-a0cea32bc7e6","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"c62172ea-5da7-4dec-8186-37267d8ee9a7"}},"createAt":1640363550754,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cqfy6g434pigk3p7j3gq55trq9o","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Icons don't display","fields":{"contentOrder":["axfkn6tuy4igubj3ka99tbymb8o","acbpep9wxdtyg8gg3fi6h1hgoro","7tedfdyq4p7g77dmkrebryh4jor"],"icon":"💻","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"1fdbb515-edd2-4af5-80fc-437ed2211a49","50117d52-bcc7-4750-82aa-831a351c44a0":"8c557f69-b0ed-46ec-83a3-8efab9d47ef5","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1640363550868,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"v1uubwdzrw7fsxnd6pss1dyhh5e","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Calendar View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"a4378omyhmgj3bex13sj4wbpfiy","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1640379248049,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"v7n4sc9cre7gsbq9yydsuekpg8a","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board: Sprints","fields":{"cardOrder":["c3jawn6e4fbr3jctthy9xxkdsqe","c5trb4319wi8n3x4r4f7f83ytdc","c9p4bdasriifc7qgihzhjm63ugy","cqfy6g434pigk3p7j3gq55trq9o","chfrdo1nb3p8ofnbftyinr6949o","cp1m1wrpfatdxikhwkf58oo5k3o"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"60985f46-3e41-486e-8213-2b987440ea1c","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["c01676ca-babf-4534-8be5-cce2287daa6c","ed4a5340-460d-461b-8838-2c56e8ee59fe","14892380-1a32-42dd-8034-a0cea32bc7e6",""],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363550811,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"v8sa3mo81d38rbmd8bz4n6dg7qc","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"List: Tasks 🔨","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"50117d52-bcc7-4750-82aa-831a351c44a0":139,"__title":280},"defaultTemplateId":"","filter":{"filters":[{"condition":"includes","propertyId":"20717ad3-5741-4416-83f1-6f133fff3d11","values":["6eea96c9-4c61-4968-8554-4b7537e8f748"]}],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"50117d52-bcc7-4750-82aa-831a351c44a0","reversed":true}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["50117d52-bcc7-4750-82aa-831a351c44a0","20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363550980,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vi43bqxsho3fmjbu1oa8qafwo4c","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board: Status","fields":{"cardOrder":["c3jawn6e4fbr3jctthy9xxkdsqe","cm4w7cc3aac6s9jdcujbs4j8f4r","c6egh6cpnj137ixdoitsoxq17oo","cct9u78utsdyotmejbmwwg66ihr","cmft87it1q7yebbd51ij9k65xbw","c9fe77j9qcruxf4itzib7ag6f1c","coup7afjknqnzbdwghiwbsq541w","c5ex1hndz8qyc8gx6ofbfeksftc"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cidz4imnqhir48brz6e8hxhfrhy","filter":{"filters":[],"operation":"and"},"groupById":"50117d52-bcc7-4750-82aa-831a351c44a0","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["8c557f69-b0ed-46ec-83a3-8efab9d47ef5","ec6d2bc5-df2b-4f77-8479-e59ceb039946","849766ba-56a5-48d1-886f-21672f415395",""],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363551099,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vod5de87tz7nxpji31oou4ine3c","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"List: Bugs 🐞","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"50117d52-bcc7-4750-82aa-831a351c44a0":145,"__title":280},"defaultTemplateId":"","filter":{"filters":[{"condition":"includes","propertyId":"20717ad3-5741-4416-83f1-6f133fff3d11","values":["1fdbb515-edd2-4af5-80fc-437ed2211a49"]}],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["50117d52-bcc7-4750-82aa-831a351c44a0","20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363550690,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7o9ktgofg37yc7gma9s3jd9bd3a","parentId":"c3jawn6e4fbr3jctthy9xxkdsqe","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"77pe9r4ckbin438ph3f18bpatua.png"},"createAt":1641589687567,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a4d68ftemrbfsfykur6eh6nrogh","parentId":"c3jawn6e4fbr3jctthy9xxkdsqe","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1641589386414,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ae54fbyywubnbtr3s4yhgns4nye","parentId":"c3jawn6e4fbr3jctthy9xxkdsqe","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1641589472988,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ag9rxpgbwqid1mm5hgg8b9yhf6o","parentId":"c5trb4319wi8n3x4r4f7f83ytdc","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1641590081840,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"afz66z155b7fhik9p6opysjneha","parentId":"c9p4bdasriifc7qgihzhjm63ugy","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1641589969935,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"73dpuy7r9qpfymrp67c9n3krrsc","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7pbp4qg415pbstc6enzeicnu3qh.png"},"createAt":1640379104209,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aabek71yr1trxmjudty7efncp3r","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\nIf applicable, add screenshots to elaborate on the problem.","fields":{},"createAt":1640379104369,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"asqzoizq31b81dpyzm1tnm8wyxc","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n\nA clear and concise description of what you expected to happen.\n\n## Edition and Platform\n\n - Edition: Personal Desktop / Personal Server / Mattermost plugin\n - Version: [e.g. v0.9.0]\n - Browser and OS: [e.g. Chrome 91 on macOS, Edge 93 on Windows]\n\n## Additional context\n\nAdd any other context about the problem here.","fields":{},"createAt":1640379104459,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"auefo9xa6sffatbeqzya56bhebo","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n\n*[A clear and concise description of what you expected to happen.]*\n\n## Screenshots\n\n*[If applicable, add screenshots to elaborate on the problem.]*\n\n## Edition and Platform\n\n - Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n - Version: *[e.g. v0.9.0]*\n - Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n\n*[Add any other context about the problem here.]*","fields":{},"createAt":1640379139361,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aw66wjze7qfr1ukqs8gw53qa5qw","parentId":"chfrdo1nb3p8ofnbftyinr6949o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1640380216220,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"anppzrbx3i7b47n17b6jje6e1yc","parentId":"cidz4imnqhir48brz6e8hxhfrhy","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1640380239894,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"azyfnyszy6jb9iys9izfz1bhbdw","parentId":"cidz4imnqhir48brz6e8hxhfrhy","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Requirements\n- [Requirement 1]\n- [Requirement 2]\n- ...","fields":{},"createAt":1640380231316,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ahsamufik97nsfxjgx9cs6cmzme","parentId":"cp1m1wrpfatdxikhwkf58oo5k3o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n\n## Motivation\n*[Brief description on why this is needed]*\n\n## Acceptance Criteria\n - *[Criteron 1]*\n - *[Criteron 2]*\n - ...\n\n## Personas\n - *[Persona A]*\n - *[Persona B]*\n - ...\n\n## Reference Materials\n - *[Links to other relevant documents as needed]*\n - ...","fields":{},"createAt":1640380010492,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7tedfdyq4p7g77dmkrebryh4jor","parentId":"cqfy6g434pigk3p7j3gq55trq9o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7pbp4qg415pbstc6enzeicnu3qh.png"},"createAt":1640379056342,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"acbpep9wxdtyg8gg3fi6h1hgoro","parentId":"cqfy6g434pigk3p7j3gq55trq9o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1640378826029,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"axfkn6tuy4igubj3ka99tbymb8o","parentId":"cqfy6g434pigk3p7j3gq55trq9o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1640378803642,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a58i6xsb3abdhm87oezaum6ehhc","parentId":"cwrq9ag3p5pgzzy98nfd3wwra1w","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n## Motivation\n*[Brief description on why this is needed]*\n## Acceptance Criteria\n- *[Criteron 1]*\n- *[Criteron 2]*\n- ...\n## Personas\n- *[Persona A]*\n- *[Persona B]*\n- ...\n## Reference Materials\n- *[Links to other relevant documents as needed]*\n- ...","fields":{},"createAt":1640380125209,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a799597ibbjb17yxy1c3zjias1w","parentId":"cwrq9ag3p5pgzzy98nfd3wwra1w","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n[Brief description of what this epic is about]\n\n## Motivation\n[Brief description on why this is needed]\n\n## Acceptance Criteria\n - [Criteron 1]\n - [Criteron 2]\n - ...\n\n## Personas\n - [Persona A]\n - [Persona B]\n - ...\n\n## Reference Materials\n - [Links to other relevant documents as needed]\n - ...","fields":{},"createAt":1640380118322,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} ================================================ FILE: server/assets/templates-boardarchive/buixxjic3xjfkieees4iafdrznc/board.jsonl ================================================ {"type":"block","data":{"id":"buixxjic3xjfkieees4iafdrznc","parentId":"","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Welcome to Boards!","fields":{"cardProperties":[{"id":"a972dc7a-5f4c-45d2-8044-8c28c69717f1","name":"Status","options":[{"color":"propColorRed","id":"amm6wfhnbuxojwssyftgs9dipqe","value":"To do 🔥"},{"color":"propColorYellow","id":"af3p8ztcyxgn8wd9z4az7o9tjeh","value":"Next up"},{"color":"propColorPurple","id":"ajurey3xkocs1nwx8di5zx6oe7o","value":"Later"},{"color":"propColorGreen","id":"agkinkjy5983wsk6kppsujajxqw","value":"Completed 🙌"}],"type":"select"},{"id":"acypkejeb5yfujhj9te57p9kaxw","name":"Priority","options":[{"color":"propColorOrange","id":"aanaehcw3m13jytujsjk5hpf6ry","value":"1. High"},{"color":"propColorBrown","id":"ascd7nm9r491ayot8i86g1gmgqw","value":"2. Medium"},{"color":"propColorGray","id":"aq6ukoiciyfctgwyhwzpfss8ghe","value":"3. Low"}],"type":"select"},{"id":"aqh13jabwexjkzr3jqsz1i1syew","name":"Assignee","options":[],"type":"person"},{"id":"acmg7mz1rr1eykfug4hcdpb1y1o","name":"Due Date","options":[],"type":"date"},{"id":"amewjwfjrtpu8ha73xsrdmxazxr","name":"Reviewed","options":[],"type":"checkbox"},{"id":"attzzboqaz6m1sdti5xa7gjnk1e","name":"Last updated time","options":[],"type":"updatedTime"},{"id":"a4nfnb5xr3txr5xq7y9ho7kyz6c","name":"Reference","options":[],"type":"url"},{"id":"a9gzwi3dt5n55nddej6zcbhxaeh","name":"Created by","options":[],"type":"createdBy"}],"description":"Mattermost Boards is an open source project management tool that helps you organize, track, and manage work across teams. Select a card to learn more.","icon":"👋","isTemplate":false,"showDescription":true},"createAt":1640034759040,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c5ay4q3t1hf8cdcschejip7ybpc","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Drag cards","fields":{"contentOrder":["apktbgtee5jb8xrnqy3ibiujxew","aefratgmk6j8nzj5fngfrf4k8hw"],"icon":"🤏","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/working-with-boards.html#dragging-cards","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ajurey3xkocs1nwx8di5zx6oe7o","acypkejeb5yfujhj9te57p9kaxw":"aq6ukoiciyfctgwyhwzpfss8ghe"}},"createAt":1640034759400,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"c9h4wpgh1ajyzfdqoyotohtj6oy","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Manage tasks with cards","fields":{"contentOrder":["a778mcixrm7byzb4mxrixjtrwwa","7mgy47rzyxpdm5c5eod9x5nypea","7tuw1my7b7fnxd8sfyzpz6dd1sc","784uu3ufcgb878ky7hyugmf6xcw","77msur4yswfn65d8qycdyfpfawe","7dh4oncxngj8jb8n59sefmsynac","7nkegq1zimifpmxcrq8ntyothoe","7nb8y7jyoetro8cd36qcju53z8c","7exhjmek1ctbexxt95w5cy1cuwo","7peuyuzgkc3fmzczfjuzseg9ksa","76nwb9tqfsid5jx46yw34itqima","7dy3mcgzgybf1ifa3emgewkzj7e","a5ca6tii33bfw8ba36y1rswq3he","7876od6xhffr6fy69zeogag7eyw","7x7bq9awkatbm5x4docbh5gaw4y","7ghpx9qff43dgtke1rwidmge1ho","7nb8y7jyoetro8cd36qcju53z8c","7hdyxemhbytfm3m83g88djq9nhr","7pgnejxokubbe9kdrxj6g9qa41e","7hw9z6qtx8jyizkmm9g5yq3gxcy","7gk6ooz6npbb8by5rgp9aig7tua","7ayruwskq4b8rte64fiwz493kjo"],"icon":"☑️","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/work-with-cards.html","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"amm6wfhnbuxojwssyftgs9dipqe","acypkejeb5yfujhj9te57p9kaxw":"aanaehcw3m13jytujsjk5hpf6ry"}},"createAt":1640034759460,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cfkikng8egbr878ryaztmpkno4w","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Create your own board","fields":{"contentOrder":["apedf7fbrspgt3cg8e5worq1gqa","as7511u6t1pdc7fe7zrbzdfg51y","7r9my1yuddbn45dojrfht3neg8c","7eir5gdjxgjbsxpbyp3df4npcze","7cux9rwr1b3rjmxakbipeoxky6h"],"icon":"📋","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/working-with-boards.html#adding-new-boards","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"amm6wfhnbuxojwssyftgs9dipqe","acypkejeb5yfujhj9te57p9kaxw":"aanaehcw3m13jytujsjk5hpf6ry"}},"createAt":1640034759557,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cm8yz355wbtfd7rtpgs655wbr4e","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Share a board","fields":{"contentOrder":["a5z5po5apabfibkgmkq53dxe9dw","ag791jfbc47gobroeo9ie1afcdo","7r7asyew8d7fyunf4sow8e5iyoc","ad8j3n8tp77bppee3ipjt6odgpe","7w935usqt6pby8qz9x5pxaj7iow","7ogbs8h6q4j8z7ngy1m7eag63nw","7z1jau5qy3jfcxdp5cgq3duk6ne","7hkn59merfbf38gzxf7sabewuma"],"icon":"📤","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/sharing-boards.html","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ajurey3xkocs1nwx8di5zx6oe7o","acypkejeb5yfujhj9te57p9kaxw":"aq6ukoiciyfctgwyhwzpfss8ghe"}},"createAt":1640034759139,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cmjtfip8a738nbr33shzmgk559o","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Create a new card","fields":{"contentOrder":["aykjshfjrxpd9zngqruenqn5s7h","adhsx4h5ss7rqdcjt8xyam6xtqc","auow16g4f4tf4z89qrxbg3btxba","7me9p46gbqiyfmfnapi7dyxb5br","76bqrrm8dobr37kttya6jhznjih"],"icon":"📝","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/working-with-boards.html#adding-cards","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"amm6wfhnbuxojwssyftgs9dipqe","acypkejeb5yfujhj9te57p9kaxw":"aanaehcw3m13jytujsjk5hpf6ry"}},"createAt":1640034759755,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cq1gmiwhx4jgd7q9ad9c1icasqr","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Share cards on Channels","fields":{"contentOrder":["arpj7spx9op8jumm6yfdsxwpeuw","a4z7htb6catgaue5npinux4tmrc","a3qa1n69wc7d1u8krumz9ogcidy","7y5hxcb9zzprzzrqeu675rtnpae"],"icon":"📮","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/work-with-cards.html#share-card-previews","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"af3p8ztcyxgn8wd9z4az7o9tjeh","acypkejeb5yfujhj9te57p9kaxw":"ascd7nm9r491ayot8i86g1gmgqw"}},"createAt":1641487149480,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cse6a9d81tfyd7e34cbmfttbgte","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Filter and sort cards","fields":{"contentOrder":["a4fz9kcfs9ibj8puk9mux7ac94c","ad9fecctco7ggjjeo9usfpwkfpa","78i8aqjmqtibr7x4okhz6uqquqr","7oz9pp3bkopgfpycph3oqgze8uw"],"icon":"🎛️","isTemplate":false,"properties":{"a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ajurey3xkocs1nwx8di5zx6oe7o","acypkejeb5yfujhj9te57p9kaxw":"aq6ukoiciyfctgwyhwzpfss8ghe"}},"createAt":1640034759298,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cstm8jadnmbds9kooz4tr8sy5wr","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Create a new view","fields":{"contentOrder":["aozbezukpgif3jpbsq7tahmmp5e","a538fji6kcp89fyhmgaoko7wk6c","7owai1ux3h3gtf8byynfk6hyx1c","7n8jq1dizyfgotby3o91arf1hxh","77y4wffj1ctg7xmm9bx45qn6q6o","7te5jcsrym3by7yxq4um8usj7ao"],"icon":"👓","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/working-with-boards.html#adding-new-views","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"af3p8ztcyxgn8wd9z4az7o9tjeh","acypkejeb5yfujhj9te57p9kaxw":"ascd7nm9r491ayot8i86g1gmgqw"}},"createAt":1640034759508,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cut8jasi4etbd7mpqpn36fna9ba","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Add new properties","fields":{"contentOrder":["afb3pntrwgtrwidfeo1f1dsonqy","ayhk11qsuz789fk8bqae4oz8mro","7gc3z8cf8rirgfyutwoke9nn6jy","76cinqnb6k3dzmfbm9fnc8eofny","79yggmhcyhbgdiej5w4re3k4ssy"],"icon":"🏷️","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/work-with-cards.html#add-and-manage-properties","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"af3p8ztcyxgn8wd9z4az7o9tjeh","acypkejeb5yfujhj9te57p9kaxw":"ascd7nm9r491ayot8i86g1gmgqw"}},"createAt":1640034759239,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"cwf9xw6d6zbgfbbgd6atr336uco","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"@mention teammates","fields":{"contentOrder":["akbz4abecginpjgz1etweynd7io","ab6ygnyg757bc9cpm9bkp8aam8e","7mbw9t71hjbrydgzgkqqaoh8usr","78mzk7qpof7ybxpfjn9j9an9gkh"],"icon":"🔔","isTemplate":false,"properties":{"a4nfnb5xr3txr5xq7y9ho7kyz6c":"https://docs.mattermost.com/boards/work-with-cards.html#mention-people","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ajurey3xkocs1nwx8di5zx6oe7o","acypkejeb5yfujhj9te57p9kaxw":"aq6ukoiciyfctgwyhwzpfss8ghe"}},"createAt":1640034759348,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vkm63rwrg5p8q5mrrk6ghzk3q1r","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Preview: Table View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":280,"a972dc7a-5f4c-45d2-8044-8c28c69717f1":100,"acypkejeb5yfujhj9te57p9kaxw":169},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a972dc7a-5f4c-45d2-8044-8c28c69717f1","aqh13jabwexjkzr3jqsz1i1syew","acmg7mz1rr1eykfug4hcdpb1y1o","acypkejeb5yfujhj9te57p9kaxw"]},"createAt":1640034759909,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vmu9ebyngpjb1mkc887m3pfocfa","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Preview: Calendar View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"acmg7mz1rr1eykfug4hcdpb1y1o","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641796830370,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vssisypn6sfbctxy8si7oj3y9io","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Preview: Gallery View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"gallery","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641791749185,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"vzou1dc41ntn73mkyymka9yrese","parentId":"buixxjic3xjfkieees4iafdrznc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Onboarding","fields":{"cardOrder":["cmjtfip8a738nbr33shzmgk559o","c9h4wpgh1ajyzfdqoyotohtj6oy","cfkikng8egbr878ryaztmpkno4w","cq1gmiwhx4jgd7q9ad9c1icasqr","cut8jasi4etbd7mpqpn36fna9ba","cstm8jadnmbds9kooz4tr8sy5wr","cwf9xw6d6zbgfbbgd6atr336uco","c5ay4q3t1hf8cdcschejip7ybpc","cm8yz355wbtfd7rtpgs655wbr4e","cse6a9d81tfyd7e34cbmfttbgte"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"a972dc7a-5f4c-45d2-8044-8c28c69717f1","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["aqb5x3pt87dcc9stbk4ofodrpoy","a1mtm777bkagq3iuu7xo9b13qfr","auxbwzptiqzkii5r61uz3ndsy1r","aj9386k1bx8qwmepeuxg3b7z4pw"],"visiblePropertyIds":[]},"createAt":1640034759964,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aefratgmk6j8nzj5fngfrf4k8hw","parentId":"c5ay4q3t1hf8cdcschejip7ybpc","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Mattermost Boards makes it easy for you to update certain properties on cards through our drag and drop functionality. Simply drag this card from the **Later** column to the **Completed** column to automatically update the status and mark this task as complete.","fields":{},"createAt":1640035135582,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"77msur4yswfn65d8qycdyfpfawe","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Assign tasks to teammates","fields":{"value":false},"createAt":1641787609114,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7ayruwskq4b8rte64fiwz493kjo","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Create and manage checklists, like this one... :)","fields":{"value":false},"createAt":1641788123369,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7dh4oncxngj8jb8n59sefmsynac","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Add and update descriptions with Markdown","fields":{"value":false},"createAt":1641793564533,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7exhjmek1ctbexxt95w5cy1cuwo","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow cards to get notified on the latest updates","fields":{"value":false},"createAt":1641787466530,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7mgy47rzyxpdm5c5eod9x5nypea","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Set priorities and update statuses","fields":{"value":false},"createAt":1641787424191,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7nkegq1zimifpmxcrq8ntyothoe","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Provide feedback and ask questions via comments","fields":{"value":false},"createAt":1642012664514,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7peuyuzgkc3fmzczfjuzseg9ksa","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"@mention teammates so they can follow, and collaborate on, comments and descriptions","fields":{"value":false},"createAt":1641787459538,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7tuw1my7b7fnxd8sfyzpz6dd1sc","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Manage deadlines and milestones","fields":{"value":false},"createAt":1641787583043,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a778mcixrm7byzb4mxrixjtrwwa","parentId":"c9h4wpgh1ajyzfdqoyotohtj6oy","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Cards allow your entire team to manage and collaborate on a task in one place. Within a card, your team can:","fields":{},"createAt":1641786489535,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7cux9rwr1b3rjmxakbipeoxky6h","parentId":"cfkikng8egbr878ryaztmpkno4w","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"74uia99m9btr8peydw7oexn37tw.gif"},"createAt":1643638662994,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"apedf7fbrspgt3cg8e5worq1gqa","parentId":"cfkikng8egbr878ryaztmpkno4w","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"A board helps you manage your project, organize tasks, and collaborate with your team all in one place.","fields":{},"createAt":1641797295263,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"as7511u6t1pdc7fe7zrbzdfg51y","parentId":"cfkikng8egbr878ryaztmpkno4w","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"To create your own board, select the \"+\" on the top of the left hand sidebar. Choose from one of our standard templates and see how they can help you get started with your next project:\n\n- **Project Tasks**: Stay on top of your project tasks, track progress, and set priorities. \n- **Meeting Agenda**: Set your meeting agendas for recurring team meetings and 1:1s.\n- **Roadmap**: Plan your roadmap and manage your releases more efficiently.\n- **Personal Tasks**: Organize your life and track your personal tasks.\n- **Content Calendar**: Plan your editorial content, assign work, and track deadlines.\n- **Personal Goals**: Set and accomplish new personal goals and milestones.","fields":{},"createAt":1641788827589,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7hkn59merfbf38gzxf7sabewuma","parentId":"cm8yz355wbtfd7rtpgs655wbr4e","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7knxbyuiedtdafcgmropgkrtybr.gif"},"createAt":1643638846658,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a5z5po5apabfibkgmkq53dxe9dw","parentId":"cm8yz355wbtfd7rtpgs655wbr4e","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Keep stakeholders and customers up-to-date on project progress by sharing your board.","fields":{},"createAt":1642198459390,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ag791jfbc47gobroeo9ie1afcdo","parentId":"cm8yz355wbtfd7rtpgs655wbr4e","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"To share a board, select **Share** at the top right of the Board view. Copy the link to share the board internally with your team or generate public link that can be accessed by anyone externally.","fields":{},"createAt":1642199464341,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"76bqrrm8dobr37kttya6jhznjih","parentId":"cmjtfip8a738nbr33shzmgk559o","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7iw4rxx7jj7bypmdotd9z469cyh.png"},"createAt":1643143198631,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"auow16g4f4tf4z89qrxbg3btxba","parentId":"cmjtfip8a738nbr33shzmgk559o","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"To create a new card, simply do any of the following:\n- Select \"**New**\" on the top right header\n- Select \"**+ New**\" below any column\n- Select \"**+**\" to the right of any columnn header","fields":{},"createAt":1640034820888,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"aykjshfjrxpd9zngqruenqn5s7h","parentId":"cmjtfip8a738nbr33shzmgk559o","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Mattermost Boards helps you manage and track all your project tasks with **Cards**.","fields":{},"createAt":1641504858080,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7y5hxcb9zzprzzrqeu675rtnpae","parentId":"cq1gmiwhx4jgd7q9ad9c1icasqr","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7ek6wbpp19jfoujs1goh6kttbby.gif"},"createAt":1643638818402,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a3qa1n69wc7d1u8krumz9ogcidy","parentId":"cq1gmiwhx4jgd7q9ad9c1icasqr","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"After you've copied the link, paste it into any channel or Direct Message to share the card. A preview of the card will display within the channel with a link back to the card on Boards.","fields":{},"createAt":1642195798726,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a4z7htb6catgaue5npinux4tmrc","parentId":"cq1gmiwhx4jgd7q9ad9c1icasqr","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"To share a card, you'll need to copy the card link first. You can:\n\n- Open a card and select the options menu button at the top right of the card.\n- Open the board view and hover your mouse over any card to access the options menu button.","fields":{},"createAt":1642193170054,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"arpj7spx9op8jumm6yfdsxwpeuw","parentId":"cq1gmiwhx4jgd7q9ad9c1icasqr","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Cards can be linked and shared with teammates directly on Channels. Card previews are displayed when shared on Channels, so your team can discuss work items and get the relevant context without having to switch over to Boards.","fields":{},"createAt":1642193162587,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7oz9pp3bkopgfpycph3oqgze8uw","parentId":"cse6a9d81tfyd7e34cbmfttbgte","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7dybb6t8fj3nrdft7nerhuf784y.png"},"createAt":1643142381491,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ad9fecctco7ggjjeo9usfpwkfpa","parentId":"cse6a9d81tfyd7e34cbmfttbgte","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Organize and find the cards you're looking for with our filter, sort, and grouping options. From the Board header, you can quickly toggle on different properties, change the group display, set filters, and change how the cards are sorted.","fields":{},"createAt":1640034870185,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"7te5jcsrym3by7yxq4um8usj7ao","parentId":"cstm8jadnmbds9kooz4tr8sy5wr","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"78jws5m1myf8pufewzkaa6i11sc.gif"},"createAt":1643638721400,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"a538fji6kcp89fyhmgaoko7wk6c","parentId":"cstm8jadnmbds9kooz4tr8sy5wr","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Views allow your team to visualize the same cards and data from different perspectives, so they can stay up-to-date in the way that works best for them. To add a new view, go to **Add a new view** from the view drop-down, then select from any of the following views:\n\n- **Board**: Adds a Kanban board, similar to this one, that allows your team to organize cards in swimlanes grouped by any property of your choosing. This view helps you visualize your project progress.\n- **Table**: Displays cards in a table format with rows and columns. Use this view to get an overview of all your project tasks. Easily view and compare the state of all properties across all cards without needing to open individual cards.\n- **Gallery**: Displays cards in a gallery format, so you can manage and organize cards with image attachments.\n- **Calendar**: Adds a calendar view to easily visualize your cards by dates and keep track of deadlines.","fields":{},"createAt":1640035911505,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"79yggmhcyhbgdiej5w4re3k4ssy","parentId":"cut8jasi4etbd7mpqpn36fna9ba","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7d6hrtig3zt8f9cnbo1um5oxx3y.gif"},"createAt":1643638613909,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"afb3pntrwgtrwidfeo1f1dsonqy","parentId":"cut8jasi4etbd7mpqpn36fna9ba","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Customize cards to fit your needs and track the information most important to you. Boards supports a wide range of fully customizable property types. For example, you can:\n- Use the **Date** property for things like deadlines or milestones.\n- Assign owners to tasks with the **Person** property.\n- Define statuses and priorities with the **Select** property.\n- Create tags with the **Multi Select** property.\n- Link cards to webpages with the **URL** property.","fields":{},"createAt":1641611832288,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"78mzk7qpof7ybxpfjn9j9an9gkh","parentId":"cwf9xw6d6zbgfbbgd6atr336uco","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"74nt9eqzea3ydjjpgjtsxcjgrxc.gif"},"createAt":1643638766210,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"ab6ygnyg757bc9cpm9bkp8aam8e","parentId":"cwf9xw6d6zbgfbbgd6atr336uco","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"To mention a teammate use the **@ symbol with their username** in the comments or description section. They'll get a Direct Message notification via Channels and also be added as a [follower](https://docs.mattermost.com/boards/work-with-cards.html#receive-updates) to the card. \n\nWhenever any changes are made to the card, they'll automatically get notified on Channels.","fields":{},"createAt":1642177456022,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} {"type":"block","data":{"id":"akbz4abecginpjgz1etweynd7io","parentId":"cwf9xw6d6zbgfbbgd6atr336uco","rootId":"buixxjic3xjfkieees4iafdrznc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"Collaborate with teammates directly on each card using @mentions and have all the relevant context in one place.","fields":{},"createAt":1642177002644,"updateAt":1643788318634,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}} ================================================ FILE: server/assets/templates-boardarchive/version.json ================================================ {"version":2,"date":1643788318636} ================================================ FILE: server/auth/auth.go ================================================ //go:generate mockgen -destination=mocks/mockauth_interface.go -package mocks . AuthInterface package auth import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/permissions" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/pkg/errors" ) type AuthInterface interface { GetSession(token string) (*model.Session, error) IsValidReadToken(boardID string, readToken string) (bool, error) DoesUserHaveTeamAccess(userID string, teamID string) bool } // Auth authenticates sessions. type Auth struct { config *config.Configuration store store.Store permissions permissions.PermissionsService } // New returns a new Auth. func New(config *config.Configuration, store store.Store, permissions permissions.PermissionsService) *Auth { return &Auth{config: config, store: store, permissions: permissions} } // GetSession Get a user active session and refresh the session if needed. func (a *Auth) GetSession(token string) (*model.Session, error) { if len(token) < 1 { return nil, errors.New("no session token") } session, err := a.store.GetSession(token, a.config.SessionExpireTime) if err != nil { return nil, errors.Wrap(err, "unable to get the session for the token") } if session.UpdateAt < (utils.GetMillis() - utils.SecondsToMillis(a.config.SessionRefreshTime)) { _ = a.store.RefreshSession(session) } return session, nil } // IsValidReadToken validates the read token for a board. func (a *Auth) IsValidReadToken(boardID string, readToken string) (bool, error) { sharing, err := a.store.GetSharing(boardID) if model.IsErrNotFound(err) { return false, nil } if err != nil { return false, err } if !a.config.EnablePublicSharedBoards { return false, errors.New("public shared boards disabled") } if sharing != nil && (sharing.ID == boardID && sharing.Enabled && sharing.Token == readToken) { return true, nil } return false, nil } func (a *Auth) DoesUserHaveTeamAccess(userID string, teamID string) bool { return a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) } ================================================ FILE: server/auth/auth_test.go ================================================ package auth import ( "testing" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/permissions/localpermissions" mockpermissions "github.com/mattermost/focalboard/server/services/permissions/mocks" "github.com/mattermost/focalboard/server/services/store/mockstore" "github.com/mattermost/focalboard/server/utils" "github.com/pkg/errors" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type TestHelper struct { Auth *Auth Session model.Session Store *mockstore.MockStore } var mockSession = &model.Session{ ID: utils.NewID(utils.IDTypeSession), Token: "goodToken", UserID: "12345", CreateAt: utils.GetMillis() - utils.SecondsToMillis(2000), UpdateAt: utils.GetMillis() - utils.SecondsToMillis(2000), } func setupTestHelper(t *testing.T) *TestHelper { ctrl := gomock.NewController(t) ctrlPermissions := gomock.NewController(t) cfg := config.Configuration{} mockStore := mockstore.NewMockStore(ctrl) mockPermissions := mockpermissions.NewMockStore(ctrlPermissions) logger, err := mlog.NewLogger() require.NoError(t, err) newAuth := New(&cfg, mockStore, localpermissions.New(mockPermissions, logger)) // called during default template setup for every test mockStore.EXPECT().GetTemplateBoards("0", "").AnyTimes() mockStore.EXPECT().RemoveDefaultTemplates(gomock.Any()).AnyTimes() mockStore.EXPECT().InsertBlock(gomock.Any(), gomock.Any()).AnyTimes() return &TestHelper{ Auth: newAuth, Session: *mockSession, Store: mockStore, } } func TestGetSession(t *testing.T) { th := setupTestHelper(t) testcases := []struct { title string token string refreshTime int64 isError bool }{ {"fail, no token", "", 0, true}, {"fail, invalid username", "badToken", 0, true}, {"success, good token", "goodToken", 1000, false}, } th.Store.EXPECT().GetSession("badToken", gomock.Any()).Return(nil, errors.New("Invalid Token")) th.Store.EXPECT().GetSession("goodToken", gomock.Any()).Return(mockSession, nil) th.Store.EXPECT().RefreshSession(gomock.Any()).Return(nil) for _, test := range testcases { t.Run(test.title, func(t *testing.T) { if test.refreshTime > 0 { th.Auth.config.SessionRefreshTime = test.refreshTime } session, err := th.Auth.GetSession(test.token) if test.isError { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, session) } }) } } func TestIsValidReadToken(t *testing.T) { // ToDo: reimplement // th := setupTestHelper(t) // validBlockID := "testBlockID" // mockContainer := store.Container{ // TeamID: "testTeamID", // } // validReadToken := "testReadToken" // mockSharing := model.Sharing{ // ID: "testRootID", // Enabled: true, // Token: validReadToken, // } // testcases := []struct { // title string // container store.Container // blockID string // readToken string // isError bool // isSuccess bool // }{ // {"fail, error GetRootID", mockContainer, "badBlock", "", true, false}, // {"fail, rootID not found", mockContainer, "goodBlockID", "", false, false}, // {"fail, sharing throws error", mockContainer, "goodBlockID2", "", true, false}, // {"fail, bad readToken", mockContainer, validBlockID, "invalidReadToken", false, false}, // {"success", mockContainer, validBlockID, validReadToken, false, true}, // } // th.Store.EXPECT().GetRootID(gomock.Eq(mockContainer), "badBlock").Return("", errors.New("invalid block")) // th.Store.EXPECT().GetRootID(gomock.Eq(mockContainer), "goodBlockID").Return("rootNotFound", nil) // th.Store.EXPECT().GetRootID(gomock.Eq(mockContainer), "goodBlockID2").Return("rootError", nil) // th.Store.EXPECT().GetRootID(gomock.Eq(mockContainer), validBlockID).Return("testRootID", nil).Times(2) // th.Store.EXPECT().GetSharing(gomock.Eq(mockContainer), "rootNotFound").Return(nil, sql.ErrNoRows) // th.Store.EXPECT().GetSharing(gomock.Eq(mockContainer), "rootError").Return(nil, errors.New("another error")) // th.Store.EXPECT().GetSharing(gomock.Eq(mockContainer), "testRootID").Return(&mockSharing, nil).Times(2) // for _, test := range testcases { // t.Run(test.title, func(t *testing.T) { // success, err := th.Auth.IsValidReadToken(test.container, test.blockID, test.readToken) // if test.isError { // require.Error(t, err) // } else { // require.NoError(t, err) // } // if test.isSuccess { // require.True(t, success) // } else { // require.False(t, success) // } // }) // } } ================================================ FILE: server/auth/mocks/mockauth_interface.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/focalboard/server/auth (interfaces: AuthInterface) // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/focalboard/server/model" ) // MockAuthInterface is a mock of AuthInterface interface. type MockAuthInterface struct { ctrl *gomock.Controller recorder *MockAuthInterfaceMockRecorder } // MockAuthInterfaceMockRecorder is the mock recorder for MockAuthInterface. type MockAuthInterfaceMockRecorder struct { mock *MockAuthInterface } // NewMockAuthInterface creates a new mock instance. func NewMockAuthInterface(ctrl *gomock.Controller) *MockAuthInterface { mock := &MockAuthInterface{ctrl: ctrl} mock.recorder = &MockAuthInterfaceMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockAuthInterface) EXPECT() *MockAuthInterfaceMockRecorder { return m.recorder } // DoesUserHaveTeamAccess mocks base method. func (m *MockAuthInterface) DoesUserHaveTeamAccess(arg0, arg1 string) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DoesUserHaveTeamAccess", arg0, arg1) ret0, _ := ret[0].(bool) return ret0 } // DoesUserHaveTeamAccess indicates an expected call of DoesUserHaveTeamAccess. func (mr *MockAuthInterfaceMockRecorder) DoesUserHaveTeamAccess(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoesUserHaveTeamAccess", reflect.TypeOf((*MockAuthInterface)(nil).DoesUserHaveTeamAccess), arg0, arg1) } // GetSession mocks base method. func (m *MockAuthInterface) GetSession(arg0 string) (*model.Session, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSession", arg0) ret0, _ := ret[0].(*model.Session) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSession indicates an expected call of GetSession. func (mr *MockAuthInterfaceMockRecorder) GetSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSession", reflect.TypeOf((*MockAuthInterface)(nil).GetSession), arg0) } // IsValidReadToken mocks base method. func (m *MockAuthInterface) IsValidReadToken(arg0, arg1 string) (bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IsValidReadToken", arg0, arg1) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(error) return ret0, ret1 } // IsValidReadToken indicates an expected call of IsValidReadToken. func (mr *MockAuthInterfaceMockRecorder) IsValidReadToken(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsValidReadToken", reflect.TypeOf((*MockAuthInterface)(nil).IsValidReadToken), arg0, arg1) } ================================================ FILE: server/client/client.go ================================================ package client import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "strings" "github.com/mattermost/focalboard/server/api" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" ) const ( APIURLSuffix = "/api/v2" ) type RequestReaderError struct { buf []byte } func (rre RequestReaderError) Error() string { return "payload: " + string(rre.buf) } type Response struct { StatusCode int Error error Header http.Header } func BuildResponse(r *http.Response) *Response { return &Response{ StatusCode: r.StatusCode, Header: r.Header, } } func BuildErrorResponse(r *http.Response, err error) *Response { statusCode := 0 header := make(http.Header) if r != nil { statusCode = r.StatusCode header = r.Header } return &Response{ StatusCode: statusCode, Error: err, Header: header, } } func closeBody(r *http.Response) { if r.Body != nil { _, _ = io.Copy(io.Discard, r.Body) _ = r.Body.Close() } } func toJSON(v interface{}) string { b, _ := json.Marshal(v) return string(b) } type Client struct { URL string APIURL string HTTPClient *http.Client HTTPHeader map[string]string // Token if token is empty indicate client is not login yet Token string } func NewClient(url, sessionToken string) *Client { url = strings.TrimRight(url, "/") headers := map[string]string{ "X-Requested-With": "XMLHttpRequest", } return &Client{url, url + APIURLSuffix, &http.Client{}, headers, sessionToken} } func (c *Client) DoAPIGet(url, etag string) (*http.Response, error) { return c.DoAPIRequest(http.MethodGet, c.APIURL+url, "", etag) } func (c *Client) DoAPIPost(url, data string) (*http.Response, error) { return c.DoAPIRequest(http.MethodPost, c.APIURL+url, data, "") } func (c *Client) DoAPIPatch(url, data string) (*http.Response, error) { return c.DoAPIRequest(http.MethodPatch, c.APIURL+url, data, "") } func (c *Client) DoAPIPut(url, data string) (*http.Response, error) { return c.DoAPIRequest(http.MethodPut, c.APIURL+url, data, "") } func (c *Client) DoAPIDelete(url string, data string) (*http.Response, error) { return c.DoAPIRequest(http.MethodDelete, c.APIURL+url, data, "") } func (c *Client) DoAPIRequest(method, url, data, etag string) (*http.Response, error) { return c.doAPIRequestReader(method, url, strings.NewReader(data), etag) } type requestOption func(r *http.Request) func (c *Client) doAPIRequestReader(method, url string, data io.Reader, _ /* etag */ string, opts ...requestOption) (*http.Response, error) { rq, err := http.NewRequest(method, url, data) if err != nil { return nil, err } for _, opt := range opts { opt(rq) } if c.HTTPHeader != nil && len(c.HTTPHeader) > 0 { for k, v := range c.HTTPHeader { rq.Header.Set(k, v) } } if c.Token != "" { rq.Header.Set("Authorization", "Bearer "+c.Token) } rp, err := c.HTTPClient.Do(rq) if err != nil || rp == nil { return nil, err } if rp.StatusCode == http.StatusNotModified { return rp, nil } if rp.StatusCode >= http.StatusMultipleChoices { defer closeBody(rp) b, err := io.ReadAll(rp.Body) if err != nil { return rp, fmt.Errorf("error when parsing response with code %d: %w", rp.StatusCode, err) } return rp, RequestReaderError{b} } return rp, nil } func (c *Client) GetTeamRoute(teamID string) string { return fmt.Sprintf("%s/%s", c.GetTeamsRoute(), teamID) } func (c *Client) GetTeamsRoute() string { return "/teams" } func (c *Client) GetBlockRoute(boardID, blockID string) string { return fmt.Sprintf("%s/%s", c.GetBlocksRoute(boardID), blockID) } func (c *Client) GetBoardsRoute() string { return "/boards" } func (c *Client) GetBoardRoute(boardID string) string { return fmt.Sprintf("%s/%s", c.GetBoardsRoute(), boardID) } func (c *Client) GetBoardMetadataRoute(boardID string) string { return fmt.Sprintf("%s/%s/metadata", c.GetBoardsRoute(), boardID) } func (c *Client) GetJoinBoardRoute(boardID string) string { return fmt.Sprintf("%s/%s/join", c.GetBoardsRoute(), boardID) } func (c *Client) GetLeaveBoardRoute(boardID string) string { return fmt.Sprintf("%s/%s/join", c.GetBoardsRoute(), boardID) } func (c *Client) GetBlocksRoute(boardID string) string { return fmt.Sprintf("%s/blocks", c.GetBoardRoute(boardID)) } func (c *Client) GetAllBlocksRoute(boardID string) string { return fmt.Sprintf("%s/blocks?all=true", c.GetBoardRoute(boardID)) } func (c *Client) GetBoardsAndBlocksRoute() string { return "/boards-and-blocks" } func (c *Client) GetCardsRoute() string { return "/cards" } func (c *Client) GetCardRoute(cardID string) string { return fmt.Sprintf("%s/%s", c.GetCardsRoute(), cardID) } func (c *Client) GetTeam(teamID string) (*model.Team, *Response) { r, err := c.DoAPIGet(c.GetTeamRoute(teamID), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.TeamFromJSON(r.Body), BuildResponse(r) } func (c *Client) GetBlocksForBoard(boardID string) ([]*model.Block, *Response) { r, err := c.DoAPIGet(c.GetBlocksRoute(boardID), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BlocksFromJSON(r.Body), BuildResponse(r) } func (c *Client) GetAllBlocksForBoard(boardID string) ([]*model.Block, *Response) { r, err := c.DoAPIGet(c.GetAllBlocksRoute(boardID), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BlocksFromJSON(r.Body), BuildResponse(r) } const disableNotifyQueryParam = "disable_notify=true" func (c *Client) PatchBlock(boardID, blockID string, blockPatch *model.BlockPatch, disableNotify bool) (bool, *Response) { var queryParams string if disableNotify { queryParams = "?" + disableNotifyQueryParam } r, err := c.DoAPIPatch(c.GetBlockRoute(boardID, blockID)+queryParams, toJSON(blockPatch)) if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) DuplicateBoard(boardID string, asTemplate bool, teamID string) (*model.BoardsAndBlocks, *Response) { queryParams := "?asTemplate=false&" if asTemplate { queryParams = "?asTemplate=true" } if len(teamID) > 0 { queryParams = queryParams + "&toTeam=" + teamID } r, err := c.DoAPIPost(c.GetBoardRoute(boardID)+"/duplicate"+queryParams, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardsAndBlocksFromJSON(r.Body), BuildResponse(r) } func (c *Client) DuplicateBlock(boardID, blockID string, asTemplate bool) (bool, *Response) { queryParams := "?asTemplate=false" if asTemplate { queryParams = "?asTemplate=true" } r, err := c.DoAPIPost(c.GetBlockRoute(boardID, blockID)+"/duplicate"+queryParams, "") if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) UndeleteBlock(boardID, blockID string) (bool, *Response) { r, err := c.DoAPIPost(c.GetBlockRoute(boardID, blockID)+"/undelete", "") if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) InsertBlocks(boardID string, blocks []*model.Block, disableNotify bool) ([]*model.Block, *Response) { var queryParams string if disableNotify { queryParams = "?" + disableNotifyQueryParam } r, err := c.DoAPIPost(c.GetBlocksRoute(boardID)+queryParams, toJSON(blocks)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BlocksFromJSON(r.Body), BuildResponse(r) } func (c *Client) DeleteBlock(boardID, blockID string, disableNotify bool) (bool, *Response) { var queryParams string if disableNotify { queryParams = "?" + disableNotifyQueryParam } r, err := c.DoAPIDelete(c.GetBlockRoute(boardID, blockID)+queryParams, "") if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } // // Cards // func (c *Client) CreateCard(boardID string, card *model.Card, disableNotify bool) (*model.Card, *Response) { var queryParams string if disableNotify { queryParams = "?" + disableNotifyQueryParam } r, err := c.DoAPIPost(c.GetBoardRoute(boardID)+"/cards"+queryParams, toJSON(card)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var cardNew *model.Card if err := json.NewDecoder(r.Body).Decode(&cardNew); err != nil { return nil, BuildErrorResponse(r, err) } return cardNew, BuildResponse(r) } func (c *Client) GetCards(boardID string, page int, perPage int) ([]*model.Card, *Response) { url := fmt.Sprintf("%s/cards?page=%d&per_page=%d", c.GetBoardRoute(boardID), page, perPage) r, err := c.DoAPIGet(url, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var cards []*model.Card if err := json.NewDecoder(r.Body).Decode(&cards); err != nil { return nil, BuildErrorResponse(r, err) } return cards, BuildResponse(r) } func (c *Client) PatchCard(cardID string, cardPatch *model.CardPatch, disableNotify bool) (*model.Card, *Response) { var queryParams string if disableNotify { queryParams = "?" + disableNotifyQueryParam } r, err := c.DoAPIPatch(c.GetCardRoute(cardID)+queryParams, toJSON(cardPatch)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var cardNew *model.Card if err := json.NewDecoder(r.Body).Decode(&cardNew); err != nil { return nil, BuildErrorResponse(r, err) } return cardNew, BuildResponse(r) } func (c *Client) GetCard(cardID string) (*model.Card, *Response) { r, err := c.DoAPIGet(c.GetCardRoute(cardID), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var card *model.Card if err := json.NewDecoder(r.Body).Decode(&card); err != nil { return nil, BuildErrorResponse(r, err) } return card, BuildResponse(r) } // // Boards and blocks. // func (c *Client) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks) (*model.BoardsAndBlocks, *Response) { r, err := c.DoAPIPost(c.GetBoardsAndBlocksRoute(), toJSON(bab)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardsAndBlocksFromJSON(r.Body), BuildResponse(r) } func (c *Client) CreateCategory(category model.Category) (*model.Category, *Response) { r, err := c.DoAPIPost(c.GetTeamRoute(category.TeamID)+"/categories", toJSON(category)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.CategoryFromJSON(r.Body), BuildResponse(r) } func (c *Client) DeleteCategory(teamID, categoryID string) *Response { r, err := c.DoAPIDelete(c.GetTeamRoute(teamID)+"/categories/"+categoryID, "") if err != nil { return BuildErrorResponse(r, err) } defer closeBody(r) return BuildResponse(r) } func (c *Client) UpdateCategoryBoard(teamID, categoryID, boardID string) *Response { r, err := c.DoAPIPost(fmt.Sprintf("%s/categories/%s/boards/%s", c.GetTeamRoute(teamID), categoryID, boardID), "") if err != nil { return BuildErrorResponse(r, err) } defer closeBody(r) return BuildResponse(r) } func (c *Client) GetUserCategoryBoards(teamID string) ([]model.CategoryBoards, *Response) { r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/categories", "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var categoryBoards []model.CategoryBoards _ = json.NewDecoder(r.Body).Decode(&categoryBoards) return categoryBoards, BuildResponse(r) } func (c *Client) ReorderCategories(teamID string, newOrder []string) ([]string, *Response) { r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/reorder", toJSON(newOrder)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var updatedCategoryOrder []string _ = json.NewDecoder(r.Body).Decode(&updatedCategoryOrder) return updatedCategoryOrder, BuildResponse(r) } func (c *Client) ReorderCategoryBoards(teamID, categoryID string, newOrder []string) ([]string, *Response) { r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/reorder", toJSON(newOrder)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var updatedBoardsOrder []string _ = json.NewDecoder(r.Body).Decode(&updatedBoardsOrder) return updatedBoardsOrder, BuildResponse(r) } func (c *Client) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks) (*model.BoardsAndBlocks, *Response) { r, err := c.DoAPIPatch(c.GetBoardsAndBlocksRoute(), toJSON(pbab)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardsAndBlocksFromJSON(r.Body), BuildResponse(r) } func (c *Client) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks) (bool, *Response) { r, err := c.DoAPIDelete(c.GetBoardsAndBlocksRoute(), toJSON(dbab)) if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } // Sharing func (c *Client) GetSharingRoute(boardID string) string { return fmt.Sprintf("%s/sharing", c.GetBoardRoute(boardID)) } func (c *Client) GetSharing(boardID string) (*model.Sharing, *Response) { r, err := c.DoAPIGet(c.GetSharingRoute(boardID), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) sharing := model.SharingFromJSON(r.Body) return &sharing, BuildResponse(r) } func (c *Client) PostSharing(sharing *model.Sharing) (bool, *Response) { r, err := c.DoAPIPost(c.GetSharingRoute(sharing.ID), toJSON(sharing)) if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) GetRegisterRoute() string { return "/register" } func (c *Client) Register(request *model.RegisterRequest) (bool, *Response) { r, err := c.DoAPIPost(c.GetRegisterRoute(), toJSON(&request)) if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) GetLoginRoute() string { return "/login" } func (c *Client) Login(request *model.LoginRequest) (*model.LoginResponse, *Response) { r, err := c.DoAPIPost(c.GetLoginRoute(), toJSON(&request)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) data, err := model.LoginResponseFromJSON(r.Body) if err != nil { return nil, BuildErrorResponse(r, err) } if data.Token != "" { c.Token = data.Token } return data, BuildResponse(r) } func (c *Client) GetMeRoute() string { return "/users/me" } func (c *Client) GetMe() (*model.User, *Response) { r, err := c.DoAPIGet(c.GetMeRoute(), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) me, err := model.UserFromJSON(r.Body) if err != nil { return nil, BuildErrorResponse(r, err) } return me, BuildResponse(r) } func (c *Client) GetUserID() string { me, _ := c.GetMe() if me == nil { return "" } return me.ID } func (c *Client) GetUserRoute(id string) string { return fmt.Sprintf("/users/%s", id) } func (c *Client) GetUser(id string) (*model.User, *Response) { r, err := c.DoAPIGet(c.GetUserRoute(id), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) user, err := model.UserFromJSON(r.Body) if err != nil { return nil, BuildErrorResponse(r, err) } return user, BuildResponse(r) } func (c *Client) GetUserList(ids []string) ([]model.User, *Response) { r, err := c.DoAPIPost("/users", toJSON(ids)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) requestBody, err := io.ReadAll(r.Body) if err != nil { return nil, BuildErrorResponse(r, err) } var users []model.User err = json.Unmarshal(requestBody, &users) if err != nil { return nil, BuildErrorResponse(r, err) } return users, BuildResponse(r) } func (c *Client) GetUserChangePasswordRoute(id string) string { return fmt.Sprintf("/users/%s/changepassword", id) } func (c *Client) UserChangePassword(id string, data *model.ChangePasswordRequest) (bool, *Response) { r, err := c.DoAPIPost(c.GetUserChangePasswordRoute(id), toJSON(&data)) if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) CreateBoard(board *model.Board) (*model.Board, *Response) { r, err := c.DoAPIPost(c.GetBoardsRoute(), toJSON(board)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardFromJSON(r.Body), BuildResponse(r) } func (c *Client) PatchBoard(boardID string, patch *model.BoardPatch) (*model.Board, *Response) { r, err := c.DoAPIPatch(c.GetBoardRoute(boardID), toJSON(patch)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardFromJSON(r.Body), BuildResponse(r) } func (c *Client) DeleteBoard(boardID string) (bool, *Response) { r, err := c.DoAPIDelete(c.GetBoardRoute(boardID), "") if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) UndeleteBoard(boardID string) (bool, *Response) { r, err := c.DoAPIPost(c.GetBoardRoute(boardID)+"/undelete", "") if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) GetBoard(boardID, readToken string) (*model.Board, *Response) { url := c.GetBoardRoute(boardID) if readToken != "" { url += fmt.Sprintf("?read_token=%s", readToken) } r, err := c.DoAPIGet(url, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardFromJSON(r.Body), BuildResponse(r) } func (c *Client) GetBoardMetadata(boardID, readToken string) (*model.BoardMetadata, *Response) { url := c.GetBoardMetadataRoute(boardID) if readToken != "" { url += fmt.Sprintf("?read_token=%s", readToken) } r, err := c.DoAPIGet(url, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardMetadataFromJSON(r.Body), BuildResponse(r) } func (c *Client) GetBoardsForTeam(teamID string) ([]*model.Board, *Response) { r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards", "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardsFromJSON(r.Body), BuildResponse(r) } func (c *Client) SearchBoardsForUser(teamID, term string, field model.BoardSearchField) ([]*model.Board, *Response) { query := fmt.Sprintf("q=%s&field=%s", term, field) r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?"+query, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardsFromJSON(r.Body), BuildResponse(r) } func (c *Client) SearchBoardsForTeam(teamID, term string) ([]*model.Board, *Response) { r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?q="+term, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardsFromJSON(r.Body), BuildResponse(r) } func (c *Client) GetMembersForBoard(boardID string) ([]*model.BoardMember, *Response) { r, err := c.DoAPIGet(c.GetBoardRoute(boardID)+"/members", "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardMembersFromJSON(r.Body), BuildResponse(r) } func (c *Client) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, *Response) { r, err := c.DoAPIPost(c.GetBoardRoute(member.BoardID)+"/members", toJSON(member)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardMemberFromJSON(r.Body), BuildResponse(r) } func (c *Client) JoinBoard(boardID string) (*model.BoardMember, *Response) { r, err := c.DoAPIPost(c.GetJoinBoardRoute(boardID), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardMemberFromJSON(r.Body), BuildResponse(r) } func (c *Client) LeaveBoard(boardID string) (*model.BoardMember, *Response) { r, err := c.DoAPIPost(c.GetLeaveBoardRoute(boardID), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardMemberFromJSON(r.Body), BuildResponse(r) } func (c *Client) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, *Response) { r, err := c.DoAPIPut(c.GetBoardRoute(member.BoardID)+"/members/"+member.UserID, toJSON(member)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardMemberFromJSON(r.Body), BuildResponse(r) } func (c *Client) DeleteBoardMember(member *model.BoardMember) (bool, *Response) { r, err := c.DoAPIDelete(c.GetBoardRoute(member.BoardID)+"/members/"+member.UserID, "") if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) GetTeamUploadFileRoute(teamID, boardID string) string { return fmt.Sprintf("%s/%s/files", c.GetTeamRoute(teamID), boardID) } func (c *Client) TeamUploadFile(teamID, boardID string, data io.Reader) (*api.FileUploadResponse, *Response) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile(api.UploadFormFileKey, "file") if err != nil { return nil, &Response{Error: err} } if _, err = io.Copy(part, data); err != nil { return nil, &Response{Error: err} } writer.Close() opt := func(r *http.Request) { r.Header.Add("Content-Type", writer.FormDataContentType()) } r, err := c.doAPIRequestReader(http.MethodPost, c.APIURL+c.GetTeamUploadFileRoute(teamID, boardID), body, "", opt) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) fileUploadResponse, err := api.FileUploadResponseFromJSON(r.Body) if err != nil { return nil, BuildErrorResponse(r, err) } return fileUploadResponse, BuildResponse(r) } func (c *Client) TeamUploadFileInfo(teamID, boardID string, fileName string) (*mmModel.FileInfo, *Response) { r, err := c.DoAPIGet(fmt.Sprintf("/files/teams/%s/%s/%s/info", teamID, boardID, fileName), "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) fileInfoResponse, error := api.FileInfoResponseFromJSON(r.Body) if error != nil { return nil, BuildErrorResponse(r, error) } return fileInfoResponse, BuildResponse(r) } func (c *Client) GetSubscriptionsRoute() string { return "/subscriptions" } func (c *Client) CreateSubscription(sub *model.Subscription) (*model.Subscription, *Response) { r, err := c.DoAPIPost(c.GetSubscriptionsRoute(), toJSON(&sub)) if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) subNew, err := model.SubscriptionFromJSON(r.Body) if err != nil { return nil, BuildErrorResponse(r, err) } return subNew, BuildResponse(r) } func (c *Client) DeleteSubscription(blockID string, subscriberID string) *Response { url := fmt.Sprintf("%s/%s/%s", c.GetSubscriptionsRoute(), blockID, subscriberID) r, err := c.DoAPIDelete(url, "") if err != nil { return BuildErrorResponse(r, err) } defer closeBody(r) return BuildResponse(r) } func (c *Client) GetSubscriptions(subscriberID string) ([]*model.Subscription, *Response) { url := fmt.Sprintf("%s/%s", c.GetSubscriptionsRoute(), subscriberID) r, err := c.DoAPIGet(url, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var subs []*model.Subscription err = json.NewDecoder(r.Body).Decode(&subs) if err != nil { return nil, BuildErrorResponse(r, err) } return subs, BuildResponse(r) } func (c *Client) GetTemplatesForTeam(teamID string) ([]*model.Board, *Response) { r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/templates", "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) return model.BoardsFromJSON(r.Body), BuildResponse(r) } func (c *Client) ExportBoardArchive(boardID string) ([]byte, *Response) { r, err := c.DoAPIGet(c.GetBoardRoute(boardID)+"/archive/export", "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) buf, err := io.ReadAll(r.Body) if err != nil { return nil, BuildErrorResponse(r, err) } return buf, BuildResponse(r) } func (c *Client) ImportArchive(teamID string, data io.Reader) *Response { body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile(api.UploadFormFileKey, "file") if err != nil { return &Response{Error: err} } if _, err = io.Copy(part, data); err != nil { return &Response{Error: err} } writer.Close() opt := func(r *http.Request) { r.Header.Add("Content-Type", writer.FormDataContentType()) } r, err := c.doAPIRequestReader(http.MethodPost, c.APIURL+c.GetTeamRoute(teamID)+"/archive/import", body, "", opt) if err != nil { return BuildErrorResponse(r, err) } defer closeBody(r) return BuildResponse(r) } func (c *Client) MoveContentBlock(srcBlockID string, dstBlockID string, where string, userID string) (bool, *Response) { r, err := c.DoAPIPost("/content-blocks/"+srcBlockID+"/moveto/"+where+"/"+dstBlockID, "") if err != nil { return false, BuildErrorResponse(r, err) } defer closeBody(r) return true, BuildResponse(r) } func (c *Client) GetBoardsForCompliance(teamID string, page, perPage int) (*model.BoardsComplianceResponse, *Response) { query := fmt.Sprintf("?team_id=%s&page=%d&per_page=%d", teamID, page, perPage) r, err := c.DoAPIGet("/admin/boards"+query, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var res *model.BoardsComplianceResponse err = json.NewDecoder(r.Body).Decode(&res) if err != nil { return nil, BuildErrorResponse(r, err) } return res, BuildResponse(r) } func (c *Client) GetBoardsComplianceHistory( modifiedSince int64, includeDeleted bool, teamID string, page, perPage int) (*model.BoardsComplianceHistoryResponse, *Response) { query := fmt.Sprintf("?modified_since=%d&include_deleted=%t&team_id=%s&page=%d&per_page=%d", modifiedSince, includeDeleted, teamID, page, perPage) r, err := c.DoAPIGet("/admin/boards_history"+query, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var res *model.BoardsComplianceHistoryResponse err = json.NewDecoder(r.Body).Decode(&res) if err != nil { return nil, BuildErrorResponse(r, err) } return res, BuildResponse(r) } func (c *Client) GetBlocksComplianceHistory( modifiedSince int64, includeDeleted bool, teamID, boardID string, page, perPage int) (*model.BlocksComplianceHistoryResponse, *Response) { query := fmt.Sprintf("?modified_since=%d&include_deleted=%t&team_id=%s&board_id=%s&page=%d&per_page=%d", modifiedSince, includeDeleted, teamID, boardID, page, perPage) r, err := c.DoAPIGet("/admin/blocks_history"+query, "") if err != nil { return nil, BuildErrorResponse(r, err) } defer closeBody(r) var res *model.BlocksComplianceHistoryResponse err = json.NewDecoder(r.Body).Decode(&res) if err != nil { return nil, BuildErrorResponse(r, err) } return res, BuildResponse(r) } func (c *Client) HideBoard(teamID, categoryID, boardID string) *Response { r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/boards/"+boardID+"/hide", "") if err != nil { return BuildErrorResponse(r, err) } defer closeBody(r) return BuildResponse(r) } func (c *Client) UnhideBoard(teamID, categoryID, boardID string) *Response { r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/boards/"+boardID+"/unhide", "") if err != nil { return BuildErrorResponse(r, err) } defer closeBody(r) return BuildResponse(r) } ================================================ FILE: server/go.mod ================================================ module github.com/mattermost/focalboard/server go 1.21 toolchain go1.21.8 require ( github.com/Masterminds/squirrel v1.5.4 github.com/golang/mock v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.1 github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 github.com/lib/pq v1.10.9 github.com/mattermost/logr/v2 v2.0.21 github.com/mattermost/mattermost/server/public v0.1.3 github.com/mattermost/mattermost/server/v8 v8.0.0-20240529104128-9d30a62c9471 github.com/mattermost/morph v1.1.0 github.com/mattn/go-sqlite3 v2.0.3+incompatible github.com/mgdelacroix/foundation v0.0.0-20230510073833-0660207768ef github.com/oklog/run v1.1.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.19.1 github.com/rivo/uniseg v0.4.7 github.com/rudderlabs/analytics-go v3.3.3+incompatible github.com/sergi/go-diff v1.3.1 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 github.com/wiggin77/merror v1.0.5 golang.org/x/crypto v0.23.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect github.com/fatih/color v1.17.0 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.6.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minio-go/v7 v7.0.70 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.53.0 // indirect github.com/prometheus/procfs v0.15.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.5.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/segmentio/backo-go v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tinylib/msgp v1.1.9 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/srslog v1.0.1 // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect github.com/yuin/goldmark v1.7.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect google.golang.org/grpc v1.64.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect modernc.org/libc v1.50.9 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect modernc.org/sqlite v1.29.10 // indirect modernc.org/strutil v1.2.0 // indirect modernc.org/token v1.1.0 // indirect ) ================================================ FILE: server/go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a h1:etIrTD8BQqzColk9nKRusM9um5+1q0iOEJLqfBMIK64= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go.mod h1:emQhSYTXqB0xxjLITTw4EaWZ+8IIQYw+kx9GqNUKdLg= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI= github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 h1:+AIlO01SKT9sfWU5CLWi0cfHc7dQwgGz3FhFRzXLoMg= github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94/go.mod h1:TcE3PIIkVWbP/HjhRAafgCjRKvDOi086iqp9VkNX/ng= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34= github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb2BTCsOdamENjjWCI6qmfHLbk6OZI= github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.21 h1:CMHsP+nrbRlEC4g7BwOk1GAnMtHkniFhlSQPXy52be4= github.com/mattermost/logr/v2 v2.0.21/go.mod h1:kZkB/zqKL9e+RY5gB3vGpsyenC+TpuiOenjMkvJJbzc= github.com/mattermost/mattermost/server/public v0.1.3 h1:A3hQ3rNCwHfKAVxe7Hk3Zd9p2pyUKRmxtRPnkWP5SFM= github.com/mattermost/mattermost/server/public v0.1.3/go.mod h1:PDPb/iqzJJ5ZvK/m70oDF55AXN/cOvVFj96Yu4e6j+Q= github.com/mattermost/mattermost/server/v8 v8.0.0-20240529104128-9d30a62c9471 h1:LxlvPGImhPoZ16qJtZHfooqfIG2UGsbcIRNiTqQ/5Is= github.com/mattermost/mattermost/server/v8 v8.0.0-20240529104128-9d30a62c9471/go.mod h1:qQjPPGKiugHw6Tunlmq3cVDkKFFbgtMxIvyNJoN+p3Y= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgdelacroix/foundation v0.0.0-20230510073833-0660207768ef h1:xSk08nuyfWQY5tpJO3qC3eKo8yDyjkIL0hIEMHTYOLI= github.com/mgdelacroix/foundation v0.0.0-20230510073833-0660207768ef/go.mod h1:ZwobEfNHde7sU2pGybCWEnSlQ2r+MGrHGOKLphHZ42g= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.70 h1:1u9NtMgfK1U42kUxcsl5v0yj6TEOPR497OAQxpJnn2g= github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI5Ek= github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rudderlabs/analytics-go v3.3.3+incompatible h1:OG0XlKoXfr539e2t1dXtTB+Gr89uFW+OUNQBVhHIIBY= github.com/rudderlabs/analytics-go v3.3.3+incompatible/go.mod h1:LF8/ty9kUX4PTY3l5c97K3nZZaX5Hwsvt+NBaRL/f30= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/segmentio/backo-go v1.1.0 h1:cJIfHQUdmLsd8t9IXqf5J8SdrOMn9vMa7cIvOavHAhc= github.com/segmentio/backo-go v1.1.0/go.mod h1:ckenwdf+v/qbyhVdNPWHnqh2YdJBED1O9cidYyM5J18= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU= github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME= github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0= github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8= github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 h1:vpzMC/iZhYFAjJzHU0Cfuq+w1vLLsF2vLkDrPjzKYck= golang.org/x/exp v0.0.0-20240529005216-23cca8864a10/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs= modernc.org/ccgo/v4 v4.17.8/go.mod h1:buJnJ6Fn0tyAdP/dqePbrrvLyr6qslFfTbFrCuaYvtA= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8= modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/libc v1.50.9 h1:hIWf1uz55lorXQhfoEoezdUHjxzuO6ceshET/yWjSjk= modernc.org/libc v1.50.9/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= ================================================ FILE: server/go.tools.mod ================================================ module github.com/mattermost/focalboard/server go 1.19 require github.com/golang/mock v1.6.0 require ( github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect golang.org/x/mod v0.4.2 // indirect golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect golang.org/x/tools v0.1.1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect ) ================================================ FILE: server/go.tools.sum ================================================ github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0 h1:jlYHihg//f7RRwuPfptm04yp4s7O6Kw8EZiVYIGcH0g= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts= github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262 h1:qsl9y/CJx34tuA7QCPNp86JNJe4spst6Ff8MjvPUdPg= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e h1:aZzprAO9/8oim3qStq3wc1Xuxx4QmAGriC4VU4ojemQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1 h1:wGiQel/hW0NnEkJUk8lbzkX2gFJU6PFxf1v5OlCfuOs= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= ================================================ FILE: server/integrationtests/blocks_test.go ================================================ package integrationtests import ( "testing" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func TestGetBlocks(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() board := th.CreateBoard("team-id", model.BoardTypeOpen) initialID1 := utils.NewID(utils.IDTypeBlock) initialID2 := utils.NewID(utils.IDTypeBlock) newBlocks := []*model.Block{ { ID: initialID1, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, }, { ID: initialID2, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, }, } newBlocks, resp := th.Client.InsertBlocks(board.ID, newBlocks, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 2) blockID1 := newBlocks[0].ID blockID2 := newBlocks[1].ID blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 2) blockIDs := make([]string, len(blocks)) for i, b := range blocks { blockIDs[i] = b.ID } require.Contains(t, blockIDs, blockID1) require.Contains(t, blockIDs, blockID2) } func TestPostBlock(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() board := th.CreateBoard("team-id", model.BoardTypeOpen) var blockID1 string var blockID2 string var blockID3 string t.Run("Create a single block", func(t *testing.T) { initialID1 := utils.NewID(utils.IDTypeBlock) block := &model.Block{ ID: initialID1, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, Title: "New title", } newBlocks, resp := th.Client.InsertBlocks(board.ID, []*model.Block{block}, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 1) blockID1 = newBlocks[0].ID blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 1) blockIDs := make([]string, len(blocks)) for i, b := range blocks { blockIDs[i] = b.ID } require.Contains(t, blockIDs, blockID1) }) t.Run("Create a couple of blocks in the same call", func(t *testing.T) { initialID2 := utils.NewID(utils.IDTypeBlock) initialID3 := utils.NewID(utils.IDTypeBlock) newBlocks := []*model.Block{ { ID: initialID2, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, }, { ID: initialID3, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, }, } newBlocks, resp := th.Client.InsertBlocks(board.ID, newBlocks, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 2) blockID2 = newBlocks[0].ID blockID3 = newBlocks[1].ID require.NotEqual(t, initialID2, blockID2) require.NotEqual(t, initialID3, blockID3) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 3) blockIDs := make([]string, len(blocks)) for i, b := range blocks { blockIDs[i] = b.ID } require.Contains(t, blockIDs, blockID1) require.Contains(t, blockIDs, blockID2) require.Contains(t, blockIDs, blockID3) }) t.Run("Update a block should not be possible through the insert endpoint", func(t *testing.T) { block := &model.Block{ ID: blockID1, BoardID: board.ID, CreateAt: 1, UpdateAt: 20, Type: model.TypeCard, Title: "Updated title", } newBlocks, resp := th.Client.InsertBlocks(board.ID, []*model.Block{block}, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 1) blockID4 := newBlocks[0].ID require.NotEqual(t, blockID1, blockID4) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 4) var block4 *model.Block for _, b := range blocks { if b.ID == blockID4 { block4 = b } } require.NotNil(t, block4) require.Equal(t, "Updated title", block4.Title) }) } func TestPatchBlock(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() initialID := utils.NewID(utils.IDTypeBlock) board := th.CreateBoard("team-id", model.BoardTypeOpen) time.Sleep(10 * time.Millisecond) block := &model.Block{ ID: initialID, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, Title: "New title", Fields: map[string]interface{}{"test": "test value", "test2": "test value 2"}, } newBlocks, resp := th.Client.InsertBlocks(board.ID, []*model.Block{block}, false) th.CheckOK(resp) require.Len(t, newBlocks, 1) blockID := newBlocks[0].ID t.Run("Patch a block basic field", func(t *testing.T) { newTitle := "Updated title" blockPatch := &model.BlockPatch{ Title: &newTitle, } _, resp := th.Client.PatchBlock(board.ID, blockID, blockPatch, false) require.NoError(t, resp.Error) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 1) var updatedBlock *model.Block for _, b := range blocks { if b.ID == blockID { updatedBlock = b } } require.NotNil(t, updatedBlock) require.Equal(t, "Updated title", updatedBlock.Title) }) t.Run("Patch a block custom fields", func(t *testing.T) { blockPatch := &model.BlockPatch{ UpdatedFields: map[string]interface{}{ "test": "new test value", "test3": "new field", }, } _, resp := th.Client.PatchBlock(board.ID, blockID, blockPatch, false) require.NoError(t, resp.Error) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 1) var updatedBlock *model.Block for _, b := range blocks { if b.ID == blockID { updatedBlock = b } } require.NotNil(t, updatedBlock) require.Equal(t, "new test value", updatedBlock.Fields["test"]) require.Equal(t, "new field", updatedBlock.Fields["test3"]) }) t.Run("Patch a block to remove custom fields", func(t *testing.T) { blockPatch := &model.BlockPatch{ DeletedFields: []string{"test", "test3", "test100"}, } _, resp := th.Client.PatchBlock(board.ID, blockID, blockPatch, false) require.NoError(t, resp.Error) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 1) var updatedBlock *model.Block for _, b := range blocks { if b.ID == blockID { updatedBlock = b } } require.NotNil(t, updatedBlock) require.Equal(t, nil, updatedBlock.Fields["test"]) require.Equal(t, "test value 2", updatedBlock.Fields["test2"]) require.Equal(t, nil, updatedBlock.Fields["test3"]) }) } func TestDeleteBlock(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() board := th.CreateBoard("team-id", model.BoardTypeOpen) time.Sleep(10 * time.Millisecond) var blockID string t.Run("Create a block", func(t *testing.T) { initialID := utils.NewID(utils.IDTypeBlock) block := &model.Block{ ID: initialID, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, Title: "New title", } newBlocks, resp := th.Client.InsertBlocks(board.ID, []*model.Block{block}, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 1) require.NotZero(t, newBlocks[0].ID) require.NotEqual(t, initialID, newBlocks[0].ID) blockID = newBlocks[0].ID blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, 1) blockIDs := make([]string, len(blocks)) for i, b := range blocks { blockIDs[i] = b.ID } require.Contains(t, blockIDs, blockID) }) t.Run("Delete a block", func(t *testing.T) { // this avoids triggering uniqueness constraint of // id,insert_at on block history time.Sleep(10 * time.Millisecond) _, resp := th.Client.DeleteBlock(board.ID, blockID, false) require.NoError(t, resp.Error) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Empty(t, blocks) }) } func TestUndeleteBlock(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := th.CreateBoard("team-id", model.BoardTypeOpen) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) initialCount := len(blocks) var blockID string t.Run("Create a block", func(t *testing.T) { initialID := utils.NewID(utils.IDTypeBoard) block := &model.Block{ ID: initialID, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeBoard, Title: "New title", } newBlocks, resp := th.Client.InsertBlocks(board.ID, []*model.Block{block}, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 1) require.NotZero(t, newBlocks[0].ID) require.NotEqual(t, initialID, newBlocks[0].ID) blockID = newBlocks[0].ID blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, initialCount+1) blockIDs := make([]string, len(blocks)) for i, b := range blocks { blockIDs[i] = b.ID } require.Contains(t, blockIDs, blockID) }) t.Run("Delete a block", func(t *testing.T) { // this avoids triggering uniqueness constraint of // id,insert_at on block history time.Sleep(10 * time.Millisecond) _, resp := th.Client.DeleteBlock(board.ID, blockID, false) require.NoError(t, resp.Error) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, initialCount) }) t.Run("Undelete a block", func(t *testing.T) { // this avoids triggering uniqueness constraint of // id,insert_at on block history time.Sleep(10 * time.Millisecond) _, resp := th.Client.UndeleteBlock(board.ID, blockID) require.NoError(t, resp.Error) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, initialCount+1) }) t.Run("Try to undelete a block without permissions", func(t *testing.T) { // this avoids triggering uniqueness constraint of // id,insert_at on block history time.Sleep(10 * time.Millisecond) _, resp := th.Client.DeleteBlock(board.ID, blockID, false) require.NoError(t, resp.Error) _, resp = th.Client2.UndeleteBlock(board.ID, blockID) th.CheckForbidden(resp) blocks, resp := th.Client.GetBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, blocks, initialCount) }) } ================================================ FILE: server/integrationtests/board_test.go ================================================ package integrationtests import ( "encoding/json" "sort" "testing" "time" "github.com/mattermost/focalboard/server/client" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func TestGetBoards(t *testing.T) { t.Run("a non authenticated client should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) teamID := "0" newBoard := &model.Board{ TeamID: teamID, Type: model.BoardTypeOpen, } board, err := th.Server.App().CreateBoard(newBoard, "user-id", false) require.NoError(t, err) require.NotNil(t, board) boards, resp := th.Client.GetBoardsForTeam(teamID) th.CheckUnauthorized(resp) require.Nil(t, boards) }) t.Run("should only return the boards that the user is a member of", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := "0" otherTeamID := "other-team-id" user1 := th.GetUser1() user2 := th.GetUser2() board1 := &model.Board{ TeamID: teamID, Type: model.BoardTypeOpen, Title: "Board 1", } rBoard1, err := th.Server.App().CreateBoard(board1, user1.ID, true) require.NoError(t, err) require.NotNil(t, rBoard1) board2 := &model.Board{ TeamID: teamID, Type: model.BoardTypeOpen, Title: "Board 2", } rBoard2, err := th.Server.App().CreateBoard(board2, user2.ID, false) require.NoError(t, err) require.NotNil(t, rBoard2) board3 := &model.Board{ TeamID: teamID, Type: model.BoardTypePrivate, Title: "Board 3", } rBoard3, err := th.Server.App().CreateBoard(board3, user1.ID, true) require.NoError(t, err) require.NotNil(t, rBoard3) board4 := &model.Board{ TeamID: teamID, Type: model.BoardTypePrivate, Title: "Board 4", } rBoard4, err := th.Server.App().CreateBoard(board4, user1.ID, false) require.NoError(t, err) require.NotNil(t, rBoard4) board5 := &model.Board{ TeamID: teamID, Type: model.BoardTypePrivate, Title: "Board 5", } rBoard5, err := th.Server.App().CreateBoard(board5, user2.ID, true) require.NoError(t, err) require.NotNil(t, rBoard5) board6 := &model.Board{ TeamID: otherTeamID, Type: model.BoardTypeOpen, } rBoard6, err := th.Server.App().CreateBoard(board6, user1.ID, true) require.NoError(t, err) require.NotNil(t, rBoard6) boards, resp := th.Client.GetBoardsForTeam(teamID) th.CheckOK(resp) require.NotNil(t, boards) require.ElementsMatch(t, []*model.Board{ rBoard1, rBoard2, rBoard3, }, boards) boardsFromOtherTeam, resp := th.Client.GetBoardsForTeam(otherTeamID) th.CheckOK(resp) require.NotNil(t, boardsFromOtherTeam) require.Len(t, boardsFromOtherTeam, 1) require.Equal(t, rBoard6.ID, boardsFromOtherTeam[0].ID) }) } func TestCreateBoard(t *testing.T) { t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) newBoard := &model.Board{ Title: "board title", Type: model.BoardTypeOpen, TeamID: testTeamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckUnauthorized(resp) require.Nil(t, board) }) t.Run("create public board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "board title 1" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypeOpen, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypeOpen, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) t.Run("creating a board should make the creator an admin", func(t *testing.T) { members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.Equal(t, me.ID, members[0].UserID) require.Equal(t, board.ID, members[0].BoardID) require.True(t, members[0].SchemeAdmin) }) t.Run("creator should be able to access the public board and its blocks", func(t *testing.T) { rbBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rbBoard) require.Equal(t, board, rbBoard) rBlocks, resp := th.Client.GetBlocksForBoard(board.ID) th.CheckOK(resp) require.NotNil(t, rBlocks) }) t.Run("A non-member user should be able to access the public board but not its blocks", func(t *testing.T) { rbBoard, resp := th.Client2.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rbBoard) require.Equal(t, board, rbBoard) rBlocks, resp := th.Client2.GetBlocksForBoard(board.ID) th.CheckForbidden(resp) require.Nil(t, rBlocks) }) }) t.Run("create private board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "private board title" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypePrivate, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypePrivate, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) t.Run("creating a board should make the creator an admin", func(t *testing.T) { members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.Equal(t, me.ID, members[0].UserID) require.Equal(t, board.ID, members[0].BoardID) require.True(t, members[0].SchemeAdmin) }) t.Run("creator should be able to access the private board and its blocks", func(t *testing.T) { rbBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rbBoard) require.Equal(t, board, rbBoard) rBlocks, resp := th.Client.GetBlocksForBoard(board.ID) th.CheckOK(resp) require.NotNil(t, rBlocks) }) t.Run("unauthorized user should not be able to access the private board or its blocks", func(t *testing.T) { rbBoard, resp := th.Client2.GetBoard(board.ID, "") th.CheckForbidden(resp) require.Nil(t, rbBoard) rBlocks, resp := th.Client2.GetBlocksForBoard(board.ID) th.CheckForbidden(resp) require.Nil(t, rBlocks) }) }) t.Run("create invalid board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() title := "invalid board title" teamID := testTeamID user1 := th.GetUser1() t.Run("invalid board type", func(t *testing.T) { var invalidBoardType model.BoardType = "invalid" newBoard := &model.Board{ Title: title, TeamID: testTeamID, Type: invalidBoardType, } board, resp := th.Client.CreateBoard(newBoard) th.CheckBadRequest(resp) require.Nil(t, board) boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID, true) require.NoError(t, err) require.Empty(t, boards) }) t.Run("no type", func(t *testing.T) { newBoard := &model.Board{ Title: title, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckBadRequest(resp) require.Nil(t, board) boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID, true) require.NoError(t, err) require.Empty(t, boards) }) t.Run("no team ID", func(t *testing.T) { newBoard := &model.Board{ Title: title, } board, resp := th.Client.CreateBoard(newBoard) // the request is unauthorized because the permission // check fails on an empty teamID th.CheckForbidden(resp) require.Nil(t, board) boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID, true) require.NoError(t, err) require.Empty(t, boards) }) }) } func TestCreateBoardTemplate(t *testing.T) { t.Run("create public board template", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "board template 1" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypeOpen, TeamID: teamID, IsTemplate: true, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypeOpen, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) t.Run("creating a board template should make the creator an admin", func(t *testing.T) { members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.Equal(t, me.ID, members[0].UserID) require.Equal(t, board.ID, members[0].BoardID) require.True(t, members[0].SchemeAdmin) }) t.Run("creator should be able to access the public board template and its blocks", func(t *testing.T) { rbBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rbBoard) require.Equal(t, board, rbBoard) rBlocks, resp := th.Client.GetBlocksForBoard(board.ID) th.CheckOK(resp) require.NotNil(t, rBlocks) }) t.Run("another user should be able to access the public board template and its blocks", func(t *testing.T) { rbBoard, resp := th.Client2.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rbBoard) require.Equal(t, board, rbBoard) rBlocks, resp := th.Client2.GetBlocksForBoard(board.ID) th.CheckOK(resp) require.NotNil(t, rBlocks) }) }) t.Run("create private board template", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "private board template title" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypePrivate, TeamID: teamID, IsTemplate: true, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypePrivate, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) t.Run("creating a board template should make the creator an admin", func(t *testing.T) { members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.Equal(t, me.ID, members[0].UserID) require.Equal(t, board.ID, members[0].BoardID) require.True(t, members[0].SchemeAdmin) }) t.Run("creator should be able to access the private board template and its blocks", func(t *testing.T) { rbBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rbBoard) require.Equal(t, board, rbBoard) rBlocks, resp := th.Client.GetBlocksForBoard(board.ID) th.CheckOK(resp) require.NotNil(t, rBlocks) }) t.Run("unauthorized user should not be able to access the private board template or its blocks", func(t *testing.T) { rbBoard, resp := th.Client2.GetBoard(board.ID, "") th.CheckForbidden(resp) require.Nil(t, rbBoard) rBlocks, resp := th.Client2.GetBlocksForBoard(board.ID) th.CheckForbidden(resp) require.Nil(t, rBlocks) }) }) } func TestGetAllBlocksForBoard(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() board := th.CreateBoard("board-id", model.BoardTypeOpen) parentBlockID := utils.NewID(utils.IDTypeBlock) childBlockID1 := utils.NewID(utils.IDTypeBlock) childBlockID2 := utils.NewID(utils.IDTypeBlock) t.Run("Create the block structure", func(t *testing.T) { newBlocks := []*model.Block{ { ID: parentBlockID, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, }, { ID: childBlockID1, BoardID: board.ID, ParentID: parentBlockID, CreateAt: 2, UpdateAt: 2, Type: model.TypeCard, }, { ID: childBlockID2, BoardID: board.ID, ParentID: parentBlockID, CreateAt: 2, UpdateAt: 2, Type: model.TypeCard, }, } insertedBlocks, resp := th.Client.InsertBlocks(board.ID, newBlocks, false) require.NoError(t, resp.Error) require.Len(t, insertedBlocks, len(newBlocks)) insertedBlockIDs := make([]string, len(insertedBlocks)) for i, b := range insertedBlocks { insertedBlockIDs[i] = b.ID } fetchedBlocks, resp := th.Client.GetAllBlocksForBoard(board.ID) require.NoError(t, resp.Error) require.Len(t, fetchedBlocks, len(newBlocks)) fetchedblockIDs := make([]string, len(fetchedBlocks)) for i, b := range fetchedBlocks { fetchedblockIDs[i] = b.ID } sort.Strings(insertedBlockIDs) sort.Strings(fetchedblockIDs) require.Equal(t, insertedBlockIDs, fetchedblockIDs) }) } func TestSearchBoards(t *testing.T) { t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) boards, resp := th.Client.SearchBoardsForTeam(testTeamID, "term") th.CheckUnauthorized(resp) require.Nil(t, boards) }) t.Run("all the matching private boards that the user is a member of and all matching public boards should be returned", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := testTeamID user1 := th.GetUser1() board1 := &model.Board{ Title: "public board where user1 is admin", Type: model.BoardTypeOpen, TeamID: teamID, } rBoard1, err := th.Server.App().CreateBoard(board1, user1.ID, true) require.NoError(t, err) board2 := &model.Board{ Title: "public board where user1 is not member", Type: model.BoardTypeOpen, TeamID: teamID, } rBoard2, err := th.Server.App().CreateBoard(board2, user1.ID, false) require.NoError(t, err) board3 := &model.Board{ Title: "private board where user1 is admin", Type: model.BoardTypePrivate, TeamID: teamID, } rBoard3, err := th.Server.App().CreateBoard(board3, user1.ID, true) require.NoError(t, err) board4 := &model.Board{ Title: "private board where user1 is not member", Type: model.BoardTypePrivate, TeamID: teamID, } _, err = th.Server.App().CreateBoard(board4, user1.ID, false) require.NoError(t, err) board5 := &model.Board{ Title: "private board where user1 is admin, but in other team", Type: model.BoardTypePrivate, TeamID: "other-team-id", } rBoard5, err := th.Server.App().CreateBoard(board5, user1.ID, true) require.NoError(t, err) testCases := []struct { Name string Client *client.Client Term string ExpectedIDs []string }{ { Name: "should return all boards where user1 is member or that are public", Client: th.Client, Term: "board", ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID, rBoard5.ID}, }, { Name: "matching a full word", Client: th.Client, Term: "admin", ExpectedIDs: []string{rBoard1.ID, rBoard3.ID, rBoard5.ID}, }, { Name: "matching part of the word", Client: th.Client, Term: "ubli", ExpectedIDs: []string{rBoard1.ID, rBoard2.ID}, }, { Name: "case insensitive", Client: th.Client, Term: "UBLI", ExpectedIDs: []string{rBoard1.ID, rBoard2.ID}, }, { Name: "user2 can only see the public boards, as he's not a member of any", Client: th.Client2, Term: "board", ExpectedIDs: []string{rBoard1.ID, rBoard2.ID}, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { boards, resp := tc.Client.SearchBoardsForTeam(teamID, tc.Term) th.CheckOK(resp) boardIDs := []string{} for _, board := range boards { boardIDs = append(boardIDs, board.ID) } require.ElementsMatch(t, tc.ExpectedIDs, boardIDs) }) } }) } func TestGetBoard(t *testing.T) { t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) board, resp := th.Client.GetBoard("boar-id", "") th.CheckUnauthorized(resp) require.Nil(t, board) }) t.Run("valid read token should be enough to get the board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Server.Config().EnablePublicSharedBoards = true teamID := testTeamID sharingToken := utils.NewID(utils.IDTypeToken) board := &model.Board{ Title: "public board where user1 is admin", Type: model.BoardTypeOpen, TeamID: teamID, } rBoard, err := th.Server.App().CreateBoard(board, th.GetUser1().ID, true) require.NoError(t, err) sharing := &model.Sharing{ ID: rBoard.ID, Enabled: true, Token: sharingToken, UpdateAt: 1, } success, resp := th.Client.PostSharing(sharing) th.CheckOK(resp) require.True(t, success) // the client logs out th.Logout(th.Client) // we make sure that the client cannot currently retrieve the // board with no session board, resp = th.Client.GetBoard(rBoard.ID, "") th.CheckUnauthorized(resp) require.Nil(t, board) // it should be able to retrieve it with the read token board, resp = th.Client.GetBoard(rBoard.ID, sharingToken) th.CheckOK(resp) require.NotNil(t, board) }) t.Run("nonexisting board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board, resp := th.Client.GetBoard("nonexistent board", "") th.CheckNotFound(resp) require.Nil(t, board) }) t.Run("a user that doesn't have permissions to a private board cannot retrieve it", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := testTeamID newBoard := &model.Board{ Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, false) require.NoError(t, err) rBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckForbidden(resp) require.Nil(t, rBoard) }) t.Run("a user that has permissions to a private board can retrieve it", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := testTeamID newBoard := &model.Board{ Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) rBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rBoard) }) t.Run("a user that doesn't have permissions to a public board but have them to its team can retrieve it", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := testTeamID newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, false) require.NoError(t, err) rBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rBoard) }) } func TestGetBoardMetadata(t *testing.T) { t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelperWithLicense(t, LicenseEnterprise).InitBasic() defer th.TearDown() th.Logout(th.Client) boardMetadata, resp := th.Client.GetBoardMetadata("boar-id", "") th.CheckUnauthorized(resp) require.Nil(t, boardMetadata) }) t.Run("getBoardMetadata query is correct", func(t *testing.T) { th := SetupTestHelperWithLicense(t, LicenseEnterprise).InitBasic() defer th.TearDown() th.Server.Config().EnablePublicSharedBoards = true teamID := testTeamID board := &model.Board{ Title: "public board where user1 is admin", Type: model.BoardTypeOpen, TeamID: teamID, } rBoard, err := th.Server.App().CreateBoard(board, th.GetUser1().ID, true) require.NoError(t, err) // Check metadata boardMetadata, resp := th.Client.GetBoardMetadata(rBoard.ID, "") th.CheckOK(resp) require.NotNil(t, boardMetadata) require.Equal(t, rBoard.CreatedBy, boardMetadata.CreatedBy) require.Equal(t, rBoard.CreateAt, boardMetadata.DescendantFirstUpdateAt) require.Equal(t, rBoard.UpdateAt, boardMetadata.DescendantLastUpdateAt) require.Equal(t, rBoard.ModifiedBy, boardMetadata.LastModifiedBy) // Insert card1 card1 := &model.Block{ ID: "card1", BoardID: rBoard.ID, Title: "Card 1", } time.Sleep(20 * time.Millisecond) require.NoError(t, th.Server.App().InsertBlock(card1, th.GetUser2().ID)) rCard1, err := th.Server.App().GetBlockByID(card1.ID) require.NoError(t, err) // Check updated metadata boardMetadata, resp = th.Client.GetBoardMetadata(rBoard.ID, "") th.CheckOK(resp) require.NotNil(t, boardMetadata) require.Equal(t, rBoard.CreatedBy, boardMetadata.CreatedBy) require.Equal(t, rBoard.CreateAt, boardMetadata.DescendantFirstUpdateAt) require.Equal(t, rCard1.UpdateAt, boardMetadata.DescendantLastUpdateAt) require.Equal(t, rCard1.ModifiedBy, boardMetadata.LastModifiedBy) // Insert card2 card2 := &model.Block{ ID: "card2", BoardID: rBoard.ID, Title: "Card 2", } time.Sleep(20 * time.Millisecond) require.NoError(t, th.Server.App().InsertBlock(card2, th.GetUser1().ID)) rCard2, err := th.Server.App().GetBlockByID(card2.ID) require.NoError(t, err) // Check updated metadata boardMetadata, resp = th.Client.GetBoardMetadata(rBoard.ID, "") th.CheckOK(resp) require.NotNil(t, boardMetadata) require.Equal(t, rBoard.CreatedBy, boardMetadata.CreatedBy) require.Equal(t, rBoard.CreateAt, boardMetadata.DescendantFirstUpdateAt) require.Equal(t, rCard2.UpdateAt, boardMetadata.DescendantLastUpdateAt) require.Equal(t, rCard2.ModifiedBy, boardMetadata.LastModifiedBy) t.Run("After delete board", func(t *testing.T) { // Delete board time.Sleep(20 * time.Millisecond) require.NoError(t, th.Server.App().DeleteBoard(rBoard.ID, th.GetUser1().ID)) // Check updated metadata boardMetadata, resp = th.Client.GetBoardMetadata(rBoard.ID, "") th.CheckOK(resp) require.NotNil(t, boardMetadata) require.Equal(t, rBoard.CreatedBy, boardMetadata.CreatedBy) require.Equal(t, rBoard.CreateAt, boardMetadata.DescendantFirstUpdateAt) require.Greater(t, boardMetadata.DescendantLastUpdateAt, rCard2.UpdateAt) require.Equal(t, th.GetUser1().ID, boardMetadata.LastModifiedBy) }) }) t.Run("getBoardMetadata should fail with no license", func(t *testing.T) { th := SetupTestHelperWithLicense(t, LicenseNone).InitBasic() defer th.TearDown() th.Server.Config().EnablePublicSharedBoards = true teamID := testTeamID board := &model.Board{ Title: "public board where user1 is admin", Type: model.BoardTypeOpen, TeamID: teamID, } rBoard, err := th.Server.App().CreateBoard(board, th.GetUser1().ID, true) require.NoError(t, err) // Check metadata boardMetadata, resp := th.Client.GetBoardMetadata(rBoard.ID, "") th.CheckNotImplemented(resp) require.Nil(t, boardMetadata) }) t.Run("getBoardMetadata should fail on Professional license", func(t *testing.T) { th := SetupTestHelperWithLicense(t, LicenseProfessional).InitBasic() defer th.TearDown() th.Server.Config().EnablePublicSharedBoards = true teamID := testTeamID board := &model.Board{ Title: "public board where user1 is admin", Type: model.BoardTypeOpen, TeamID: teamID, } rBoard, err := th.Server.App().CreateBoard(board, th.GetUser1().ID, true) require.NoError(t, err) // Check metadata boardMetadata, resp := th.Client.GetBoardMetadata(rBoard.ID, "") th.CheckNotImplemented(resp) require.Nil(t, boardMetadata) }) t.Run("valid read token should not get the board metadata", func(t *testing.T) { th := SetupTestHelperWithLicense(t, LicenseEnterprise).InitBasic() defer th.TearDown() th.Server.Config().EnablePublicSharedBoards = true teamID := testTeamID sharingToken := utils.NewID(utils.IDTypeToken) userID := th.GetUser1().ID board := &model.Board{ Title: "public board where user1 is admin", Type: model.BoardTypeOpen, TeamID: teamID, } rBoard, err := th.Server.App().CreateBoard(board, userID, true) require.NoError(t, err) sharing := &model.Sharing{ ID: rBoard.ID, Enabled: true, Token: sharingToken, UpdateAt: 1, } success, resp := th.Client.PostSharing(sharing) th.CheckOK(resp) require.True(t, success) // the client logs out th.Logout(th.Client) // we make sure that the client cannot currently retrieve the // board with no session boardMetadata, resp := th.Client.GetBoardMetadata(rBoard.ID, "") th.CheckUnauthorized(resp) require.Nil(t, boardMetadata) // it should not be able to retrieve it with the read token either boardMetadata, resp = th.Client.GetBoardMetadata(rBoard.ID, sharingToken) th.CheckUnauthorized(resp) require.Nil(t, boardMetadata) }) } func TestPatchBoard(t *testing.T) { teamID := testTeamID t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) initialTitle := "title 1" newBoard := &model.Board{ Title: initialTitle, Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "user-id", false) require.NoError(t, err) newTitle := "a new title 1" patch := &model.BoardPatch{Title: &newTitle} rBoard, resp := th.Client.PatchBoard(board.ID, patch) th.CheckUnauthorized(resp) require.Nil(t, rBoard) dbBoard, err := th.Server.App().GetBoard(board.ID) require.NoError(t, err) require.Equal(t, initialTitle, dbBoard.Title) }) t.Run("non existing board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newTitle := "a new title 2" patch := &model.BoardPatch{Title: &newTitle} board, resp := th.Client.PatchBoard("non-existing-board", patch) th.CheckNotFound(resp) require.Nil(t, board) }) t.Run("invalid patch on a board with permissions", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() user1 := th.GetUser1() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, user1.ID, true) require.NoError(t, err) var invalidPatchType model.BoardType = "invalid" patch := &model.BoardPatch{Type: &invalidPatchType} rBoard, resp := th.Client.PatchBoard(board.ID, patch) th.CheckBadRequest(resp) require.Nil(t, rBoard) }) t.Run("valid patch on a board with permissions", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() user1 := th.GetUser1() initialTitle := "title" newBoard := &model.Board{ Title: initialTitle, Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, user1.ID, true) require.NoError(t, err) newTitle := "a new title" patch := &model.BoardPatch{Title: &newTitle} rBoard, resp := th.Client.PatchBoard(board.ID, patch) th.CheckOK(resp) require.NotNil(t, rBoard) require.Equal(t, newTitle, rBoard.Title) }) t.Run("valid patch on a board without permissions", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() user1 := th.GetUser1() initialTitle := "title" newBoard := &model.Board{ Title: initialTitle, Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, user1.ID, false) require.NoError(t, err) newTitle := "a new title" patch := &model.BoardPatch{Title: &newTitle} rBoard, resp := th.Client.PatchBoard(board.ID, patch) th.CheckForbidden(resp) require.Nil(t, rBoard) dbBoard, err := th.Server.App().GetBoard(board.ID) require.NoError(t, err) require.Equal(t, initialTitle, dbBoard.Title) }) } func TestDeleteBoard(t *testing.T) { teamID := testTeamID t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "user-id", false) require.NoError(t, err) success, resp := th.Client.DeleteBoard(board.ID) th.CheckUnauthorized(resp) require.False(t, success) dbBoard, err := th.Server.App().GetBoard(board.ID) require.NoError(t, err) require.NotNil(t, dbBoard) }) t.Run("a user without permissions should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "some-user-id", false) require.NoError(t, err) success, resp := th.Client.DeleteBoard(board.ID) th.CheckForbidden(resp) require.False(t, success) dbBoard, err := th.Server.App().GetBoard(board.ID) require.NoError(t, err) require.NotNil(t, dbBoard) }) t.Run("non existing board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() success, resp := th.Client.DeleteBoard("non-existing-board") th.CheckNotFound(resp) require.False(t, success) }) t.Run("an existing board should be correctly deleted", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) success, resp := th.Client.DeleteBoard(board.ID) th.CheckOK(resp) require.True(t, success) dbBoard, err := th.Server.App().GetBoard(board.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, dbBoard) }) } func TestUndeleteBoard(t *testing.T) { teamID := testTeamID t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "user-id", false) require.NoError(t, err) time.Sleep(1 * time.Millisecond) err = th.Server.App().DeleteBoard(newBoard.ID, "user-id") require.NoError(t, err) success, resp := th.Client.UndeleteBoard(board.ID) th.CheckUnauthorized(resp) require.False(t, success) dbBoard, err := th.Server.App().GetBoard(board.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, dbBoard) }) t.Run("a user without membership should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "some-user-id", false) require.NoError(t, err) time.Sleep(1 * time.Millisecond) err = th.Server.App().DeleteBoard(newBoard.ID, "some-user-id") require.NoError(t, err) success, resp := th.Client.UndeleteBoard(board.ID) th.CheckForbidden(resp) require.False(t, success) dbBoard, err := th.Server.App().GetBoard(board.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, dbBoard) }) t.Run("a user with membership but without permissions should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "some-user-id", false) require.NoError(t, err) newUser2Member := &model.BoardMember{ UserID: "user-id", BoardID: board.ID, SchemeEditor: true, } _, err = th.Server.App().AddMemberToBoard(newUser2Member) require.NoError(t, err) time.Sleep(1 * time.Millisecond) err = th.Server.App().DeleteBoard(newBoard.ID, "some-user-id") require.NoError(t, err) success, resp := th.Client.UndeleteBoard(board.ID) th.CheckForbidden(resp) require.False(t, success) dbBoard, err := th.Server.App().GetBoard(board.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, dbBoard) }) t.Run("non existing board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() success, resp := th.Client.UndeleteBoard("non-existing-board") th.CheckForbidden(resp) require.False(t, success) }) t.Run("an existing deleted board should be correctly undeleted", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) time.Sleep(1 * time.Millisecond) err = th.Server.App().DeleteBoard(newBoard.ID, "user-id") require.NoError(t, err) success, resp := th.Client.UndeleteBoard(board.ID) th.CheckOK(resp) require.True(t, success) dbBoard, err := th.Server.App().GetBoard(board.ID) require.NoError(t, err) require.NotNil(t, dbBoard) }) } func TestGetMembersForBoard(t *testing.T) { teamID := testTeamID createBoardWithUsers := func(th *TestHelper) *model.Board { user1 := th.GetUser1() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, user1.ID, true) require.NoError(t, err) newUser2Member := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } user2Member, err := th.Server.App().AddMemberToBoard(newUser2Member) require.NoError(t, err) require.NotNil(t, user2Member) return board } t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := createBoardWithUsers(th) th.Logout(th.Client) members, resp := th.Client.GetMembersForBoard(board.ID) th.CheckUnauthorized(resp) require.Empty(t, members) }) t.Run("a user without permissions should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := createBoardWithUsers(th) _ = th.Server.App().DeleteBoardMember(board.ID, th.GetUser2().ID) members, resp := th.Client2.GetMembersForBoard(board.ID) th.CheckForbidden(resp) require.Empty(t, members) }) t.Run("non existing board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() members, resp := th.Client.GetMembersForBoard("non-existing-board") th.CheckForbidden(resp) require.Empty(t, members) }) t.Run("should correctly return board members for a valid board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := createBoardWithUsers(th) members, resp := th.Client.GetMembersForBoard(board.ID) th.CheckOK(resp) require.Len(t, members, 2) }) } func TestAddMember(t *testing.T) { teamID := testTeamID t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "user-id", false) require.NoError(t, err) newMember := &model.BoardMember{ UserID: "user1", BoardID: board.ID, SchemeEditor: true, } member, resp := th.Client.AddMemberToBoard(newMember) th.CheckUnauthorized(resp) require.Nil(t, member) }) t.Run("a user without permissions should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, "user-id", false) require.NoError(t, err) newMember := &model.BoardMember{ UserID: "user1", BoardID: board.ID, SchemeEditor: true, } member, resp := th.Client.AddMemberToBoard(newMember) th.CheckForbidden(resp) require.Nil(t, member) }) t.Run("non existing board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newMember := &model.BoardMember{ UserID: "user1", BoardID: "non-existing-board-id", SchemeEditor: true, } member, resp := th.Client.AddMemberToBoard(newMember) th.CheckNotFound(resp) require.Nil(t, member) }) t.Run("should correctly add a new member for a valid board", func(t *testing.T) { t.Run("a private board through an admin user", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newMember := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } member, resp := th.Client.AddMemberToBoard(newMember) th.CheckOK(resp) require.Equal(t, newMember.UserID, member.UserID) require.Equal(t, newMember.BoardID, member.BoardID) require.Equal(t, newMember.SchemeAdmin, member.SchemeAdmin) require.Equal(t, newMember.SchemeEditor, member.SchemeEditor) require.False(t, member.SchemeCommenter) require.False(t, member.SchemeViewer) }) t.Run("a public board through a user that is not yet a member", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newMember := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } member, resp := th.Client2.AddMemberToBoard(newMember) th.CheckForbidden(resp) require.Nil(t, member) members, resp := th.Client2.GetMembersForBoard(board.ID) th.CheckForbidden(resp) require.Nil(t, members) // Join board - will become an editor member, resp = th.Client2.JoinBoard(board.ID) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, member) require.Equal(t, board.ID, member.BoardID) require.Equal(t, th.GetUser2().ID, member.UserID) member, resp = th.Client2.AddMemberToBoard(newMember) th.CheckOK(resp) require.NotNil(t, member) members, resp = th.Client2.GetMembersForBoard(board.ID) th.CheckOK(resp) require.Len(t, members, 2) }) t.Run("should always add a new member as given board role", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newMember := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeAdmin: false, SchemeEditor: false, SchemeCommenter: true, } member, resp := th.Client.AddMemberToBoard(newMember) th.CheckOK(resp) require.Equal(t, newMember.UserID, member.UserID) require.Equal(t, newMember.BoardID, member.BoardID) require.False(t, member.SchemeAdmin) require.False(t, member.SchemeEditor) require.True(t, member.SchemeCommenter) }) }) t.Run("should do nothing if the member already exists", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newMember := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, SchemeAdmin: false, SchemeEditor: true, } members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.True(t, members[0].SchemeAdmin) require.True(t, members[0].SchemeEditor) member, resp := th.Client.AddMemberToBoard(newMember) th.CheckOK(resp) require.True(t, member.SchemeAdmin) require.True(t, member.SchemeEditor) members, err = th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.True(t, members[0].SchemeAdmin) require.True(t, members[0].SchemeEditor) }) } func TestUpdateMember(t *testing.T) { teamID := testTeamID t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) updatedMember := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, SchemeEditor: true, } th.Logout(th.Client) member, resp := th.Client.UpdateBoardMember(updatedMember) th.CheckUnauthorized(resp) require.Nil(t, member) }) t.Run("a user without permissions should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) updatedMember := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, SchemeEditor: true, } member, resp := th.Client2.UpdateBoardMember(updatedMember) th.CheckForbidden(resp) require.Nil(t, member) }) t.Run("non existing board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() updatedMember := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: "non-existent-board-id", SchemeEditor: true, } member, resp := th.Client.UpdateBoardMember(updatedMember) th.CheckForbidden(resp) require.Nil(t, member) }) t.Run("should correctly update a member for a valid board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newUser2Member := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } user2Member, err := th.Server.App().AddMemberToBoard(newUser2Member) require.NoError(t, err) require.NotNil(t, user2Member) require.False(t, user2Member.SchemeAdmin) require.True(t, user2Member.SchemeEditor) memberUpdate := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeAdmin: true, SchemeEditor: true, } updatedUser2Member, resp := th.Client.UpdateBoardMember(memberUpdate) th.CheckOK(resp) require.True(t, updatedUser2Member.SchemeAdmin) require.True(t, updatedUser2Member.SchemeEditor) }) t.Run("should not update a member if that means that a board will not have any admin", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) memberUpdate := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, SchemeEditor: true, } updatedUser1Member, resp := th.Client.UpdateBoardMember(memberUpdate) th.CheckBadRequest(resp) require.Nil(t, updatedUser1Member) members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.True(t, members[0].SchemeAdmin) }) t.Run("should always disable the admin role on update member if the user is a guest", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, userAdmin, true) require.NoError(t, err) newGuestMember := &model.BoardMember{ UserID: userGuest, BoardID: board.ID, SchemeViewer: true, SchemeCommenter: true, SchemeEditor: true, SchemeAdmin: false, } guestMember, err := th.Server.App().AddMemberToBoard(newGuestMember) require.NoError(t, err) require.NotNil(t, guestMember) require.True(t, guestMember.SchemeViewer) require.True(t, guestMember.SchemeCommenter) require.True(t, guestMember.SchemeEditor) require.False(t, guestMember.SchemeAdmin) memberUpdate := &model.BoardMember{ UserID: userGuest, BoardID: board.ID, SchemeAdmin: true, SchemeViewer: true, SchemeCommenter: true, SchemeEditor: true, } updatedGuestMember, resp := clients.Admin.UpdateBoardMember(memberUpdate) th.CheckOK(resp) require.True(t, updatedGuestMember.SchemeViewer) require.True(t, updatedGuestMember.SchemeCommenter) require.True(t, updatedGuestMember.SchemeEditor) require.False(t, updatedGuestMember.SchemeAdmin) }) } func TestDeleteMember(t *testing.T) { teamID := testTeamID t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) member := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, } th.Logout(th.Client) success, resp := th.Client.DeleteBoardMember(member) th.CheckUnauthorized(resp) require.False(t, success) }) t.Run("a user without permissions should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) member := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, } success, resp := th.Client2.DeleteBoardMember(member) th.CheckForbidden(resp) require.False(t, success) }) t.Run("non existing board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() updatedMember := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: "non-existent-board-id", } success, resp := th.Client.DeleteBoardMember(updatedMember) th.CheckNotFound(resp) require.False(t, success) }) t.Run("should correctly delete a member for a valid board", func(t *testing.T) { //nolint:dupl t.Run("admin removing a user", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newUser2Member := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } user2Member, err := th.Server.App().AddMemberToBoard(newUser2Member) require.NoError(t, err) require.NotNil(t, user2Member) require.False(t, user2Member.SchemeAdmin) require.True(t, user2Member.SchemeEditor) memberToDelete := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, } members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 2) success, resp := th.Client.DeleteBoardMember(memberToDelete) th.CheckOK(resp) require.True(t, success) members, err = th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) }) //nolint:dupl t.Run("user removing themselves", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newUser2Member := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } user2Member, err := th.Server.App().AddMemberToBoard(newUser2Member) require.NoError(t, err) require.NotNil(t, user2Member) require.False(t, user2Member.SchemeAdmin) require.True(t, user2Member.SchemeEditor) memberToDelete := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, } members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 2) // Should fail - must call leave to leave a board success, resp := th.Client2.DeleteBoardMember(memberToDelete) th.CheckForbidden(resp) require.False(t, success) members, err = th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 2) }) //nolint:dupl t.Run("a non admin user should not be able to remove another user", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) newUser2Member := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } user2Member, err := th.Server.App().AddMemberToBoard(newUser2Member) require.NoError(t, err) require.NotNil(t, user2Member) require.False(t, user2Member.SchemeAdmin) require.True(t, user2Member.SchemeEditor) memberToDelete := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, } members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 2) success, resp := th.Client2.DeleteBoardMember(memberToDelete) th.CheckForbidden(resp) require.False(t, success) members, err = th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 2) }) }) t.Run("should not delete a member if that means that a board will not have any admin", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBoard := &model.Board{ Title: "title", Type: model.BoardTypePrivate, TeamID: teamID, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) memberToDelete := &model.BoardMember{ UserID: th.GetUser1().ID, BoardID: board.ID, } success, resp := th.Client.DeleteBoardMember(memberToDelete) th.CheckBadRequest(resp) require.False(t, success) members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 1) require.True(t, members[0].SchemeAdmin) }) } func TestGetTemplates(t *testing.T) { t.Run("should be able to retrieve built-in templates", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() err := th.Server.App().InitTemplates() require.NoError(t, err, "InitTemplates should not fail") teamID := "my-team-id" rBoards, resp := th.Client.GetTemplatesForTeam("0") th.CheckOK(resp) require.NotNil(t, rBoards) require.GreaterOrEqual(t, len(rBoards), 6) t.Log("\n\n") for _, board := range rBoards { t.Logf("Test get template: %s - %s\n", board.Title, board.ID) rBoard, resp := th.Client.GetBoard(board.ID, "") th.CheckOK(resp) require.NotNil(t, rBoard) require.Equal(t, board, rBoard) rBlocks, resp := th.Client.GetAllBlocksForBoard(board.ID) th.CheckOK(resp) require.NotNil(t, rBlocks) require.Greater(t, len(rBlocks), 0) t.Logf("Got %d block(s)\n", len(rBlocks)) rBoardsAndBlock, resp := th.Client.DuplicateBoard(board.ID, false, teamID) th.CheckOK(resp) require.NotNil(t, rBoardsAndBlock) require.Greater(t, len(rBoardsAndBlock.Boards), 0) require.Greater(t, len(rBoardsAndBlock.Blocks), 0) rBoard2 := rBoardsAndBlock.Boards[0] require.Contains(t, board.Title, rBoard2.Title) require.False(t, rBoard2.IsTemplate) t.Logf("Duplicate template: %s - %s, %d block(s)\n", rBoard2.Title, rBoard2.ID, len(rBoardsAndBlock.Blocks)) rBoard3, resp := th.Client.GetBoard(rBoard2.ID, "") th.CheckOK(resp) require.NotNil(t, rBoard3) require.Equal(t, rBoard2, rBoard3) rBlocks2, resp := th.Client.GetAllBlocksForBoard(rBoard2.ID) th.CheckOK(resp) require.NotNil(t, rBlocks2) require.Equal(t, len(rBoardsAndBlock.Blocks), len(rBlocks2)) } t.Log("\n\n") }) } func TestDuplicateBoard(t *testing.T) { t.Run("create and duplicate public board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "Public board" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypeOpen, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypeOpen, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) newBlocks := []*model.Block{ { ID: utils.NewID(utils.IDTypeBlock), BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Title: "View 1", Type: model.TypeView, }, } newBlocks, resp = th.Client.InsertBlocks(board.ID, newBlocks, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 1) newUserMember := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } th.Client.AddMemberToBoard(newUserMember) members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 2) // Duplicate the board rBoardsAndBlock, resp := th.Client.DuplicateBoard(board.ID, false, teamID) th.CheckOK(resp) require.NotNil(t, rBoardsAndBlock) require.Equal(t, len(rBoardsAndBlock.Boards), 1) require.Equal(t, len(rBoardsAndBlock.Blocks), 1) duplicateBoard := rBoardsAndBlock.Boards[0] require.Equal(t, duplicateBoard.Type, model.BoardTypePrivate, "Duplicated board should be private") members, err = th.Server.App().GetMembersForBoard(duplicateBoard.ID) require.NoError(t, err) require.Len(t, members, 1, "Duplicated board should only have one member") require.Equal(t, me.ID, members[0].UserID) require.Equal(t, duplicateBoard.ID, members[0].BoardID) require.True(t, members[0].SchemeAdmin) }) t.Run("create and duplicate public board from a custom category", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() teamID := testTeamID category := model.Category{ Name: "My Category", UserID: me.ID, TeamID: teamID, } createdCategory, resp := th.Client.CreateCategory(category) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, createdCategory) require.Equal(t, "My Category", createdCategory.Name) require.Equal(t, me.ID, createdCategory.UserID) require.Equal(t, teamID, createdCategory.TeamID) title := "Public board" newBoard := &model.Board{ Title: title, Type: model.BoardTypeOpen, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypeOpen, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) // move board to custom category resp = th.Client.UpdateCategoryBoard(teamID, createdCategory.ID, board.ID) th.CheckOK(resp) require.NoError(t, resp.Error) newBlocks := []*model.Block{ { ID: utils.NewID(utils.IDTypeBlock), BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Title: "View 1", Type: model.TypeView, }, } newBlocks, resp = th.Client.InsertBlocks(board.ID, newBlocks, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 1) newUserMember := &model.BoardMember{ UserID: th.GetUser2().ID, BoardID: board.ID, SchemeEditor: true, } th.Client.AddMemberToBoard(newUserMember) members, err := th.Server.App().GetMembersForBoard(board.ID) require.NoError(t, err) require.Len(t, members, 2) // Duplicate the board rBoardsAndBlock, resp := th.Client.DuplicateBoard(board.ID, false, teamID) th.CheckOK(resp) require.NotNil(t, rBoardsAndBlock) require.Equal(t, len(rBoardsAndBlock.Boards), 1) require.Equal(t, len(rBoardsAndBlock.Blocks), 1) duplicateBoard := rBoardsAndBlock.Boards[0] require.Equal(t, duplicateBoard.Type, model.BoardTypePrivate, "Duplicated board should be private") require.Equal(t, "Public board copy", duplicateBoard.Title) members, err = th.Server.App().GetMembersForBoard(duplicateBoard.ID) require.NoError(t, err) require.Len(t, members, 1, "Duplicated board should only have one member") require.Equal(t, me.ID, members[0].UserID) require.Equal(t, duplicateBoard.ID, members[0].BoardID) require.True(t, members[0].SchemeAdmin) // verify duplicated board is in the same custom category userCategoryBoards, resp := th.Client.GetUserCategoryBoards(teamID) th.CheckOK(resp) require.NotNil(t, rBoardsAndBlock) var duplicateBoardCategoryID string for _, categoryBoard := range userCategoryBoards { for _, boardMetadata := range categoryBoard.BoardMetadata { if boardMetadata.BoardID == duplicateBoard.ID { duplicateBoardCategoryID = categoryBoard.Category.ID } } } require.Equal(t, createdCategory.ID, duplicateBoardCategoryID) }) } func TestJoinBoard(t *testing.T) { t.Run("create and join public board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "Test Public board" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypeOpen, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypeOpen, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) require.Equal(t, model.BoardRoleNone, board.MinimumRole) member, resp := th.Client2.JoinBoard(board.ID) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, member) require.Equal(t, board.ID, member.BoardID) require.Equal(t, th.GetUser2().ID, member.UserID) s, _ := json.MarshalIndent(member, "", "\t") t.Log(string(s)) }) t.Run("create and join public board should match the minimumRole in the membership", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "Public board for commenters" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypeOpen, TeamID: teamID, MinimumRole: model.BoardRoleCommenter, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypeOpen, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) member, resp := th.Client2.JoinBoard(board.ID) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, member) require.Equal(t, board.ID, member.BoardID) require.Equal(t, th.GetUser2().ID, member.UserID) require.False(t, member.SchemeAdmin, "new member should not be admin") require.False(t, member.SchemeEditor, "new member should not be editor") require.True(t, member.SchemeCommenter, "new member should be commenter") require.False(t, member.SchemeViewer, "new member should not be viewer") s, _ := json.MarshalIndent(member, "", "\t") t.Log(string(s)) }) t.Run("create and join public board should match editor role in the membership when MinimumRole is empty", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "Public board for editors" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypeOpen, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypeOpen, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) member, resp := th.Client2.JoinBoard(board.ID) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, member) require.Equal(t, board.ID, member.BoardID) require.Equal(t, th.GetUser2().ID, member.UserID) require.False(t, member.SchemeAdmin, "new member should not be admin") require.True(t, member.SchemeEditor, "new member should be editor") require.False(t, member.SchemeCommenter, "new member should not be commenter") require.False(t, member.SchemeViewer, "new member should not be viewer") s, _ := json.MarshalIndent(member, "", "\t") t.Log(string(s)) }) t.Run("create and join private board (should not succeed)", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() me := th.GetUser1() title := "Private board" teamID := testTeamID newBoard := &model.Board{ Title: title, Type: model.BoardTypePrivate, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, board) require.NotNil(t, board.ID) require.Equal(t, title, board.Title) require.Equal(t, model.BoardTypePrivate, board.Type) require.Equal(t, teamID, board.TeamID) require.Equal(t, me.ID, board.CreatedBy) require.Equal(t, me.ID, board.ModifiedBy) member, resp := th.Client2.JoinBoard(board.ID) th.CheckForbidden(resp) require.Nil(t, member) }) t.Run("join invalid board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() member, resp := th.Client2.JoinBoard("nonexistent-board-ID") th.CheckNotFound(resp) require.Nil(t, member) }) } ================================================ FILE: server/integrationtests/boards_and_blocks_test.go ================================================ package integrationtests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/require" ) func TestCreateBoardsAndBlocks(t *testing.T) { teamID := testTeamID t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{}, Blocks: []*model.Block{}, } bab, resp := th.Client.CreateBoardsAndBlocks(newBab) th.CheckUnauthorized(resp) require.Nil(t, bab) }) t.Run("invalid boards and blocks", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() t.Run("no boards", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{}, Blocks: []*model.Block{ {ID: "block-id", BoardID: "board-id", Type: model.TypeCard}, }, } bab, resp := th.Client.CreateBoardsAndBlocks(newBab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("no blocks", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id", TeamID: teamID, Type: model.BoardTypePrivate}, }, Blocks: []*model.Block{}, } bab, resp := th.Client.CreateBoardsAndBlocks(newBab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("blocks from nonexistent boards", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id", TeamID: teamID, Type: model.BoardTypePrivate}, }, Blocks: []*model.Block{ {ID: "block-id", BoardID: "nonexistent-board-id", Type: model.TypeCard, CreateAt: 1, UpdateAt: 1}, }, } bab, resp := th.Client.CreateBoardsAndBlocks(newBab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("boards with no IDs", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id", TeamID: teamID, Type: model.BoardTypePrivate}, {TeamID: teamID, Type: model.BoardTypePrivate}, }, Blocks: []*model.Block{ {ID: "block-id", BoardID: "board-id", Type: model.TypeCard, CreateAt: 1, UpdateAt: 1}, }, } bab, resp := th.Client.CreateBoardsAndBlocks(newBab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("boards from different teams", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-1", TeamID: "team-id-1", Type: model.BoardTypePrivate}, {ID: "board-id-2", TeamID: "team-id-2", Type: model.BoardTypePrivate}, }, Blocks: []*model.Block{ {ID: "block-id", BoardID: "board-id-1", Type: model.TypeCard, CreateAt: 1, UpdateAt: 1}, }, } bab, resp := th.Client.CreateBoardsAndBlocks(newBab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("creating boards and blocks", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-1", Title: "public board", TeamID: teamID, Type: model.BoardTypeOpen}, {ID: "board-id-2", Title: "private board", TeamID: teamID, Type: model.BoardTypePrivate}, }, Blocks: []*model.Block{ {ID: "block-id-1", Title: "block 1", BoardID: "board-id-1", Type: model.TypeCard, CreateAt: 1, UpdateAt: 1}, {ID: "block-id-2", Title: "block 2", BoardID: "board-id-2", Type: model.TypeCard, CreateAt: 1, UpdateAt: 1}, }, } bab, resp := th.Client.CreateBoardsAndBlocks(newBab) th.CheckOK(resp) require.NotNil(t, bab) require.Len(t, bab.Boards, 2) require.Len(t, bab.Blocks, 2) // board 1 should have been created with a new ID, and its // block should be there too boardsTermPublic, resp := th.Client.SearchBoardsForTeam(teamID, "public") th.CheckOK(resp) require.Len(t, boardsTermPublic, 1) board1 := boardsTermPublic[0] require.Equal(t, "public board", board1.Title) require.Equal(t, model.BoardTypeOpen, board1.Type) require.NotEqual(t, "board-id-1", board1.ID) blocks1, err := th.Server.App().GetBlocksForBoard(board1.ID) require.NoError(t, err) require.Len(t, blocks1, 1) require.Equal(t, "block 1", blocks1[0].Title) // board 1 should have been created with a new ID, and its // block should be there too boardsTermPrivate, resp := th.Client.SearchBoardsForTeam(teamID, "private") th.CheckOK(resp) require.Len(t, boardsTermPrivate, 1) board2 := boardsTermPrivate[0] require.Equal(t, "private board", board2.Title) require.Equal(t, model.BoardTypePrivate, board2.Type) require.NotEqual(t, "board-id-2", board2.ID) blocks2, err := th.Server.App().GetBlocksForBoard(board2.ID) require.NoError(t, err) require.Len(t, blocks2, 1) require.Equal(t, "block 2", blocks2[0].Title) // user should be an admin of both newly created boards user1 := th.GetUser1() members1, err := th.Server.App().GetMembersForBoard(board1.ID) require.NoError(t, err) require.Len(t, members1, 1) require.Equal(t, user1.ID, members1[0].UserID) members2, err := th.Server.App().GetMembersForBoard(board2.ID) require.NoError(t, err) require.Len(t, members2, 1) require.Equal(t, user1.ID, members2[0].UserID) }) }) } func TestPatchBoardsAndBlocks(t *testing.T) { teamID := "team-id" t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() pbab := &model.PatchBoardsAndBlocks{} bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckUnauthorized(resp) require.Nil(t, bab) }) t.Run("invalid patch boards and blocks", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() userID := th.GetUser1().ID initialTitle := "initial title 1" newTitle := "new title 1" newBoard1 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := th.Server.App().CreateBoard(newBoard1, userID, true) require.NoError(t, err) require.NotNil(t, board1) newBoard2 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := th.Server.App().CreateBoard(newBoard2, userID, true) require.NoError(t, err) require.NotNil(t, board2) newBlock1 := &model.Block{ ID: "block-id-1", BoardID: board1.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock1, userID)) block1, err := th.Server.App().GetBlockByID("block-id-1") require.NoError(t, err) require.NotNil(t, block1) newBlock2 := &model.Block{ ID: "block-id-2", BoardID: board2.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock2, userID)) block2, err := th.Server.App().GetBlockByID("block-id-2") require.NoError(t, err) require.NotNil(t, block2) t.Run("no board IDs", func(t *testing.T) { pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, {Title: &newTitle}, }, BlockIDs: []string{block1.ID, block2.ID}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("missmatch board IDs and patches", func(t *testing.T) { pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID, board2.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, }, BlockIDs: []string{block1.ID, block2.ID}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("no block IDs", func(t *testing.T) { pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID, board2.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, {Title: &newTitle}, }, BlockIDs: []string{}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("missmatch block IDs and patches", func(t *testing.T) { pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID, board2.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, {Title: &newTitle}, }, BlockIDs: []string{block1.ID, block2.ID}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("block that doesn't belong to any board", func(t *testing.T) { pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, }, BlockIDs: []string{block1.ID, block2.ID}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) }) t.Run("if the user doesn't have permissions for one of the boards, nothing should be updated", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() userID := th.GetUser1().ID initialTitle := "initial title 2" newTitle := "new title 2" newBoard1 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := th.Server.App().CreateBoard(newBoard1, userID, true) require.NoError(t, err) require.NotNil(t, board1) newBoard2 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := th.Server.App().CreateBoard(newBoard2, userID, false) require.NoError(t, err) require.NotNil(t, board2) newBlock1 := &model.Block{ ID: "block-id-1", BoardID: board1.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock1, userID)) block1, err := th.Server.App().GetBlockByID("block-id-1") require.NoError(t, err) require.NotNil(t, block1) newBlock2 := &model.Block{ ID: "block-id-2", BoardID: board2.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock2, userID)) block2, err := th.Server.App().GetBlockByID("block-id-2") require.NoError(t, err) require.NotNil(t, block2) pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID, board2.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, {Title: &newTitle}, }, BlockIDs: []string{block1.ID, block2.ID}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckForbidden(resp) require.Nil(t, bab) }) t.Run("boards belonging to different teams should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() userID := th.GetUser1().ID initialTitle := "initial title 3" newTitle := "new title 3" newBoard1 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := th.Server.App().CreateBoard(newBoard1, userID, true) require.NoError(t, err) require.NotNil(t, board1) newBoard2 := &model.Board{ Title: initialTitle, TeamID: "different-team-id", Type: model.BoardTypeOpen, } board2, err := th.Server.App().CreateBoard(newBoard2, userID, true) require.NoError(t, err) require.NotNil(t, board2) newBlock1 := &model.Block{ ID: "block-id-1", BoardID: board1.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock1, userID)) block1, err := th.Server.App().GetBlockByID("block-id-1") require.NoError(t, err) require.NotNil(t, block1) newBlock2 := &model.Block{ ID: "block-id-2", BoardID: board2.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock2, userID)) block2, err := th.Server.App().GetBlockByID("block-id-2") require.NoError(t, err) require.NotNil(t, block2) pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID, board2.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, {Title: &newTitle}, }, BlockIDs: []string{block1.ID, "board-id-2"}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("patches should be rejected if one is invalid", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() userID := th.GetUser1().ID initialTitle := "initial title 4" newTitle := "new title 4" newBoard1 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := th.Server.App().CreateBoard(newBoard1, userID, true) require.NoError(t, err) require.NotNil(t, board1) newBoard2 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := th.Server.App().CreateBoard(newBoard2, userID, false) require.NoError(t, err) require.NotNil(t, board2) newBlock1 := &model.Block{ ID: "block-id-1", BoardID: board1.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock1, userID)) block1, err := th.Server.App().GetBlockByID("block-id-1") require.NoError(t, err) require.NotNil(t, block1) newBlock2 := &model.Block{ ID: "block-id-2", BoardID: board2.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock2, userID)) block2, err := th.Server.App().GetBlockByID("block-id-2") require.NoError(t, err) require.NotNil(t, block2) var invalidPatchType model.BoardType = "invalid" invalidPatch := &model.BoardPatch{Type: &invalidPatchType} pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID, board2.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, invalidPatch, }, BlockIDs: []string{block1.ID, "board-id-2"}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("patches should be rejected if there is a block that doesn't belong to the boards being patched", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() userID := th.GetUser1().ID initialTitle := "initial title" newTitle := "new patched title" newBoard1 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := th.Server.App().CreateBoard(newBoard1, userID, true) require.NoError(t, err) require.NotNil(t, board1) newBoard2 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := th.Server.App().CreateBoard(newBoard2, userID, true) require.NoError(t, err) require.NotNil(t, board2) newBlock1 := &model.Block{ ID: "block-id-1", BoardID: board1.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock1, userID)) block1, err := th.Server.App().GetBlockByID("block-id-1") require.NoError(t, err) require.NotNil(t, block1) newBlock2 := &model.Block{ ID: "block-id-2", BoardID: board2.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock2, userID)) block2, err := th.Server.App().GetBlockByID("block-id-2") require.NoError(t, err) require.NotNil(t, block2) pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, }, BlockIDs: []string{block1.ID, block2.ID}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckBadRequest(resp) require.Nil(t, bab) }) t.Run("patches should be applied if they're valid and they're related", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() userID := th.GetUser1().ID initialTitle := "initial title" newTitle := "new other title" newBoard1 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := th.Server.App().CreateBoard(newBoard1, userID, true) require.NoError(t, err) require.NotNil(t, board1) newBoard2 := &model.Board{ Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := th.Server.App().CreateBoard(newBoard2, userID, true) require.NoError(t, err) require.NotNil(t, board2) newBlock1 := &model.Block{ ID: "block-id-1", BoardID: board1.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock1, userID)) block1, err := th.Server.App().GetBlockByID("block-id-1") require.NoError(t, err) require.NotNil(t, block1) newBlock2 := &model.Block{ ID: "block-id-2", BoardID: board2.ID, Title: initialTitle, } require.NoError(t, th.Server.App().InsertBlock(newBlock2, userID)) block2, err := th.Server.App().GetBlockByID("block-id-2") require.NoError(t, err) require.NotNil(t, block2) pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{board1.ID, board2.ID}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, {Title: &newTitle}, }, BlockIDs: []string{block1.ID, block2.ID}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } bab, resp := th.Client.PatchBoardsAndBlocks(pbab) th.CheckOK(resp) require.NotNil(t, bab) require.Len(t, bab.Boards, 2) require.Len(t, bab.Blocks, 2) // ensure that the entities have been updated rBoard1, err := th.Server.App().GetBoard(board1.ID) require.NoError(t, err) require.Equal(t, newTitle, rBoard1.Title) rBlock1, err := th.Server.App().GetBlockByID(block1.ID) require.NoError(t, err) require.Equal(t, newTitle, rBlock1.Title) rBoard2, err := th.Server.App().GetBoard(board2.ID) require.NoError(t, err) require.Equal(t, newTitle, rBoard2.Title) rBlock2, err := th.Server.App().GetBlockByID(block2.ID) require.NoError(t, err) require.Equal(t, newTitle, rBlock2.Title) }) } func TestDeleteBoardsAndBlocks(t *testing.T) { teamID := "team-id" t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() dbab := &model.DeleteBoardsAndBlocks{} success, resp := th.Client.DeleteBoardsAndBlocks(dbab) th.CheckUnauthorized(resp) require.False(t, success) }) t.Run("invalid delete boards and blocks", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() // a board and a block are required for the permission checks newBoard := &model.Board{ TeamID: teamID, Type: model.BoardTypeOpen, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) require.NotNil(t, board) newBlock := &model.Block{ ID: "block-id-1", BoardID: board.ID, Title: "title", } require.NoError(t, th.Server.App().InsertBlock(newBlock, th.GetUser1().ID)) block, err := th.Server.App().GetBlockByID(newBlock.ID) require.NoError(t, err) require.NotNil(t, block) t.Run("no boards", func(t *testing.T) { dbab := &model.DeleteBoardsAndBlocks{ Blocks: []string{block.ID}, } success, resp := th.Client.DeleteBoardsAndBlocks(dbab) th.CheckBadRequest(resp) require.False(t, success) }) t.Run("boards from different teams", func(t *testing.T) { newOtherTeamsBoard := &model.Board{ TeamID: "another-team-id", Type: model.BoardTypeOpen, } otherTeamsBoard, err := th.Server.App().CreateBoard(newOtherTeamsBoard, th.GetUser1().ID, true) require.NoError(t, err) require.NotNil(t, board) dbab := &model.DeleteBoardsAndBlocks{ Boards: []string{board.ID, otherTeamsBoard.ID}, Blocks: []string{"block-id-1"}, } success, resp := th.Client.DeleteBoardsAndBlocks(dbab) th.CheckBadRequest(resp) require.False(t, success) }) }) t.Run("if the user has no permissions to one of the boards, nothing should be deleted", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() // the user is an admin of the first board newBoard1 := &model.Board{ Type: model.BoardTypeOpen, TeamID: "team_id_1", } board1, err := th.Server.App().CreateBoard(newBoard1, th.GetUser1().ID, true) require.NoError(t, err) require.NotNil(t, board1) // but not of the second newBoard2 := &model.Board{ Type: model.BoardTypeOpen, TeamID: "team_id_1", } board2, err := th.Server.App().CreateBoard(newBoard2, th.GetUser1().ID, false) require.NoError(t, err) require.NotNil(t, board2) dbab := &model.DeleteBoardsAndBlocks{ Boards: []string{board1.ID, board2.ID}, Blocks: []string{"block-id-1"}, } success, resp := th.Client.DeleteBoardsAndBlocks(dbab) th.CheckForbidden(resp) require.False(t, success) }) t.Run("all boards and blocks should be deleted if the request is correct", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-1", Title: "public board", TeamID: teamID, Type: model.BoardTypeOpen}, {ID: "board-id-2", Title: "private board", TeamID: teamID, Type: model.BoardTypePrivate}, }, Blocks: []*model.Block{ {ID: "block-id-1", Title: "block 1", BoardID: "board-id-1", Type: model.TypeCard, CreateAt: 1, UpdateAt: 1}, {ID: "block-id-2", Title: "block 2", BoardID: "board-id-2", Type: model.TypeCard, CreateAt: 1, UpdateAt: 1}, }, } bab, err := th.Server.App().CreateBoardsAndBlocks(newBab, th.GetUser1().ID, true) require.NoError(t, err) require.Len(t, bab.Boards, 2) require.Len(t, bab.Blocks, 2) // ensure that the entities have been successfully created board1, err := th.Server.App().GetBoard("board-id-1") require.NoError(t, err) require.NotNil(t, board1) block1, err := th.Server.App().GetBlockByID("block-id-1") require.NoError(t, err) require.NotNil(t, block1) board2, err := th.Server.App().GetBoard("board-id-2") require.NoError(t, err) require.NotNil(t, board2) block2, err := th.Server.App().GetBlockByID("block-id-2") require.NoError(t, err) require.NotNil(t, block2) // call the API to delete boards and blocks dbab := &model.DeleteBoardsAndBlocks{ Boards: []string{"board-id-1", "board-id-2"}, Blocks: []string{"block-id-1", "block-id-2"}, } success, resp := th.Client.DeleteBoardsAndBlocks(dbab) th.CheckOK(resp) require.True(t, success) // ensure that the entities have been successfully deleted board1, err = th.Server.App().GetBoard("board-id-1") require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, board1) block1, err = th.Server.App().GetBlockByID("block-id-1") require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, block1) board2, err = th.Server.App().GetBoard("board-id-2") require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, board2) block2, err = th.Server.App().GetBlockByID("block-id-2") require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, block2) }) } ================================================ FILE: server/integrationtests/cards_test.go ================================================ package integrationtests import ( "fmt" "strconv" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateCard(t *testing.T) { t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := th.CreateBoard(testTeamID, model.BoardTypeOpen) th.Logout(th.Client) card := &model.Card{ Title: "basic card", } cardNew, resp := th.Client.CreateCard(board.ID, card, false) th.CheckUnauthorized(resp) require.Nil(t, cardNew) }) t.Run("good", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := th.CreateBoard(testTeamID, model.BoardTypeOpen) contentOrder := []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)} card := &model.Card{ Title: "test card 1", Icon: "😱", ContentOrder: contentOrder, } cardNew, resp := th.Client.CreateCard(board.ID, card, false) require.NoError(t, resp.Error) th.CheckOK(resp) require.NotNil(t, cardNew) require.Equal(t, board.ID, cardNew.BoardID) require.Equal(t, "test card 1", cardNew.Title) require.Equal(t, "😱", cardNew.Icon) require.Equal(t, contentOrder, cardNew.ContentOrder) }) t.Run("invalid card", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := th.CreateBoard(testTeamID, model.BoardTypeOpen) card := &model.Card{ Title: "too many emoji's", Icon: "😱😱😱😱", } cardNew, resp := th.Client.CreateCard(board.ID, card, false) require.Error(t, resp.Error) require.Nil(t, cardNew) }) } func TestGetCards(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := th.CreateBoard(testTeamID, model.BoardTypeOpen) userID := th.GetUser1().ID const cardCount = 25 // make some cards with content for i := 0; i < cardCount; i++ { card := &model.Card{ BoardID: board.ID, CreatedBy: userID, ModifiedBy: userID, Title: fmt.Sprintf("%d", i), } cardNew, resp := th.Client.CreateCard(board.ID, card, true) th.CheckOK(resp) blocks := make([]*model.Block, 0, 3) for j := 0; j < 3; j++ { now := model.GetMillis() block := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), ParentID: cardNew.ID, CreatedBy: userID, ModifiedBy: userID, CreateAt: now, UpdateAt: now, Schema: 1, Type: model.TypeText, Title: fmt.Sprintf("text %d for card %d", j, i), BoardID: board.ID, } blocks = append(blocks, block) } _, resp = th.Client.InsertBlocks(board.ID, blocks, true) th.CheckOK(resp) } t.Run("fetch all cards", func(t *testing.T) { cards, resp := th.Client.GetCards(board.ID, 0, -1) th.CheckOK(resp) assert.Len(t, cards, cardCount) }) t.Run("fetch with pagination", func(t *testing.T) { cardNums := make(map[int]struct{}) // return first 10 cards, resp := th.Client.GetCards(board.ID, 0, 10) th.CheckOK(resp) assert.Len(t, cards, 10) for _, card := range cards { cardNum, err := strconv.Atoi(card.Title) require.NoError(t, err) cardNums[cardNum] = struct{}{} } // return second 10 cards, resp = th.Client.GetCards(board.ID, 1, 10) th.CheckOK(resp) assert.Len(t, cards, 10) for _, card := range cards { cardNum, err := strconv.Atoi(card.Title) require.NoError(t, err) cardNums[cardNum] = struct{}{} } // return remaining 5 cards, resp = th.Client.GetCards(board.ID, 2, 10) th.CheckOK(resp) assert.Len(t, cards, 5) for _, card := range cards { cardNum, err := strconv.Atoi(card.Title) require.NoError(t, err) cardNums[cardNum] = struct{}{} } // make sure all card numbers were returned assert.Len(t, cardNums, cardCount) for i := 0; i < cardCount; i++ { _, ok := cardNums[i] assert.True(t, ok) } }) t.Run("a non authenticated user should be rejected", func(t *testing.T) { th.Logout(th.Client) cards, resp := th.Client.GetCards(board.ID, 0, 10) th.CheckUnauthorized(resp) require.Nil(t, cards) }) } func TestPatchCard(t *testing.T) { t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() _, cards := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 1) card := cards[0] th.Logout(th.Client) newTitle := "another title" patch := &model.CardPatch{ Title: &newTitle, } patchedCard, resp := th.Client.PatchCard(card.ID, patch, false) th.CheckUnauthorized(resp) require.Nil(t, patchedCard) }) t.Run("good", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board, cards := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 1) card := cards[0] // Patch the card newTitle := "another title" newIcon := "🐿" newContentOrder := reverse(card.ContentOrder) updatedProps := modifyCardProps(card.Properties) patch := &model.CardPatch{ Title: &newTitle, Icon: &newIcon, ContentOrder: &newContentOrder, UpdatedProperties: updatedProps, } patchedCard, resp := th.Client.PatchCard(card.ID, patch, false) th.CheckOK(resp) require.NotNil(t, patchedCard) require.Equal(t, board.ID, patchedCard.BoardID) require.Equal(t, newTitle, patchedCard.Title) require.Equal(t, newIcon, patchedCard.Icon) require.NotEqual(t, card.ContentOrder, patchedCard.ContentOrder) require.ElementsMatch(t, card.ContentOrder, patchedCard.ContentOrder) require.EqualValues(t, updatedProps, patchedCard.Properties) }) t.Run("invalid card patch", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() _, cards := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 1) card := cards[0] // Bad patch (too many emoji) newIcon := "🐿🐿🐿" patch := &model.CardPatch{ Icon: &newIcon, } cardNew, resp := th.Client.PatchCard(card.ID, patch, false) require.Error(t, resp.Error) require.Nil(t, cardNew) }) } func TestGetCard(t *testing.T) { t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() _, cards := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 1) card := cards[0] th.Logout(th.Client) cardFetched, resp := th.Client.GetCard(card.ID) th.CheckUnauthorized(resp) require.Nil(t, cardFetched) }) t.Run("good", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board, cards := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 1) card := cards[0] cardFetched, resp := th.Client.GetCard(card.ID) th.CheckOK(resp) require.NotNil(t, cardFetched) require.Equal(t, board.ID, cardFetched.BoardID) require.Equal(t, card.Title, cardFetched.Title) require.Equal(t, card.Icon, cardFetched.Icon) require.Equal(t, card.ContentOrder, cardFetched.ContentOrder) require.EqualValues(t, card.Properties, cardFetched.Properties) }) } // Helpers. func reverse(src []string) []string { out := make([]string, 0, len(src)) for i := len(src) - 1; i >= 0; i-- { out = append(out, src[i]) } return out } func modifyCardProps(m map[string]any) map[string]any { out := make(map[string]any) for k := range m { out[k] = utils.NewID(utils.IDTypeBlock) } return out } ================================================ FILE: server/integrationtests/clienttestlib.go ================================================ package integrationtests import ( "errors" "fmt" "net/http" "os" "testing" "time" "github.com/mattermost/focalboard/server/client" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/server" "github.com/mattermost/focalboard/server/services/auth" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/permissions/localpermissions" "github.com/mattermost/focalboard/server/services/permissions/mmpermissions" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/services/store/sqlstore" "github.com/mattermost/focalboard/server/utils" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/stretchr/testify/require" ) const ( user1Username = "user1" user2Username = "user2" password = "Pa$$word" testTeamID = "team-id" ) const ( userAnon string = "anon" userNoTeamMember string = "no-team-member" userTeamMember string = "team-member" userViewer string = "viewer" userCommenter string = "commenter" userEditor string = "editor" userAdmin string = "admin" userGuest string = "guest" ) var ( userAnonID = userAnon userNoTeamMemberID = userNoTeamMember userTeamMemberID = userTeamMember userViewerID = userViewer userCommenterID = userCommenter userEditorID = userEditor userAdminID = userAdmin userGuestID = userGuest ) type LicenseType int const ( LicenseNone LicenseType = iota // 0 LicenseProfessional // 1 LicenseEnterprise // 2 ) type TestHelper struct { T *testing.T Server *server.Server Client *client.Client Client2 *client.Client origEnvUnitTesting string } type FakePermissionPluginAPI struct{} func (*FakePermissionPluginAPI) HasPermissionTo(userID string, permission *mmModel.Permission) bool { return userID == userAdmin } func (*FakePermissionPluginAPI) HasPermissionToTeam(userID string, teamID string, permission *mmModel.Permission) bool { if permission.Id == model.PermissionManageTeam.Id { return false } if userID == userNoTeamMember { return false } if teamID == "empty-team" { return false } return true } func (*FakePermissionPluginAPI) HasPermissionToChannel(userID string, channelID string, permission *mmModel.Permission) bool { return channelID == "valid-channel-id" || channelID == "valid-channel-id-2" } func getTestConfig() (*config.Configuration, error) { dbType, connectionString, err := sqlstore.PrepareNewTestDatabase() if err != nil { return nil, err } logging := ` { "testing": { "type": "console", "options": { "out": "stdout" }, "format": "plain", "format_options": { "delim": " " }, "levels": [ {"id": 5, "name": "debug"}, {"id": 4, "name": "info"}, {"id": 3, "name": "warn"}, {"id": 2, "name": "error", "stacktrace": true}, {"id": 1, "name": "fatal", "stacktrace": true}, {"id": 0, "name": "panic", "stacktrace": true} ] } }` return &config.Configuration{ ServerRoot: "http://localhost:8888", Port: 8888, DBType: dbType, DBConfigString: connectionString, DBTablePrefix: "test_", WebPath: "./pack", FilesDriver: "local", FilesPath: "./files", LoggingCfgJSON: logging, SessionExpireTime: int64(30 * time.Second), AuthMode: "native", }, nil } func newTestServer(singleUserToken string) *server.Server { return newTestServerWithLicense(singleUserToken, LicenseNone) } func newTestServerWithLicense(singleUserToken string, licenseType LicenseType) *server.Server { cfg, err := getTestConfig() if err != nil { panic(err) } logger, _ := mlog.NewLogger() if err = logger.Configure("", cfg.LoggingCfgJSON, nil); err != nil { panic(err) } singleUser := len(singleUserToken) > 0 innerStore, err := server.NewStore(cfg, singleUser, logger) if err != nil { panic(err) } var db store.Store switch licenseType { case LicenseProfessional: db = NewTestProfessionalStore(innerStore) case LicenseEnterprise: db = NewTestEnterpriseStore(innerStore) case LicenseNone: fallthrough default: db = innerStore } permissionsService := localpermissions.New(db, logger) params := server.Params{ Cfg: cfg, SingleUserToken: singleUserToken, DBStore: db, Logger: logger, PermissionsService: permissionsService, } srv, err := server.New(params) if err != nil { panic(err) } return srv } func NewTestServerPluginMode() *server.Server { cfg, err := getTestConfig() if err != nil { panic(err) } cfg.AuthMode = "mattermost" cfg.EnablePublicSharedBoards = true logger, _ := mlog.NewLogger() if err = logger.Configure("", cfg.LoggingCfgJSON, nil); err != nil { panic(err) } innerStore, err := server.NewStore(cfg, false, logger) if err != nil { panic(err) } db := NewPluginTestStore(innerStore) permissionsService := mmpermissions.New(db, &FakePermissionPluginAPI{}, logger) params := server.Params{ Cfg: cfg, DBStore: db, Logger: logger, PermissionsService: permissionsService, } srv, err := server.New(params) if err != nil { panic(err) } return srv } func newTestServerLocalMode() *server.Server { cfg, err := getTestConfig() if err != nil { panic(err) } cfg.EnablePublicSharedBoards = true logger, _ := mlog.NewLogger() if err = logger.Configure("", cfg.LoggingCfgJSON, nil); err != nil { panic(err) } db, err := server.NewStore(cfg, false, logger) if err != nil { panic(err) } permissionsService := localpermissions.New(db, logger) params := server.Params{ Cfg: cfg, DBStore: db, Logger: logger, PermissionsService: permissionsService, } srv, err := server.New(params) if err != nil { panic(err) } // Reduce password has strength for unit tests to dramatically speed up account creation and login auth.PasswordHashStrength = 4 return srv } func SetupTestHelperWithToken(t *testing.T) *TestHelper { origUnitTesting := os.Getenv("FOCALBOARD_UNIT_TESTING") os.Setenv("FOCALBOARD_UNIT_TESTING", "1") sessionToken := "TESTTOKEN" th := &TestHelper{ T: t, origEnvUnitTesting: origUnitTesting, } th.Server = newTestServer(sessionToken) th.Client = client.NewClient(th.Server.Config().ServerRoot, sessionToken) th.Client2 = client.NewClient(th.Server.Config().ServerRoot, sessionToken) return th } func SetupTestHelper(t *testing.T) *TestHelper { return SetupTestHelperWithLicense(t, LicenseNone) } func SetupTestHelperPluginMode(t *testing.T) *TestHelper { origUnitTesting := os.Getenv("FOCALBOARD_UNIT_TESTING") os.Setenv("FOCALBOARD_UNIT_TESTING", "1") th := &TestHelper{ T: t, origEnvUnitTesting: origUnitTesting, } th.Server = NewTestServerPluginMode() th.Start() return th } func SetupTestHelperLocalMode(t *testing.T) *TestHelper { origUnitTesting := os.Getenv("FOCALBOARD_UNIT_TESTING") os.Setenv("FOCALBOARD_UNIT_TESTING", "1") th := &TestHelper{ T: t, origEnvUnitTesting: origUnitTesting, } th.Server = newTestServerLocalMode() th.Start() return th } func SetupTestHelperWithLicense(t *testing.T, licenseType LicenseType) *TestHelper { origUnitTesting := os.Getenv("FOCALBOARD_UNIT_TESTING") os.Setenv("FOCALBOARD_UNIT_TESTING", "1") th := &TestHelper{ T: t, origEnvUnitTesting: origUnitTesting, } th.Server = newTestServerWithLicense("", licenseType) th.Client = client.NewClient(th.Server.Config().ServerRoot, "") th.Client2 = client.NewClient(th.Server.Config().ServerRoot, "") return th } // Start starts the test server and ensures that it's correctly // responding to requests before returning. func (th *TestHelper) Start() *TestHelper { go func() { if err := th.Server.Start(); err != nil { panic(err) } }() for { URL := th.Server.Config().ServerRoot th.Server.Logger().Info("Polling server", mlog.String("url", URL)) resp, err := http.Get(URL) //nolint:gosec if err != nil { th.Server.Logger().Error("Polling failed", mlog.Err(err)) time.Sleep(100 * time.Millisecond) continue } resp.Body.Close() // Currently returns 404 // if resp.StatusCode != http.StatusOK { // th.Server.Logger().Error("Not OK", mlog.Int("statusCode", resp.StatusCode)) // continue // } // Reached this point: server is up and running! th.Server.Logger().Info("Server ping OK", mlog.Int("statusCode", resp.StatusCode)) break } return th } // InitBasic starts the test server and initializes the clients of the // helper, registering them and logging them into the system. func (th *TestHelper) InitBasic() *TestHelper { // Reduce password has strength for unit tests to dramatically speed up account creation and login auth.PasswordHashStrength = 4 th.Start() // user1 th.RegisterAndLogin(th.Client, user1Username, "user1@sample.com", password, "") // get token team, resp := th.Client.GetTeam(model.GlobalTeamID) th.CheckOK(resp) require.NotNil(th.T, team) require.NotNil(th.T, team.SignupToken) // user2 th.RegisterAndLogin(th.Client2, user2Username, "user2@sample.com", password, team.SignupToken) return th } var ErrRegisterFail = errors.New("register failed") func (th *TestHelper) TearDown() { os.Setenv("FOCALBOARD_UNIT_TESTING", th.origEnvUnitTesting) logger := th.Server.Logger() if l, ok := logger.(*mlog.Logger); ok { defer func() { _ = l.Shutdown() }() } err := th.Server.Shutdown() if err != nil { panic(err) } os.RemoveAll(th.Server.Config().FilesPath) if err := os.Remove(th.Server.Config().DBConfigString); err == nil { logger.Debug("Removed test database", mlog.String("file", th.Server.Config().DBConfigString)) } } func (th *TestHelper) RegisterAndLogin(client *client.Client, username, email, password, token string) { req := &model.RegisterRequest{ Username: username, Email: email, Password: password, Token: token, } success, resp := th.Client.Register(req) th.CheckOK(resp) require.True(th.T, success) th.Login(client, username, password) } func (th *TestHelper) Login(client *client.Client, username, password string) { req := &model.LoginRequest{ Type: "normal", Username: username, Password: password, } data, resp := client.Login(req) th.CheckOK(resp) require.NotNil(th.T, data) } func (th *TestHelper) Login1() { th.Login(th.Client, user1Username, password) } func (th *TestHelper) Login2() { th.Login(th.Client2, user2Username, password) } func (th *TestHelper) Logout(client *client.Client) { client.Token = "" } func (th *TestHelper) Me(client *client.Client) *model.User { user, resp := client.GetMe() th.CheckOK(resp) require.NotNil(th.T, user) return user } func (th *TestHelper) CreateBoard(teamID string, boardType model.BoardType) *model.Board { newBoard := &model.Board{ TeamID: teamID, Type: boardType, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) return board } func (th *TestHelper) CreateBoards(teamID string, boardType model.BoardType, count int) []*model.Board { boards := make([]*model.Board, 0, count) for i := 0; i < count; i++ { board := th.CreateBoard(teamID, boardType) boards = append(boards, board) } return boards } func (th *TestHelper) CreateCategory(category model.Category) *model.Category { cat, resp := th.Client.CreateCategory(category) th.CheckOK(resp) return cat } func (th *TestHelper) UpdateCategoryBoard(teamID, categoryID, boardID string) { response := th.Client.UpdateCategoryBoard(teamID, categoryID, boardID) th.CheckOK(response) } func (th *TestHelper) CreateBoardAndCards(teamdID string, boardType model.BoardType, numCards int) (*model.Board, []*model.Card) { board := th.CreateBoard(teamdID, boardType) cards := make([]*model.Card, 0, numCards) for i := 0; i < numCards; i++ { card := &model.Card{ Title: fmt.Sprintf("test card %d", i+1), ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)}, Icon: "😱", Properties: th.MakeCardProps(5), } newCard, resp := th.Client.CreateCard(board.ID, card, true) th.CheckOK(resp) cards = append(cards, newCard) } return board, cards } func (th *TestHelper) MakeCardProps(count int) map[string]any { props := make(map[string]any) for i := 0; i < count; i++ { props[utils.NewID(utils.IDTypeBlock)] = utils.NewID(utils.IDTypeBlock) } return props } func (th *TestHelper) GetUserCategoryBoards(teamID string) []model.CategoryBoards { categoryBoards, response := th.Client.GetUserCategoryBoards(teamID) th.CheckOK(response) return categoryBoards } func (th *TestHelper) DeleteCategory(teamID, categoryID string) { response := th.Client.DeleteCategory(teamID, categoryID) th.CheckOK(response) } func (th *TestHelper) GetUser1() *model.User { return th.Me(th.Client) } func (th *TestHelper) GetUser2() *model.User { return th.Me(th.Client2) } func (th *TestHelper) CheckOK(r *client.Response) { require.Equal(th.T, http.StatusOK, r.StatusCode) require.NoError(th.T, r.Error) } func (th *TestHelper) CheckBadRequest(r *client.Response) { require.Equal(th.T, http.StatusBadRequest, r.StatusCode) require.Error(th.T, r.Error) } func (th *TestHelper) CheckNotFound(r *client.Response) { require.Equal(th.T, http.StatusNotFound, r.StatusCode) require.Error(th.T, r.Error) } func (th *TestHelper) CheckUnauthorized(r *client.Response) { require.Equal(th.T, http.StatusUnauthorized, r.StatusCode) require.Error(th.T, r.Error) } func (th *TestHelper) CheckForbidden(r *client.Response) { require.Equal(th.T, http.StatusForbidden, r.StatusCode) require.Error(th.T, r.Error) } func (th *TestHelper) CheckRequestEntityTooLarge(r *client.Response) { require.Equal(th.T, http.StatusRequestEntityTooLarge, r.StatusCode) require.Error(th.T, r.Error) } func (th *TestHelper) CheckNotImplemented(r *client.Response) { require.Equal(th.T, http.StatusNotImplemented, r.StatusCode) require.Error(th.T, r.Error) } ================================================ FILE: server/integrationtests/compliance_test.go ================================================ package integrationtests import ( "math" "os" "strconv" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) var ( OneHour int64 = 360000 OneDay int64 = OneHour * 24 OneYear int64 = OneDay * 365 ) func setupTestHelperForCompliance(t *testing.T, complianceLicense bool) (*TestHelper, Clients) { os.Setenv("FOCALBOARD_UNIT_TESTING_COMPLIANCE", strconv.FormatBool(complianceLicense)) th := SetupTestHelperPluginMode(t) clients := setupClients(th) th.Client = clients.TeamMember th.Client2 = clients.TeamMember return th, clients } func TestGetBoardsForCompliance(t *testing.T) { t.Run("missing Features.Compliance license should fail", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, false) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) bcr, resp := clients.Admin.GetBoardsForCompliance(testTeamID, 0, 0) th.CheckNotImplemented(resp) require.Nil(t, bcr) }) t.Run("a non authenticated user should be rejected", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) th.Logout(th.Client) bcr, resp := clients.Anon.GetBoardsForCompliance(testTeamID, 0, 0) th.CheckUnauthorized(resp) require.Nil(t, bcr) }) t.Run("a user without manage_system permission should be rejected", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) bcr, resp := clients.TeamMember.GetBoardsForCompliance(testTeamID, 0, 0) th.CheckUnauthorized(resp) require.Nil(t, bcr) }) t.Run("good call", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 10 _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, count) bcr, resp := clients.Admin.GetBoardsForCompliance(testTeamID, 0, 0) th.CheckOK(resp) require.False(t, bcr.HasNext) require.Len(t, bcr.Results, count) }) t.Run("pagination", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 20 const perPage = 3 _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, count) boards := make([]*model.Board, 0, count) page := 0 for { bcr, resp := clients.Admin.GetBoardsForCompliance(testTeamID, page, perPage) page++ th.CheckOK(resp) boards = append(boards, bcr.Results...) if !bcr.HasNext { break } } require.Len(t, boards, count) require.Equal(t, int(math.Floor((count/perPage)+1)), page) }) t.Run("invalid teamID", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) bcr, resp := clients.Admin.GetBoardsForCompliance(utils.NewID(utils.IDTypeTeam), 0, 0) th.CheckBadRequest(resp) require.Nil(t, bcr) }) } func TestGetBoardsComplianceHistory(t *testing.T) { t.Run("missing Features.Compliance license should fail", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, false) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.Admin.GetBoardsComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, 0, 0) th.CheckNotImplemented(resp) require.Nil(t, bchr) }) t.Run("a non authenticated user should be rejected", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) th.Logout(th.Client) bchr, resp := clients.Anon.GetBoardsComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, 0, 0) th.CheckUnauthorized(resp) require.Nil(t, bchr) }) t.Run("a user without manage_system permission should be rejected", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.TeamMember.GetBoardsComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, 0, 0) th.CheckUnauthorized(resp) require.Nil(t, bchr) }) t.Run("good call, exclude deleted", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 10 boards := th.CreateBoards(testTeamID, model.BoardTypeOpen, count) deleted, resp := th.Client.DeleteBoard(boards[0].ID) th.CheckOK(resp) require.True(t, deleted) deleted, resp = th.Client.DeleteBoard(boards[1].ID) th.CheckOK(resp) require.True(t, deleted) bchr, resp := clients.Admin.GetBoardsComplianceHistory(utils.GetMillis()-OneDay, false, testTeamID, 0, 0) th.CheckOK(resp) require.False(t, bchr.HasNext) require.Len(t, bchr.Results, count-2) // two boards deleted }) t.Run("good call, include deleted", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 10 boards := th.CreateBoards(testTeamID, model.BoardTypeOpen, count) deleted, resp := th.Client.DeleteBoard(boards[0].ID) th.CheckOK(resp) require.True(t, deleted) deleted, resp = th.Client.DeleteBoard(boards[1].ID) th.CheckOK(resp) require.True(t, deleted) bchr, resp := clients.Admin.GetBoardsComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, 0, 0) th.CheckOK(resp) require.False(t, bchr.HasNext) require.Len(t, bchr.Results, count+2) // both deleted boards have 2 history records each }) t.Run("pagination", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 20 const perPage = 3 _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, count) boardHistory := make([]*model.BoardHistory, 0, count) page := 0 for { bchr, resp := clients.Admin.GetBoardsComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, page, perPage) page++ th.CheckOK(resp) boardHistory = append(boardHistory, bchr.Results...) if !bchr.HasNext { break } } require.Len(t, boardHistory, count) require.Equal(t, int(math.Floor((count/perPage)+1)), page) }) t.Run("invalid teamID", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() _ = th.CreateBoards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.Admin.GetBoardsComplianceHistory(utils.GetMillis()-OneDay, true, utils.NewID(utils.IDTypeTeam), 0, 0) th.CheckBadRequest(resp) require.Nil(t, bchr) }) } func TestGetBlocksComplianceHistory(t *testing.T) { t.Run("missing Features.Compliance license should fail", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, false) defer th.TearDown() board, _ := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.Admin.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, board.ID, 0, 0) th.CheckNotImplemented(resp) require.Nil(t, bchr) }) t.Run("a non authenticated user should be rejected", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() board, _ := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.Anon.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, board.ID, 0, 0) th.CheckUnauthorized(resp) require.Nil(t, bchr) }) t.Run("a user without manage_system permission should be rejected", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() board, _ := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.TeamMember.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, board.ID, 0, 0) th.CheckUnauthorized(resp) require.Nil(t, bchr) }) t.Run("good call, exclude deleted", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 10 board, cards := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, count) deleted, resp := th.Client.DeleteBlock(board.ID, cards[0].ID, true) th.CheckOK(resp) require.True(t, deleted) deleted, resp = th.Client.DeleteBlock(board.ID, cards[1].ID, true) th.CheckOK(resp) require.True(t, deleted) bchr, resp := clients.Admin.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, false, testTeamID, board.ID, 0, 0) th.CheckOK(resp) require.False(t, bchr.HasNext) require.Len(t, bchr.Results, count-2) // 2 blocks deleted }) t.Run("good call, include deleted", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 10 board, cards := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, count) deleted, resp := th.Client.DeleteBlock(board.ID, cards[0].ID, true) th.CheckOK(resp) require.True(t, deleted) deleted, resp = th.Client.DeleteBlock(board.ID, cards[1].ID, true) th.CheckOK(resp) require.True(t, deleted) bchr, resp := clients.Admin.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, board.ID, 0, 0) th.CheckOK(resp) require.False(t, bchr.HasNext) require.Len(t, bchr.Results, count+2) // both deleted boards have 2 history records each }) t.Run("pagination", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() const count = 20 const perPage = 3 board, _ := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, count) blockHistory := make([]*model.BlockHistory, 0, count) page := 0 for { bchr, resp := clients.Admin.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, board.ID, page, perPage) page++ th.CheckOK(resp) blockHistory = append(blockHistory, bchr.Results...) if !bchr.HasNext { break } } require.Len(t, blockHistory, count) require.Equal(t, int(math.Floor((count/perPage)+1)), page) }) t.Run("invalid teamID", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() board, _ := th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.Admin.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, true, utils.NewID(utils.IDTypeTeam), board.ID, 0, 0) th.CheckBadRequest(resp) require.Nil(t, bchr) }) t.Run("invalid boardID", func(t *testing.T) { th, clients := setupTestHelperForCompliance(t, true) defer th.TearDown() _, _ = th.CreateBoardAndCards(testTeamID, model.BoardTypeOpen, 2) bchr, resp := clients.Admin.GetBlocksComplianceHistory(utils.GetMillis()-OneDay, true, testTeamID, utils.NewID(utils.IDTypeBoard), 0, 0) th.CheckBadRequest(resp) require.Nil(t, bchr) }) } ================================================ FILE: server/integrationtests/content_blocks_test.go ================================================ package integrationtests import ( "fmt" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func TestMoveContentBlock(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() board := th.CreateBoard("team-id", model.BoardTypeOpen) cardID1 := utils.NewID(utils.IDTypeBlock) cardID2 := utils.NewID(utils.IDTypeBlock) contentBlockID1 := utils.NewID(utils.IDTypeBlock) contentBlockID2 := utils.NewID(utils.IDTypeBlock) contentBlockID3 := utils.NewID(utils.IDTypeBlock) contentBlockID4 := utils.NewID(utils.IDTypeBlock) contentBlockID5 := utils.NewID(utils.IDTypeBlock) contentBlockID6 := utils.NewID(utils.IDTypeBlock) card1 := &model.Block{ ID: cardID1, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, Fields: map[string]interface{}{ "contentOrder": []string{contentBlockID1, contentBlockID2, contentBlockID3}, }, } card2 := &model.Block{ ID: cardID2, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, Fields: map[string]interface{}{ "contentOrder": []string{contentBlockID4, contentBlockID5, contentBlockID6}, }, } contentBlock1 := &model.Block{ ID: contentBlockID1, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, ParentID: cardID1, } contentBlock2 := &model.Block{ ID: contentBlockID2, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, ParentID: cardID1, } contentBlock3 := &model.Block{ ID: contentBlockID3, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, ParentID: cardID1, } contentBlock4 := &model.Block{ ID: contentBlockID4, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, ParentID: cardID2, } contentBlock5 := &model.Block{ ID: contentBlockID5, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, ParentID: cardID2, } contentBlock6 := &model.Block{ ID: contentBlockID6, BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, ParentID: cardID2, } newBlocks := []*model.Block{ contentBlock1, contentBlock2, contentBlock3, contentBlock4, contentBlock5, contentBlock6, card1, card2, } createdBlocks, resp := th.Client.InsertBlocks(board.ID, newBlocks, false) require.NoError(t, resp.Error) require.Len(t, newBlocks, 8) contentBlock1.ID = createdBlocks[0].ID contentBlock2.ID = createdBlocks[1].ID contentBlock3.ID = createdBlocks[2].ID contentBlock4.ID = createdBlocks[3].ID contentBlock5.ID = createdBlocks[4].ID contentBlock6.ID = createdBlocks[5].ID card1.ID = createdBlocks[6].ID card2.ID = createdBlocks[7].ID ttCases := []struct { name string srcBlockID string dstBlockID string where string userID string errorMessage string expectedContentOrder []interface{} }{ { name: "not matching parents", srcBlockID: contentBlock1.ID, dstBlockID: contentBlock4.ID, where: "after", userID: "user-id", errorMessage: fmt.Sprintf("payload: {\"error\":\"not matching parent %s and %s\",\"errorCode\":400}", card1.ID, card2.ID), expectedContentOrder: []interface{}{contentBlock1.ID, contentBlock2.ID, contentBlock3.ID}, }, { name: "valid request with not real change", srcBlockID: contentBlock2.ID, dstBlockID: contentBlock1.ID, where: "after", userID: "user-id", errorMessage: "", expectedContentOrder: []interface{}{contentBlock1.ID, contentBlock2.ID, contentBlock3.ID}, }, { name: "valid request changing order with before", srcBlockID: contentBlock2.ID, dstBlockID: contentBlock1.ID, where: "before", userID: "user-id", errorMessage: "", expectedContentOrder: []interface{}{contentBlock2.ID, contentBlock1.ID, contentBlock3.ID}, }, { name: "valid request changing order with after", srcBlockID: contentBlock1.ID, dstBlockID: contentBlock2.ID, where: "after", userID: "user-id", errorMessage: "", expectedContentOrder: []interface{}{contentBlock2.ID, contentBlock1.ID, contentBlock3.ID}, }, } for _, tc := range ttCases { t.Run(tc.name, func(t *testing.T) { _, resp := th.Client.MoveContentBlock(tc.srcBlockID, tc.dstBlockID, tc.where, tc.userID) if tc.errorMessage == "" { require.NoError(t, resp.Error) } else { require.EqualError(t, resp.Error, tc.errorMessage) } parent, err := th.Server.App().GetBlockByID(card1.ID) require.NoError(t, err) require.Equal(t, parent.Fields["contentOrder"], tc.expectedContentOrder) }) } } ================================================ FILE: server/integrationtests/export_test.go ================================================ package integrationtests import ( "bytes" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func TestExportBoard(t *testing.T) { t.Run("export single board", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() board := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: "test-team", Title: "Export Test Board", CreatedBy: th.GetUser1().ID, Type: model.BoardTypeOpen, CreateAt: utils.GetMillis(), UpdateAt: utils.GetMillis(), } block := &model.Block{ ID: utils.NewID(utils.IDTypeCard), ParentID: board.ID, Type: model.TypeCard, BoardID: board.ID, Title: "Test card # for export", CreatedBy: th.GetUser1().ID, CreateAt: utils.GetMillis(), UpdateAt: utils.GetMillis(), } babs := &model.BoardsAndBlocks{ Boards: []*model.Board{board}, Blocks: []*model.Block{block}, } babs, resp := th.Client.CreateBoardsAndBlocks(babs) th.CheckOK(resp) // export the board to an in-memory archive file buf, resp := th.Client.ExportBoardArchive(babs.Boards[0].ID) th.CheckOK(resp) require.NotNil(t, buf) // import the archive file to team 0 resp = th.Client.ImportArchive(model.GlobalTeamID, bytes.NewReader(buf)) th.CheckOK(resp) require.NoError(t, resp.Error) // check for test card boardsImported, err := th.Server.App().GetBoardsForUserAndTeam(th.GetUser1().ID, model.GlobalTeamID, true) require.NoError(t, err) require.Len(t, boardsImported, 1) boardImported := boardsImported[0] blocksImported, err := th.Server.App().GetBlocksForBoard(boardImported.ID) require.NoError(t, err) require.Len(t, blocksImported, 1) require.Equal(t, block.Title, blocksImported[0].Title) }) } ================================================ FILE: server/integrationtests/file_test.go ================================================ package integrationtests import ( "bytes" "testing" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/require" ) func TestUploadFile(t *testing.T) { const ( testTeamID = "team-id" ) t.Run("a non authenticated user should be rejected", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() th.Logout(th.Client) file, resp := th.Client.TeamUploadFile(testTeamID, "test-board-id", bytes.NewBuffer([]byte("test"))) th.CheckUnauthorized(resp) require.Nil(t, file) }) t.Run("upload a file to an existing team and board without permissions", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() file, resp := th.Client.TeamUploadFile(testTeamID, "not-valid-board", bytes.NewBuffer([]byte("test"))) th.CheckForbidden(resp) require.Nil(t, file) }) t.Run("upload a file to an existing team and board with permissions", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() testBoard := th.CreateBoard(testTeamID, model.BoardTypeOpen) file, resp := th.Client.TeamUploadFile(testTeamID, testBoard.ID, bytes.NewBuffer([]byte("test"))) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, file) require.NotNil(t, file.FileID) }) t.Run("upload a file to an existing team and board with permissions but reaching the MaxFileLimit", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() testBoard := th.CreateBoard(testTeamID, model.BoardTypeOpen) config := th.Server.App().GetConfig() config.MaxFileSize = 1 th.Server.App().SetConfig(config) file, resp := th.Client.TeamUploadFile(testTeamID, testBoard.ID, bytes.NewBuffer([]byte("test"))) th.CheckRequestEntityTooLarge(resp) require.Nil(t, file) config.MaxFileSize = 100000 th.Server.App().SetConfig(config) file, resp = th.Client.TeamUploadFile(testTeamID, testBoard.ID, bytes.NewBuffer([]byte("test"))) th.CheckOK(resp) require.NoError(t, resp.Error) require.NotNil(t, file) require.NotNil(t, file.FileID) }) } func TestFileInfo(t *testing.T) { const ( testTeamID = "team-id" ) t.Run("Retrieving file info", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() testBoard := th.CreateBoard(testTeamID, model.BoardTypeOpen) fileInfo, resp := th.Client.TeamUploadFileInfo(testTeamID, testBoard.ID, "test") th.CheckOK(resp) require.NotNil(t, fileInfo) require.NotNil(t, fileInfo.Id) }) } ================================================ FILE: server/integrationtests/permissions_test.go ================================================ //nolint:dupl package integrationtests import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "testing" "github.com/mattermost/focalboard/server/client" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/require" ) type Clients struct { Anon *client.Client NoTeamMember *client.Client TeamMember *client.Client Viewer *client.Client Commenter *client.Client Editor *client.Client Admin *client.Client Guest *client.Client } const ( methodPost = "POST" methodGet = "GET" methodPut = "PUT" methodDelete = "DELETE" methodPatch = "PATCH" ) type TestCase struct { url string method string body string userRole string // userAnon, userNoTeamMember, userTeamMember, userViewer, userCommenter, userEditor, userAdmin or userGuest expectedStatusCode int totalResults int } func (tt TestCase) identifier() string { return fmt.Sprintf( "url: %s method: %s body: %s userRoles: %s expectedStatusCode: %d totalResults: %d", tt.url, tt.method, tt.body, tt.userRole, tt.expectedStatusCode, tt.totalResults, ) } func setupClients(th *TestHelper) Clients { // user1 clients := Clients{ Anon: client.NewClient(th.Server.Config().ServerRoot, ""), NoTeamMember: client.NewClient(th.Server.Config().ServerRoot, ""), TeamMember: client.NewClient(th.Server.Config().ServerRoot, ""), Viewer: client.NewClient(th.Server.Config().ServerRoot, ""), Commenter: client.NewClient(th.Server.Config().ServerRoot, ""), Editor: client.NewClient(th.Server.Config().ServerRoot, ""), Admin: client.NewClient(th.Server.Config().ServerRoot, ""), Guest: client.NewClient(th.Server.Config().ServerRoot, ""), } clients.NoTeamMember.HTTPHeader["Mattermost-User-Id"] = userNoTeamMember clients.TeamMember.HTTPHeader["Mattermost-User-Id"] = userTeamMember clients.Viewer.HTTPHeader["Mattermost-User-Id"] = userViewer clients.Commenter.HTTPHeader["Mattermost-User-Id"] = userCommenter clients.Editor.HTTPHeader["Mattermost-User-Id"] = userEditor clients.Admin.HTTPHeader["Mattermost-User-Id"] = userAdmin clients.Guest.HTTPHeader["Mattermost-User-Id"] = userGuest // For plugin tests, the userID = username userAnonID = userAnon userNoTeamMemberID = userNoTeamMember userTeamMemberID = userTeamMember userViewerID = userViewer userCommenterID = userCommenter userEditorID = userEditor userAdminID = userAdmin userGuestID = userGuest return clients } func setupLocalClients(th *TestHelper) Clients { th.Client = client.NewClient(th.Server.Config().ServerRoot, "") th.RegisterAndLogin(th.Client, "sysadmin", "sysadmin@sample.com", password, "") clients := Clients{ Anon: client.NewClient(th.Server.Config().ServerRoot, ""), NoTeamMember: client.NewClient(th.Server.Config().ServerRoot, ""), TeamMember: client.NewClient(th.Server.Config().ServerRoot, ""), Viewer: client.NewClient(th.Server.Config().ServerRoot, ""), Commenter: client.NewClient(th.Server.Config().ServerRoot, ""), Editor: client.NewClient(th.Server.Config().ServerRoot, ""), Admin: client.NewClient(th.Server.Config().ServerRoot, ""), Guest: nil, } // get token team, resp := th.Client.GetTeam(model.GlobalTeamID) th.CheckOK(resp) require.NotNil(th.T, team) require.NotNil(th.T, team.SignupToken) th.RegisterAndLogin(clients.NoTeamMember, userNoTeamMember, userNoTeamMember+"@sample.com", password, team.SignupToken) userNoTeamMemberID = clients.NoTeamMember.GetUserID() th.RegisterAndLogin(clients.TeamMember, userTeamMember, userTeamMember+"@sample.com", password, team.SignupToken) userTeamMemberID = clients.TeamMember.GetUserID() th.RegisterAndLogin(clients.Viewer, userViewer, userViewer+"@sample.com", password, team.SignupToken) userViewerID = clients.Viewer.GetUserID() th.RegisterAndLogin(clients.Commenter, userCommenter, userCommenter+"@sample.com", password, team.SignupToken) userCommenterID = clients.Commenter.GetUserID() th.RegisterAndLogin(clients.Editor, userEditor, userEditor+"@sample.com", password, team.SignupToken) userEditorID = clients.Editor.GetUserID() th.RegisterAndLogin(clients.Admin, userAdmin, userAdmin+"@sample.com", password, team.SignupToken) userAdminID = clients.Admin.GetUserID() return clients } func toJSON(t *testing.T, obj interface{}) string { result, err := json.Marshal(obj) require.NoError(t, err) return string(result) } type TestData struct { publicBoard *model.Board privateBoard *model.Board publicTemplate *model.Board privateTemplate *model.Board } func setupData(t *testing.T, th *TestHelper) TestData { customTemplate1, err := th.Server.App().CreateBoard( &model.Board{Title: "Custom template 1", TeamID: "test-team", IsTemplate: true, Type: model.BoardTypeOpen, MinimumRole: "viewer"}, userAdminID, true, ) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-1", Title: "Test", Type: "card", BoardID: customTemplate1.ID, Fields: map[string]interface{}{}}, userAdminID) require.NoError(t, err) customTemplate2, err := th.Server.App().CreateBoard( &model.Board{Title: "Custom template 2", TeamID: "test-team", IsTemplate: true, Type: model.BoardTypePrivate, MinimumRole: "viewer"}, userAdminID, true) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-2", Title: "Test", Type: "card", BoardID: customTemplate2.ID, Fields: map[string]interface{}{}}, userAdminID) require.NoError(t, err) board1, err := th.Server.App().CreateBoard(&model.Board{Title: "Board 1", TeamID: "test-team", Type: model.BoardTypeOpen, MinimumRole: "viewer"}, userAdminID, true) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-3", Title: "Test", Type: "card", BoardID: board1.ID, Fields: map[string]interface{}{}}, userAdminID) require.NoError(t, err) board2, err := th.Server.App().CreateBoard(&model.Board{Title: "Board 2", TeamID: "test-team", Type: model.BoardTypePrivate, MinimumRole: "viewer"}, userAdminID, true) require.NoError(t, err) rBoard2, err := th.Server.App().GetBoard(board2.ID) require.NoError(t, err) require.NotNil(t, rBoard2) require.Equal(t, rBoard2, board2) boardMember, err := th.Server.App().GetMemberForBoard(board2.ID, userAdminID) require.NoError(t, err) require.NotNil(t, boardMember) require.Equal(t, boardMember.UserID, userAdminID) require.Equal(t, boardMember.BoardID, board2.ID) err = th.Server.App().InsertBlock(&model.Block{ID: "block-4", Title: "Test", Type: "card", BoardID: board2.ID, Fields: map[string]interface{}{}}, userAdminID) require.NoError(t, err) err = th.Server.App().UpsertSharing(model.Sharing{ID: board2.ID, Enabled: true, Token: "valid", ModifiedBy: userAdminID, UpdateAt: model.GetMillis()}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate1.ID, UserID: userViewerID, SchemeViewer: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate2.ID, UserID: userViewerID, SchemeViewer: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate1.ID, UserID: userCommenterID, SchemeCommenter: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate2.ID, UserID: userCommenterID, SchemeCommenter: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate1.ID, UserID: userEditorID, SchemeEditor: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate2.ID, UserID: userEditorID, SchemeEditor: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate1.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: customTemplate2.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board1.ID, UserID: userViewerID, SchemeViewer: true}) require.NoError(t, err) boardMember, err = th.Server.App().GetMemberForBoard(board1.ID, userViewerID) require.NoError(t, err) require.NotNil(t, boardMember) require.Equal(t, boardMember.UserID, userViewerID) require.Equal(t, boardMember.BoardID, board1.ID) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board2.ID, UserID: userViewerID, SchemeViewer: true}) require.NoError(t, err) boardMember, err = th.Server.App().GetMemberForBoard(board2.ID, userViewerID) require.NoError(t, err) require.NotNil(t, boardMember) require.Equal(t, boardMember.UserID, userViewerID) require.Equal(t, boardMember.BoardID, board2.ID) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board1.ID, UserID: userCommenterID, SchemeCommenter: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board2.ID, UserID: userCommenterID, SchemeCommenter: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board1.ID, UserID: userEditorID, SchemeEditor: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board2.ID, UserID: userEditorID, SchemeEditor: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board1.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board2.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: board2.ID, UserID: userGuestID, SchemeViewer: true}) require.NoError(t, err) return TestData{ publicBoard: board1, privateBoard: board2, publicTemplate: customTemplate1, privateTemplate: customTemplate2, } } func runTestCases(t *testing.T, ttCases []TestCase, testData TestData, clients Clients) { for _, tc := range ttCases { t.Run(tc.userRole+": "+tc.method+" "+tc.url, func(t *testing.T) { reqClient := clients.Anon switch tc.userRole { case userAnon: reqClient = clients.Anon case userNoTeamMember: reqClient = clients.NoTeamMember case userTeamMember: reqClient = clients.TeamMember case userViewer: reqClient = clients.Viewer case userCommenter: reqClient = clients.Commenter case userEditor: reqClient = clients.Editor case userAdmin: reqClient = clients.Admin case userGuest: if clients.Guest == nil { return } reqClient = clients.Guest } url := strings.ReplaceAll(tc.url, "{PRIVATE_BOARD_ID}", testData.privateBoard.ID) url = strings.ReplaceAll(url, "{PUBLIC_BOARD_ID}", testData.publicBoard.ID) url = strings.ReplaceAll(url, "{PUBLIC_TEMPLATE_ID}", testData.publicTemplate.ID) url = strings.ReplaceAll(url, "{PRIVATE_TEMPLATE_ID}", testData.privateTemplate.ID) url = strings.ReplaceAll(url, "{USER_ANON_ID}", userAnonID) url = strings.ReplaceAll(url, "{USER_NO_TEAM_MEMBER_ID}", userNoTeamMemberID) url = strings.ReplaceAll(url, "{USER_TEAM_MEMBER_ID}", userTeamMemberID) url = strings.ReplaceAll(url, "{USER_VIEWER_ID}", userViewerID) url = strings.ReplaceAll(url, "{USER_COMMENTER_ID}", userCommenterID) url = strings.ReplaceAll(url, "{USER_EDITOR_ID}", userEditorID) url = strings.ReplaceAll(url, "{USER_ADMIN_ID}", userAdminID) url = strings.ReplaceAll(url, "{USER_GUEST_ID}", userGuestID) if strings.Contains(url, "{") || strings.Contains(url, "}") { require.Fail(t, "Unreplaced tokens in url", url, tc.identifier()) } var response *http.Response var err error switch tc.method { case methodGet: response, err = reqClient.DoAPIGet(url, "") defer response.Body.Close() case methodPost: response, err = reqClient.DoAPIPost(url, tc.body) defer response.Body.Close() case methodPatch: response, err = reqClient.DoAPIPatch(url, tc.body) defer response.Body.Close() case methodPut: response, err = reqClient.DoAPIPut(url, tc.body) defer response.Body.Close() case methodDelete: response, err = reqClient.DoAPIDelete(url, tc.body) defer response.Body.Close() } require.Equal(t, tc.expectedStatusCode, response.StatusCode, tc.identifier()) if tc.expectedStatusCode >= 200 && tc.expectedStatusCode < 300 { require.NoError(t, err, tc.identifier()) } if tc.expectedStatusCode >= 200 && tc.expectedStatusCode < 300 { body, err := io.ReadAll(response.Body) if err != nil { require.Fail(t, err.Error(), tc.identifier()) } if strings.HasPrefix(string(body), "[") { var data []interface{} err = json.Unmarshal(body, &data) if err != nil { require.Fail(t, err.Error(), tc.identifier()) } require.Len(t, data, tc.totalResults, tc.identifier()) } else { if tc.totalResults > 0 { require.Equal(t, 1, tc.totalResults) require.Greater(t, len(string(body)), 2, tc.identifier()) } else { require.Len(t, string(body), 2, tc.identifier()) } } } }) } } func TestPermissionsGetTeamBoards(t *testing.T) { ttCases := []TestCase{ {"/teams/test-team/boards", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/boards", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/boards", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/boards", methodGet, "", userViewer, http.StatusOK, 2}, {"/teams/test-team/boards", methodGet, "", userCommenter, http.StatusOK, 2}, {"/teams/test-team/boards", methodGet, "", userEditor, http.StatusOK, 2}, {"/teams/test-team/boards", methodGet, "", userAdmin, http.StatusOK, 2}, {"/teams/test-team/boards", methodGet, "", userGuest, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsSearchTeamBoards(t *testing.T) { ttCases := []TestCase{ // Search boards {"/teams/test-team/boards/search?q=b", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/boards/search?q=b", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/boards/search?q=b", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/boards/search?q=b", methodGet, "", userViewer, http.StatusOK, 2}, {"/teams/test-team/boards/search?q=b", methodGet, "", userCommenter, http.StatusOK, 2}, {"/teams/test-team/boards/search?q=b", methodGet, "", userEditor, http.StatusOK, 2}, {"/teams/test-team/boards/search?q=b", methodGet, "", userAdmin, http.StatusOK, 2}, {"/teams/test-team/boards/search?q=b", methodGet, "", userGuest, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsSearchTeamLinkableBoards(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ // Search boards {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userTeamMember, http.StatusOK, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userViewer, http.StatusOK, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userCommenter, http.StatusOK, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userEditor, http.StatusOK, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userAdmin, http.StatusOK, 2}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ // Search boards {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userNoTeamMember, http.StatusNotImplemented, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userTeamMember, http.StatusNotImplemented, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userViewer, http.StatusNotImplemented, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userCommenter, http.StatusNotImplemented, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userEditor, http.StatusNotImplemented, 0}, {"/teams/test-team/boards/search/linkable?q=b", methodGet, "", userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetTeamTemplates(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper) { err := th.Server.App().InitTemplates() require.NoError(t, err, "InitTemplates should succeed") } builtInTemplateCount := 13 ttCases := []TestCase{ // Get Team Boards {"/teams/test-team/templates", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/templates", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/templates", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/templates", methodGet, "", userViewer, http.StatusOK, 2}, {"/teams/test-team/templates", methodGet, "", userCommenter, http.StatusOK, 2}, {"/teams/test-team/templates", methodGet, "", userEditor, http.StatusOK, 2}, {"/teams/test-team/templates", methodGet, "", userAdmin, http.StatusOK, 2}, {"/teams/test-team/templates", methodGet, "", userGuest, http.StatusForbidden, 0}, // Built-in templates {"/teams/0/templates", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/0/templates", methodGet, "", userNoTeamMember, http.StatusOK, builtInTemplateCount}, {"/teams/0/templates", methodGet, "", userTeamMember, http.StatusOK, builtInTemplateCount}, {"/teams/0/templates", methodGet, "", userViewer, http.StatusOK, builtInTemplateCount}, {"/teams/0/templates", methodGet, "", userCommenter, http.StatusOK, builtInTemplateCount}, {"/teams/0/templates", methodGet, "", userEditor, http.StatusOK, builtInTemplateCount}, {"/teams/0/templates", methodGet, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsCreateBoard(t *testing.T) { publicBoard := toJSON(t, model.Board{Title: "Board To Create", TeamID: "test-team", Type: model.BoardTypeOpen}) privateBoard := toJSON(t, model.Board{Title: "Board To Create", TeamID: "test-team", Type: model.BoardTypeOpen}) ttCases := []TestCase{ // Create Public boards {"/boards", methodPost, publicBoard, userAnon, http.StatusUnauthorized, 0}, {"/boards", methodPost, publicBoard, userNoTeamMember, http.StatusForbidden, 0}, {"/boards", methodPost, publicBoard, userGuest, http.StatusForbidden, 0}, {"/boards", methodPost, publicBoard, userTeamMember, http.StatusOK, 1}, // Create private boards {"/boards", methodPost, privateBoard, userAnon, http.StatusUnauthorized, 0}, {"/boards", methodPost, privateBoard, userNoTeamMember, http.StatusForbidden, 0}, {"/boards", methodPost, privateBoard, userGuest, http.StatusForbidden, 0}, {"/boards", methodPost, privateBoard, userTeamMember, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 ttCases[5].expectedStatusCode = http.StatusOK ttCases[5].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetBoard(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}", methodGet, "", userGuest, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}?read_token=invalid", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}?read_token=valid", methodGet, "", userAnon, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}?read_token=invalid", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}?read_token=valid", methodGet, "", userTeamMember, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[9].expectedStatusCode = http.StatusOK ttCases[9].totalResults = 1 ttCases[25].expectedStatusCode = http.StatusOK ttCases[25].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetBoardPublic(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}?read_token=invalid", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}?read_token=valid", methodGet, "", userAnon, http.StatusUnauthorized, 1}, {"/boards/{PRIVATE_BOARD_ID}?read_token=invalid", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}?read_token=valid", methodGet, "", userTeamMember, http.StatusForbidden, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() cfg := th.Server.Config() cfg.EnablePublicSharedBoards = false th.Server.UpdateAppConfig() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() cfg := th.Server.Config() cfg.EnablePublicSharedBoards = false th.Server.UpdateAppConfig() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsPatchBoard(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"title\": \"test\"}", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"title\": \"test\"}", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsPatchBoardType(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, "{\"type\": \"P\"}", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, "{\"type\": \"P\"}", userAdmin, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsPatchBoardMinimumRole(t *testing.T) { patch := toJSON(t, map[string]model.BoardRole{"minimumRole": model.BoardRoleViewer}) ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsPatchBoardChannelId(t *testing.T) { patch := toJSON(t, map[string]string{"channelId": "valid-channel-id"}) ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDeleteBoard(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDuplicateBoard(t *testing.T) { // In same team ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userTeamMember, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin-same-team", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local-same-team", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[25].expectedStatusCode = http.StatusOK ttCases[25].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) // In other team ttCases = []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=other-team", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userTeamMember, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=other-team", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin-other-team", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local-other-team", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[25].expectedStatusCode = http.StatusOK ttCases[25].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) // In empty team ttCases = []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userAdmin, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userAdmin, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/duplicate?toTeam=empty-team", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userAdmin, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userAdmin, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/duplicate?toTeam=empty-team", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin-empty-team", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetBoardBlocks(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodGet, "", userGuest, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks?read_token=invalid", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks?read_token=valid", methodGet, "", userAnon, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks?read_token=invalid", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks?read_token=valid", methodGet, "", userTeamMember, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[25].expectedStatusCode = http.StatusOK ttCases[25].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsCreateBoardBlocks(t *testing.T) { ttCasesF := func(testData TestData) []TestCase { counter := 0 newBlockJSON := func(boardID string) string { counter++ return toJSON(t, []*model.Block{{ ID: fmt.Sprintf("%d", counter), Title: "Board To Create", BoardID: boardID, Type: "card", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }}) } return []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userGuest, http.StatusForbidden, 0}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsCreateBoardComments(t *testing.T) { ttCasesF := func(testData TestData) []TestCase { counter := 0 newBlockJSON := func(boardID string) string { counter++ return toJSON(t, []*model.Block{{ ID: fmt.Sprintf("%d", counter), Title: "Comment to create", BoardID: boardID, Type: model.TypeComment, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }}) } return []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAdmin, http.StatusOK, 1}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsPatchBoardBlocks(t *testing.T) { newBlocksPatchJSON := func(blockID string) string { newTitle := "New Patch Block Title" return toJSON(t, model.BlockPatchBatch{ BlockIDs: []string{blockID}, BlockPatches: []model.BlockPatch{ {Title: &newTitle}, }, }) } ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-4"), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPatch, newBlocksPatchJSON("block-3"), userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-2"), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPatch, newBlocksPatchJSON("block-1"), userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsPatchBoardBlock(t *testing.T) { newTitle := "New Patch Title" patchJSON := toJSON(t, model.BlockPatch{Title: &newTitle}) ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodPatch, patchJSON, userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodPatch, patchJSON, userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodPatch, patchJSON, userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodPatch, patchJSON, userGuest, http.StatusForbidden, 0}, // Invalid boardID/blockID combination {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-3", methodPatch, patchJSON, userAdmin, http.StatusNotFound, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDeleteBoardBlock(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper, testData TestData) { err := th.Server.App().InsertBlock(&model.Block{ID: "block-5", Title: "Test", Type: "card", BoardID: testData.publicTemplate.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-6", Title: "Test", Type: "card", BoardID: testData.privateTemplate.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-7", Title: "Test", Type: "card", BoardID: testData.publicBoard.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-8", Title: "Test", Type: "card", BoardID: testData.privateBoard.ID}, userAdmin) require.NoError(t, err) } ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodDelete, "", userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-8", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodDelete, "", userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-7", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodDelete, "", userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodDelete, "", userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1", methodDelete, "", userGuest, http.StatusForbidden, 0}, // Invalid boardID/blockID combination {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-3", methodDelete, "", userAdmin, http.StatusNotFound, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUndeleteBoardBlock(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper, testData TestData) { err := th.Server.App().InsertBlock(&model.Block{ID: "block-5", Title: "Test", Type: "card", BoardID: testData.publicTemplate.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-6", Title: "Test", Type: "card", BoardID: testData.privateTemplate.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-7", Title: "Test", Type: "card", BoardID: testData.publicBoard.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-8", Title: "Test", Type: "card", BoardID: testData.privateBoard.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-1", userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-2", userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-3", userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-4", userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-5", userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-6", userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-7", userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBlock("block-8", userAdmin) require.NoError(t, err) } ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/undelete", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/undelete", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/undelete", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/undelete", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/undelete", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/undelete", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/undelete", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/undelete", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, // Invalid boardID/blockID combination {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-3/undelete", methodPost, "", userAdmin, http.StatusNotFound, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsMoveContentBlock(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper, testData TestData) { err := th.Server.App().InsertBlock(&model.Block{ID: "content-1-1", Title: "Test", Type: "text", BoardID: testData.publicTemplate.ID, ParentID: "block-1"}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "content-1-2", Title: "Test", Type: "text", BoardID: testData.publicTemplate.ID, ParentID: "block-1"}, userAdmin) require.NoError(t, err) _, err = th.Server.App().PatchBlock("block-1", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-1-1", "content-1-2"}}}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "content-2-1", Title: "Test", Type: "text", BoardID: testData.privateTemplate.ID, ParentID: "block-2"}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "content-2-2", Title: "Test", Type: "text", BoardID: testData.privateTemplate.ID, ParentID: "block-2"}, userAdmin) require.NoError(t, err) _, err = th.Server.App().PatchBlock("block-2", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-2-1", "content-2-2"}}}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "content-3-1", Title: "Test", Type: "text", BoardID: testData.publicBoard.ID, ParentID: "block-3"}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "content-3-2", Title: "Test", Type: "text", BoardID: testData.publicBoard.ID, ParentID: "block-3"}, userAdmin) require.NoError(t, err) _, err = th.Server.App().PatchBlock("block-3", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-3-1", "content-3-2"}}}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "content-4-1", Title: "Test", Type: "text", BoardID: testData.privateBoard.ID, ParentID: "block-4"}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "content-4-2", Title: "Test", Type: "text", BoardID: testData.privateBoard.ID, ParentID: "block-4"}, userAdmin) require.NoError(t, err) _, err = th.Server.App().PatchBlock("block-4", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-4-1", "content-4-2"}}}, userAdmin) require.NoError(t, err) } ttCases := []TestCase{ {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0}, {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userViewer, http.StatusForbidden, 0}, {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0}, {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userEditor, http.StatusOK, 0}, {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userAdmin, http.StatusOK, 0}, {"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userGuest, http.StatusForbidden, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userViewer, http.StatusForbidden, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userEditor, http.StatusOK, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userAdmin, http.StatusOK, 0}, {"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userGuest, http.StatusForbidden, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userViewer, http.StatusForbidden, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userEditor, http.StatusOK, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userAdmin, http.StatusOK, 0}, {"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userGuest, http.StatusForbidden, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userViewer, http.StatusForbidden, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userEditor, http.StatusOK, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userAdmin, http.StatusOK, 0}, {"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userGuest, http.StatusForbidden, 0}, // Invalid srcBlockID/dstBlockID combination {"/content-blocks/content-1-1/moveto/after/content-2-1", methodPost, "{}", userAdmin, http.StatusBadRequest, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUndeleteBoard(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper, testData TestData) { err := th.Server.App().DeleteBoard(testData.publicBoard.ID, userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBoard(testData.privateBoard.ID, userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBoard(testData.publicTemplate.ID, userAdmin) require.NoError(t, err) err = th.Server.App().DeleteBoard(testData.privateTemplate.ID, userAdmin) require.NoError(t, err) } ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDuplicateBoardBlock(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper, testData TestData) { err := th.Server.App().InsertBlock(&model.Block{ID: "block-5", Title: "Test", Type: "card", BoardID: testData.publicTemplate.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-6", Title: "Test", Type: "card", BoardID: testData.privateTemplate.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-7", Title: "Test", Type: "card", BoardID: testData.publicBoard.ID}, userAdmin) require.NoError(t, err) err = th.Server.App().InsertBlock(&model.Block{ID: "block-8", Title: "Test", Type: "card", BoardID: testData.privateBoard.ID}, userAdmin) require.NoError(t, err) } ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userGuest, http.StatusForbidden, 0}, // Invalid boardID/blockID combination {"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-3/duplicate", methodPost, "", userAdmin, http.StatusNotFound, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetBoardMembers(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userViewer, http.StatusOK, 5}, {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userCommenter, http.StatusOK, 5}, {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userEditor, http.StatusOK, 5}, {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userAdmin, http.StatusOK, 5}, {"/boards/{PRIVATE_BOARD_ID}/members", methodGet, "", userGuest, http.StatusOK, 5}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userViewer, http.StatusOK, 4}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userCommenter, http.StatusOK, 4}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userEditor, http.StatusOK, 4}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userAdmin, http.StatusOK, 4}, {"/boards/{PUBLIC_BOARD_ID}/members", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userViewer, http.StatusOK, 4}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userCommenter, http.StatusOK, 4}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userEditor, http.StatusOK, 4}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userAdmin, http.StatusOK, 4}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userViewer, http.StatusOK, 4}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userCommenter, http.StatusOK, 4}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userEditor, http.StatusOK, 4}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userAdmin, http.StatusOK, 4}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodGet, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsCreateBoardMembers(t *testing.T) { ttCasesF := func(testData TestData) []TestCase { boardMemberJSON := func(boardID string) string { return toJSON(t, model.BoardMember{ BoardID: boardID, UserID: userTeamMemberID, SchemeEditor: true, }) } return []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/members", methodPost, boardMemberJSON(testData.privateBoard.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/members", methodPost, boardMemberJSON(testData.publicBoard.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.privateTemplate.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/members", methodPost, boardMemberJSON(testData.publicTemplate.ID), userGuest, http.StatusForbidden, 0}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUpdateBoardMember(t *testing.T) { ttCasesF := func(testData TestData) []TestCase { boardMemberJSON := func(boardID string) string { return toJSON(t, model.BoardMember{ BoardID: boardID, UserID: userTeamMember, SchemeEditor: false, SchemeViewer: true, }) } return []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateBoard.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicBoard.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.privateTemplate.ID), userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_VIEWER_ID}", methodPut, boardMemberJSON(testData.publicTemplate.ID), userGuest, http.StatusForbidden, 0}, // Invalid boardID/memberID combination {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodPut, "", userAdmin, http.StatusBadRequest, 0}, // Invalid boardID {"/boards/invalid/members/{USER_VIEWER_ID}", methodPut, "", userAdmin, http.StatusBadRequest, 0}, // Invalid memberID {"/boards/{PUBLIC_TEMPLATE_ID}/members/invalid", methodPut, "", userAdmin, http.StatusBadRequest, 0}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDeleteBoardMember(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper, testData TestData) { _, err := th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.publicBoard.ID, UserID: userTeamMemberID, SchemeViewer: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.privateBoard.ID, UserID: userTeamMemberID, SchemeViewer: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.publicTemplate.ID, UserID: userTeamMemberID, SchemeViewer: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.privateTemplate.ID, UserID: userTeamMemberID, SchemeViewer: true}) require.NoError(t, err) } ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userGuest, http.StatusForbidden, 0}, // Invalid boardID/memberID combination {"/boards/{PUBLIC_TEMPLATE_ID}/members/{USER_TEAM_MEMBER_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, // Invalid boardID {"/boards/invalid/members/{USER_VIEWER_ID}", methodDelete, "", userAdmin, http.StatusNotFound, 0}, // Invalid memberID {"/boards/{PUBLIC_TEMPLATE_ID}/members/invalid", methodDelete, "", userAdmin, http.StatusOK, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsJoinBoardAsMember(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userTeamMember, http.StatusForbidden, 0}, // Do we want to forbid already existing members to join to the board or simply return the current membership? {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userAdmin, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/join", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userTeamMember, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/join", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userAdmin, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/join", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userTeamMember, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/join", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[9].expectedStatusCode = http.StatusOK ttCases[9].totalResults = 1 ttCases[25].expectedStatusCode = http.StatusOK ttCases[25].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsLeaveBoardAsMember(t *testing.T) { extraSetup := func(t *testing.T, th *TestHelper, testData TestData) { _, err := th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.publicBoard.ID, UserID: "not-real-user", SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.privateBoard.ID, UserID: "not-real-user", SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.publicTemplate.ID, UserID: "not-real-user", SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.privateTemplate.ID, UserID: "not-real-user", SchemeAdmin: true}) require.NoError(t, err) } ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userViewer, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userCommenter, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userGuest, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userViewer, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userCommenter, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userViewer, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userCommenter, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userEditor, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userViewer, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userCommenter, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userEditor, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) // Last admin leave should fail extraSetup = func(t *testing.T, th *TestHelper, testData TestData) { _, err := th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.publicBoard.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.privateBoard.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.publicTemplate.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) _, err = th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.privateTemplate.ID, UserID: userAdminID, SchemeAdmin: true}) require.NoError(t, err) require.NoError(t, th.Server.App().DeleteBoardMember(testData.publicBoard.ID, "not-real-user")) require.NoError(t, th.Server.App().DeleteBoardMember(testData.privateBoard.ID, "not-real-user")) require.NoError(t, th.Server.App().DeleteBoardMember(testData.publicTemplate.ID, "not-real-user")) require.NoError(t, th.Server.App().DeleteBoardMember(testData.privateTemplate.ID, "not-real-user")) } ttCases = []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/leave", methodPost, "", userAdmin, http.StatusBadRequest, 0}, {"/boards/{PUBLIC_BOARD_ID}/leave", methodPost, "", userAdmin, http.StatusBadRequest, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/leave", methodPost, "", userAdmin, http.StatusBadRequest, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/leave", methodPost, "", userAdmin, http.StatusBadRequest, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsShareBoard(t *testing.T) { sharing := toJSON(t, model.Sharing{Enabled: true, Token: "test-token"}) ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodPost, sharing, userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodPost, sharing, userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userAdmin, http.StatusOK, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodPost, sharing, userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userAdmin, http.StatusOK, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodPost, sharing, userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetSharedBoardInfo(t *testing.T) { ttCases := []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/sharing", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/sharing", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/sharing", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/sharing", methodGet, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) clients.Admin.PostSharing(&model.Sharing{ID: testData.publicBoard.ID, Enabled: true, Token: "test-token"}) clients.Admin.PostSharing(&model.Sharing{ID: testData.privateBoard.ID, Enabled: true, Token: "test-token"}) clients.Admin.PostSharing(&model.Sharing{ID: testData.publicTemplate.ID, Enabled: true, Token: "test-token"}) clients.Admin.PostSharing(&model.Sharing{ID: testData.privateTemplate.ID, Enabled: true, Token: "test-token"}) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) clients.Admin.PostSharing(&model.Sharing{ID: testData.publicBoard.ID, Enabled: true, Token: "test-token"}) clients.Admin.PostSharing(&model.Sharing{ID: testData.privateBoard.ID, Enabled: true, Token: "test-token"}) clients.Admin.PostSharing(&model.Sharing{ID: testData.publicTemplate.ID, Enabled: true, Token: "test-token"}) clients.Admin.PostSharing(&model.Sharing{ID: testData.privateTemplate.ID, Enabled: true, Token: "test-token"}) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsListTeams(t *testing.T) { ttCases := []TestCase{ {"/teams", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams", methodGet, "", userNoTeamMember, http.StatusOK, 0}, {"/teams", methodGet, "", userTeamMember, http.StatusOK, 2}, {"/teams", methodGet, "", userViewer, http.StatusOK, 2}, {"/teams", methodGet, "", userCommenter, http.StatusOK, 2}, {"/teams", methodGet, "", userEditor, http.StatusOK, 2}, {"/teams", methodGet, "", userAdmin, http.StatusOK, 2}, {"/teams", methodGet, "", userGuest, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[1].expectedStatusCode = http.StatusOK for i := range ttCases { ttCases[i].totalResults = 1 } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetTeam(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userViewer, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userCommenter, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userEditor, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userAdmin, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userGuest, http.StatusOK, 1}, {"/teams/empty-team", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/empty-team", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/empty-team", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/teams/empty-team", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/teams/empty-team", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/teams/empty-team", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/teams/empty-team", methodGet, "", userAdmin, http.StatusForbidden, 0}, {"/teams/empty-team", methodGet, "", userGuest, http.StatusForbidden, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team", methodGet, "", userNoTeamMember, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userViewer, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userCommenter, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userEditor, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userAdmin, http.StatusOK, 1}, {"/teams/test-team", methodGet, "", userGuest, http.StatusOK, 1}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsRegenerateSignupToken(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/regenerate_signup_token", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/regenerate_signup_token", methodPost, "", userAdmin, http.StatusNotImplemented, 0}, {"/teams/empty-team/regenerate_signup_token", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/empty-team/regenerate_signup_token", methodPost, "", userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/regenerate_signup_token", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/regenerate_signup_token", methodPost, "", userAdmin, http.StatusOK, 0}, {"/teams/empty-team/regenerate_signup_token", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/empty-team/regenerate_signup_token", methodPost, "", userAdmin, http.StatusOK, 0}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetTeamUsers(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/users", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/users", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/users", methodGet, "", userTeamMember, http.StatusOK, 6}, {"/teams/test-team/users", methodGet, "", userViewer, http.StatusOK, 6}, {"/teams/test-team/users", methodGet, "", userCommenter, http.StatusOK, 6}, {"/teams/test-team/users", methodGet, "", userEditor, http.StatusOK, 6}, {"/teams/test-team/users", methodGet, "", userAdmin, http.StatusOK, 6}, {"/teams/test-team/users", methodGet, "", userGuest, http.StatusOK, 5}, {"/teams/empty-team/users", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/empty-team/users", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/empty-team/users", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/teams/empty-team/users", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/teams/empty-team/users", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/teams/empty-team/users", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/teams/empty-team/users", methodGet, "", userAdmin, http.StatusForbidden, 0}, {"/teams/empty-team/users", methodGet, "", userGuest, http.StatusForbidden, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/users", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/users", methodGet, "", userNoTeamMember, http.StatusOK, 7}, {"/teams/test-team/users", methodGet, "", userTeamMember, http.StatusOK, 7}, {"/teams/test-team/users", methodGet, "", userViewer, http.StatusOK, 7}, {"/teams/test-team/users", methodGet, "", userCommenter, http.StatusOK, 7}, {"/teams/test-team/users", methodGet, "", userEditor, http.StatusOK, 7}, {"/teams/test-team/users", methodGet, "", userAdmin, http.StatusOK, 7}, {"/teams/test-team/users", methodGet, "", userGuest, http.StatusOK, 7}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsTeamArchiveExport(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/archive/export", methodGet, "", userAdmin, http.StatusNotImplemented, 0}, {"/teams/empty-team/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/empty-team/archive/export", methodGet, "", userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/archive/export", methodGet, "", userAdmin, http.StatusOK, 1}, {"/teams/empty-team/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/empty-team/archive/export", methodGet, "", userAdmin, http.StatusOK, 1}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUploadFile(t *testing.T) { ttCases := []TestCase{ {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userEditor, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userAdmin, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PRIVATE_BOARD_ID}/files", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userEditor, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userAdmin, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PUBLIC_BOARD_ID}/files", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userEditor, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userAdmin, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PRIVATE_TEMPLATE_ID}/files", methodPost, "", userGuest, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userViewer, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userCommenter, http.StatusForbidden, 0}, {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userEditor, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userAdmin, http.StatusBadRequest, 1}, // Not checking the logic, only the permissions {"/teams/test-team/{PUBLIC_TEMPLATE_ID}/files", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetMe(t *testing.T) { ttCases := []TestCase{ {"/users/me", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/users/me", methodGet, "", userNoTeamMember, http.StatusOK, 1}, {"/users/me", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/users/me", methodGet, "", userViewer, http.StatusOK, 1}, {"/users/me", methodGet, "", userCommenter, http.StatusOK, 1}, {"/users/me", methodGet, "", userEditor, http.StatusOK, 1}, {"/users/me", methodGet, "", userAdmin, http.StatusOK, 1}, {"/users/me", methodGet, "", userGuest, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetMyMemberships(t *testing.T) { ttCases := []TestCase{ {"/users/me/memberships", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/users/me/memberships", methodGet, "", userNoTeamMember, http.StatusOK, 0}, {"/users/me/memberships", methodGet, "", userTeamMember, http.StatusOK, 0}, {"/users/me/memberships", methodGet, "", userViewer, http.StatusOK, 4}, {"/users/me/memberships", methodGet, "", userCommenter, http.StatusOK, 4}, {"/users/me/memberships", methodGet, "", userEditor, http.StatusOK, 4}, {"/users/me/memberships", methodGet, "", userAdmin, http.StatusOK, 4}, {"/users/me/memberships", methodGet, "", userGuest, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetUser(t *testing.T) { ttCases := []TestCase{ {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userNoTeamMember, http.StatusOK, 1}, {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/users/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userGuest, http.StatusNotFound, 0}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userNoTeamMember, http.StatusOK, 1}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/users/{USER_TEAM_MEMBER_ID}", methodGet, "", userGuest, http.StatusNotFound, 0}, {"/users/{USER_VIEWER_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/users/{USER_VIEWER_ID}", methodGet, "", userNoTeamMember, http.StatusOK, 1}, {"/users/{USER_VIEWER_ID}", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/users/{USER_VIEWER_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/users/{USER_VIEWER_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/users/{USER_VIEWER_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/users/{USER_VIEWER_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/users/{USER_VIEWER_ID}", methodGet, "", userGuest, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUserChangePassword(t *testing.T) { postBody := toJSON(t, model.ChangePasswordRequest{ OldPassword: password, NewPassword: "newpa$$word123", }) t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/users/{USER_ADMIN_ID}/changepassword", methodPost, postBody, userAnon, http.StatusUnauthorized, 0}, {"/users/{USER_ADMIN_ID}/changepassword", methodPost, postBody, userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/users/{USER_ADMIN_ID}/changepassword", methodPost, postBody, userAnon, http.StatusUnauthorized, 0}, {"/users/{USER_ADMIN_ID}/changepassword", methodPost, postBody, userAdmin, http.StatusOK, 0}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUpdateUserConfig(t *testing.T) { patch := toJSON(t, model.UserPreferencesPatch{UpdatedFields: map[string]string{"test": "test"}}) ttCases := []TestCase{ {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userAnon, http.StatusUnauthorized, 0}, {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userNoTeamMember, http.StatusForbidden, 0}, {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userTeamMember, http.StatusOK, 1}, {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userViewer, http.StatusForbidden, 0}, {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userCommenter, http.StatusForbidden, 0}, {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userEditor, http.StatusForbidden, 0}, {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userAdmin, http.StatusForbidden, 0}, {"/users/{USER_TEAM_MEMBER_ID}/config", methodPut, patch, userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsCreateBoardsAndBlocks(t *testing.T) { bab := toJSON(t, model.BoardsAndBlocks{ Boards: []*model.Board{{ID: "test", Title: "Test Board", TeamID: "test-team"}}, Blocks: []*model.Block{ {ID: "test-block", BoardID: "test", Type: "card", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, }, }) ttCases := []TestCase{ {"/boards-and-blocks", methodPost, bab, userAnon, http.StatusUnauthorized, 0}, {"/boards-and-blocks", methodPost, bab, userNoTeamMember, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPost, bab, userTeamMember, http.StatusOK, 1}, {"/boards-and-blocks", methodPost, bab, userViewer, http.StatusOK, 1}, {"/boards-and-blocks", methodPost, bab, userCommenter, http.StatusOK, 1}, {"/boards-and-blocks", methodPost, bab, userEditor, http.StatusOK, 1}, {"/boards-and-blocks", methodPost, bab, userAdmin, http.StatusOK, 1}, {"/boards-and-blocks", methodPost, bab, userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUpdateBoardsAndBlocks(t *testing.T) { ttCasesF := func(t *testing.T, testData TestData) []TestCase { newTitle := "New Block Title" bab := toJSON(t, model.PatchBoardsAndBlocks{ BoardIDs: []string{testData.publicBoard.ID}, BoardPatches: []*model.BoardPatch{{Title: &newTitle}}, BlockIDs: []string{"block-3"}, BlockPatches: []*model.BlockPatch{{Title: &newTitle}}, }) return []TestCase{ {"/boards-and-blocks", methodPatch, bab, userAnon, http.StatusUnauthorized, 0}, {"/boards-and-blocks", methodPatch, bab, userNoTeamMember, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userTeamMember, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userViewer, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userCommenter, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userEditor, http.StatusOK, 1}, {"/boards-and-blocks", methodPatch, bab, userAdmin, http.StatusOK, 1}, {"/boards-and-blocks", methodPatch, bab, userGuest, http.StatusForbidden, 0}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, testData) runTestCases(t, ttCases, testData, clients) }) // With type change ttCasesF = func(t *testing.T, testData TestData) []TestCase { newType := model.BoardTypePrivate newTitle := "New Block Title" bab := toJSON(t, model.PatchBoardsAndBlocks{ BoardIDs: []string{testData.publicBoard.ID}, BoardPatches: []*model.BoardPatch{{Type: &newType}}, BlockIDs: []string{"block-3"}, BlockPatches: []*model.BlockPatch{{Title: &newTitle}}, }) return []TestCase{ {"/boards-and-blocks", methodPatch, bab, userAnon, http.StatusUnauthorized, 0}, {"/boards-and-blocks", methodPatch, bab, userNoTeamMember, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userTeamMember, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userViewer, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userCommenter, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userEditor, http.StatusForbidden, 0}, {"/boards-and-blocks", methodPatch, bab, userAdmin, http.StatusOK, 1}, {"/boards-and-blocks", methodPatch, bab, userGuest, http.StatusForbidden, 0}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, testData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDeleteBoardsAndBlocks(t *testing.T) { ttCasesF := func(t *testing.T, testData TestData) []TestCase { bab := toJSON(t, model.DeleteBoardsAndBlocks{ Boards: []string{testData.publicBoard.ID}, Blocks: []string{"block-3"}, }) return []TestCase{ {"/boards-and-blocks", methodDelete, bab, userAnon, http.StatusUnauthorized, 0}, {"/boards-and-blocks", methodDelete, bab, userNoTeamMember, http.StatusForbidden, 0}, {"/boards-and-blocks", methodDelete, bab, userTeamMember, http.StatusForbidden, 0}, {"/boards-and-blocks", methodDelete, bab, userViewer, http.StatusForbidden, 0}, {"/boards-and-blocks", methodDelete, bab, userCommenter, http.StatusForbidden, 0}, {"/boards-and-blocks", methodDelete, bab, userEditor, http.StatusForbidden, 0}, {"/boards-and-blocks", methodDelete, bab, userGuest, http.StatusForbidden, 0}, {"/boards-and-blocks", methodDelete, bab, userAdmin, http.StatusOK, 0}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, testData) _, err := th.Server.App().AddMemberToBoard(&model.BoardMember{BoardID: testData.publicBoard.ID, UserID: userGuestID, SchemeViewer: true}) require.NoError(t, err) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, testData) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsLogin(t *testing.T) { loginReq := func(username, password string) string { return toJSON(t, model.LoginRequest{ Type: "normal", Username: username, Password: password, }) } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/login", methodPost, loginReq(userAnon, password), userAnon, http.StatusNotImplemented, 0}, {"/login", methodPost, loginReq(userAdmin, password), userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/login", methodPost, loginReq(userAnon, password), userAnon, http.StatusUnauthorized, 0}, {"/login", methodPost, loginReq(userAdmin, password), userAdmin, http.StatusOK, 1}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsLogout(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/logout", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/logout", methodPost, "", userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/logout", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/logout", methodPost, "", userAdmin, http.StatusOK, 0}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsRegister(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/register", methodPost, "", userAnon, http.StatusNotImplemented, 0}, {"/register", methodPost, "", userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) team, resp := th.Client.GetTeam(model.GlobalTeamID) th.CheckOK(resp) require.NotNil(th.T, team) require.NotNil(th.T, team.SignupToken) postData := toJSON(t, model.RegisterRequest{ Username: "newuser", Email: "newuser@test.com", Password: password, Token: team.SignupToken, }) ttCases := []TestCase{ {"/register", methodPost, postData, userAnon, http.StatusOK, 0}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsClientConfig(t *testing.T) { ttCases := []TestCase{ {"/clientConfig", methodGet, "", userAnon, http.StatusOK, 1}, {"/clientConfig", methodGet, "", userAdmin, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetCategories(t *testing.T) { ttCases := []TestCase{ {"/teams/test-team/categories", methodGet, "", userAnon, http.StatusUnauthorized, 1}, {"/teams/test-team/categories", methodGet, "", userNoTeamMember, http.StatusForbidden, 1}, {"/teams/test-team/categories", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/categories", methodGet, "", userViewer, http.StatusOK, 1}, {"/teams/test-team/categories", methodGet, "", userCommenter, http.StatusOK, 1}, {"/teams/test-team/categories", methodGet, "", userEditor, http.StatusOK, 1}, {"/teams/test-team/categories", methodGet, "", userAdmin, http.StatusOK, 1}, {"/teams/test-team/categories", methodGet, "", userGuest, http.StatusOK, 1}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsCreateCategory(t *testing.T) { ttCasesF := func() []TestCase { category := func(userID string) string { return toJSON(t, model.Category{ Name: "Test category", TeamID: "test-team", UserID: userID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }) } return []TestCase{ {"/teams/test-team/categories", methodPost, category(""), userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/categories", methodPost, category(userNoTeamMemberID), userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/categories", methodPost, category(userTeamMemberID), userTeamMember, http.StatusOK, 1}, {"/teams/test-team/categories", methodPost, category(userViewerID), userViewer, http.StatusOK, 1}, {"/teams/test-team/categories", methodPost, category(userCommenterID), userCommenter, http.StatusOK, 1}, {"/teams/test-team/categories", methodPost, category(userEditorID), userEditor, http.StatusOK, 1}, {"/teams/test-team/categories", methodPost, category(userAdminID), userAdmin, http.StatusOK, 1}, {"/teams/test-team/categories", methodPost, category(userGuestID), userGuest, http.StatusOK, 1}, {"/teams/test-team/categories", methodPost, category("other"), userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/categories", methodPost, category("other"), userNoTeamMember, http.StatusBadRequest, 0}, {"/teams/test-team/categories", methodPost, category("other"), userTeamMember, http.StatusBadRequest, 0}, {"/teams/test-team/categories", methodPost, category("other"), userViewer, http.StatusBadRequest, 0}, {"/teams/test-team/categories", methodPost, category("other"), userCommenter, http.StatusBadRequest, 0}, {"/teams/test-team/categories", methodPost, category("other"), userEditor, http.StatusBadRequest, 0}, {"/teams/test-team/categories", methodPost, category("other"), userAdmin, http.StatusBadRequest, 0}, {"/teams/test-team/categories", methodPost, category("other"), userGuest, http.StatusBadRequest, 0}, {"/teams/other-team/categories", methodPost, category(""), userAnon, http.StatusUnauthorized, 0}, {"/teams/other-team/categories", methodPost, category(userNoTeamMemberID), userNoTeamMember, http.StatusBadRequest, 0}, {"/teams/other-team/categories", methodPost, category(userTeamMemberID), userTeamMember, http.StatusBadRequest, 0}, {"/teams/other-team/categories", methodPost, category(userViewerID), userViewer, http.StatusBadRequest, 0}, {"/teams/other-team/categories", methodPost, category(userCommenterID), userCommenter, http.StatusBadRequest, 0}, {"/teams/other-team/categories", methodPost, category(userEditorID), userEditor, http.StatusBadRequest, 0}, {"/teams/other-team/categories", methodPost, category(userAdminID), userAdmin, http.StatusBadRequest, 0}, {"/teams/other-team/categories", methodPost, category(userGuestID), userGuest, http.StatusBadRequest, 0}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF() runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF() ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUpdateCategory(t *testing.T) { ttCasesF := func(extraData map[string]string) []TestCase { category := func(userID string, categoryID string) string { return toJSON(t, model.Category{ ID: categoryID, Name: "Test category", TeamID: "test-team", UserID: userID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), Type: "custom", }) } return []TestCase{ {"/teams/test-team/categories/any", methodPut, category("", "any"), userAnonID, http.StatusUnauthorized, 0}, {"/teams/test-team/categories/" + extraData["noTeamMember"], methodPut, category(userNoTeamMemberID, extraData["noTeamMember"]), userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/categories/" + extraData["teamMember"], methodPut, category(userTeamMemberID, extraData["teamMember"]), userTeamMember, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["viewer"], methodPut, category(userViewerID, extraData["viewer"]), userViewer, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["commenter"], methodPut, category(userCommenterID, extraData["commenter"]), userCommenter, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["editor"], methodPut, category(userEditorID, extraData["editor"]), userEditor, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["admin"], methodPut, category(userAdminID, extraData["admin"]), userAdmin, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["guest"], methodPut, category(userGuestID, extraData["guest"]), userGuest, http.StatusOK, 1}, {"/teams/test-team/categories/any", methodPut, category("other", "any"), userAnonID, http.StatusUnauthorized, 0}, {"/teams/test-team/categories/" + extraData["noTeamMember"], methodPut, category("other", extraData["noTeamMember"]), userNoTeamMember, http.StatusBadRequest, 0}, {"/teams/test-team/categories/" + extraData["teamMember"], methodPut, category("other", extraData["teamMember"]), userTeamMember, http.StatusBadRequest, 0}, {"/teams/test-team/categories/" + extraData["viewer"], methodPut, category("other", extraData["viewer"]), userViewer, http.StatusBadRequest, 0}, {"/teams/test-team/categories/" + extraData["commenter"], methodPut, category("other", extraData["commenter"]), userCommenter, http.StatusBadRequest, 0}, {"/teams/test-team/categories/" + extraData["editor"], methodPut, category("other", extraData["editor"]), userEditor, http.StatusBadRequest, 0}, {"/teams/test-team/categories/" + extraData["admin"], methodPut, category("other", extraData["admin"]), userAdmin, http.StatusBadRequest, 0}, {"/teams/test-team/categories/" + extraData["guest"], methodPut, category("other", extraData["guest"]), userGuest, http.StatusBadRequest, 0}, {"/teams/other-team/categories/any", methodPut, category("", "any"), userAnonID, http.StatusUnauthorized, 0}, {"/teams/other-team/categories/" + extraData["noTeamMember"], methodPut, category(userNoTeamMemberID, extraData["noTeamMember"]), userNoTeamMember, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["teamMember"], methodPut, category(userTeamMemberID, extraData["teamMember"]), userTeamMember, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["viewer"], methodPut, category(userViewerID, extraData["viewer"]), userViewer, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["commenter"], methodPut, category(userCommenterID, extraData["commenter"]), userCommenter, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["editor"], methodPut, category(userEditorID, extraData["editor"]), userEditor, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["admin"], methodPut, category(userAdminID, extraData["admin"]), userAdmin, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["guest"], methodPut, category(userGuestID, extraData["guest"]), userGuest, http.StatusBadRequest, 0}, } } extraSetup := func(t *testing.T, th *TestHelper) map[string]string { categoryNoTeamMember, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userNoTeamMemberID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryTeamMember, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userTeamMemberID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryViewer, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userViewerID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryCommenter, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userCommenterID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryEditor, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userEditorID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryAdmin, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userAdminID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryGuest, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userGuestID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) return map[string]string{ "noTeamMember": categoryNoTeamMember.ID, "teamMember": categoryTeamMember.ID, "viewer": categoryViewer.ID, "commenter": categoryCommenter.ID, "editor": categoryEditor.ID, "admin": categoryAdmin.ID, "guest": categoryGuest.ID, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraData := extraSetup(t, th) ttCases := ttCasesF(extraData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraData := extraSetup(t, th) ttCases := ttCasesF(extraData) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDeleteCategory(t *testing.T) { ttCasesF := func(extraData map[string]string) []TestCase { return []TestCase{ {"/teams/other-team/categories/any", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/other-team/categories/" + extraData["noTeamMember"], methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/other-team/categories/" + extraData["teamMember"], methodDelete, "", userTeamMember, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["viewer"], methodDelete, "", userViewer, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["commenter"], methodDelete, "", userCommenter, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["editor"], methodDelete, "", userEditor, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["admin"], methodDelete, "", userAdmin, http.StatusBadRequest, 0}, {"/teams/other-team/categories/" + extraData["guest"], methodDelete, "", userGuest, http.StatusBadRequest, 0}, {"/teams/test-team/categories/any", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/categories/" + extraData["noTeamMember"], methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/categories/" + extraData["teamMember"], methodDelete, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["viewer"], methodDelete, "", userViewer, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["commenter"], methodDelete, "", userCommenter, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["editor"], methodDelete, "", userEditor, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["admin"], methodDelete, "", userAdmin, http.StatusOK, 1}, {"/teams/test-team/categories/" + extraData["guest"], methodDelete, "", userGuest, http.StatusOK, 1}, } } extraSetup := func(t *testing.T, th *TestHelper) map[string]string { categoryNoTeamMember, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userNoTeamMemberID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryTeamMember, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userTeamMemberID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryViewer, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userViewerID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryCommenter, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userCommenterID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryEditor, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userEditorID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryAdmin, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userAdminID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryGuest, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userGuestID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) return map[string]string{ "noTeamMember": categoryNoTeamMember.ID, "teamMember": categoryTeamMember.ID, "viewer": categoryViewer.ID, "commenter": categoryCommenter.ID, "editor": categoryEditor.ID, "admin": categoryAdmin.ID, "guest": categoryGuest.ID, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraData := extraSetup(t, th) ttCases := ttCasesF(extraData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraData := extraSetup(t, th) ttCases := ttCasesF(extraData) ttCases[1].expectedStatusCode = http.StatusBadRequest ttCases[1].totalResults = 0 ttCases[9].expectedStatusCode = http.StatusOK ttCases[9].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsUpdateCategoryBoard(t *testing.T) { ttCasesF := func(testData TestData, extraData map[string]string) []TestCase { return []TestCase{ {"/teams/test-team/categories/any/boards/any", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/categories/" + extraData["noTeamMember"] + "/boards/" + testData.publicBoard.ID, methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/categories/" + extraData["teamMember"] + "/boards/" + testData.publicBoard.ID, methodPost, "", userTeamMember, http.StatusOK, 0}, {"/teams/test-team/categories/" + extraData["viewer"] + "/boards/" + testData.publicBoard.ID, methodPost, "", userViewer, http.StatusOK, 0}, {"/teams/test-team/categories/" + extraData["commenter"] + "/boards/" + testData.publicBoard.ID, methodPost, "", userCommenter, http.StatusOK, 0}, {"/teams/test-team/categories/" + extraData["editor"] + "/boards/" + testData.publicBoard.ID, methodPost, "", userEditor, http.StatusOK, 0}, {"/teams/test-team/categories/" + extraData["admin"] + "/boards/" + testData.publicBoard.ID, methodPost, "", userAdmin, http.StatusOK, 0}, {"/teams/test-team/categories/" + extraData["guest"] + "/boards/" + testData.publicBoard.ID, methodPost, "", userGuest, http.StatusOK, 0}, } } extraSetup := func(t *testing.T, th *TestHelper) map[string]string { categoryNoTeamMember, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userNoTeamMemberID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryTeamMember, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userTeamMemberID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryViewer, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userViewerID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryCommenter, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userCommenterID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryEditor, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userEditorID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryAdmin, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userAdminID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) categoryGuest, err := th.Server.App().CreateCategory( &model.Category{Name: "Test category", TeamID: "test-team", UserID: userGuestID, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis()}, ) require.NoError(t, err) return map[string]string{ "noTeamMember": categoryNoTeamMember.ID, "teamMember": categoryTeamMember.ID, "viewer": categoryViewer.ID, "commenter": categoryCommenter.ID, "editor": categoryEditor.ID, "admin": categoryAdmin.ID, "guest": categoryGuest.ID, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraData := extraSetup(t, th) ttCases := ttCasesF(testData, extraData) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraData := extraSetup(t, th) ttCases := ttCasesF(testData, extraData) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 0 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetFile(t *testing.T) { ttCasesF := func() []TestCase { return []TestCase{ {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userViewer, http.StatusOK, 1}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userCommenter, http.StatusOK, 1}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userEditor, http.StatusOK, 1}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userAdmin, http.StatusOK, 1}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}", methodGet, "", userGuest, http.StatusOK, 1}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}?read_token=invalid", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}?read_token=valid", methodGet, "", userAnon, http.StatusOK, 1}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}?read_token=invalid", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/files/teams/test-team/{PRIVATE_BOARD_ID}/{NEW_FILE_ID}?read_token=valid", methodGet, "", userTeamMember, http.StatusOK, 1}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png", false) require.NoError(t, err) ttCases := ttCasesF() for i, tc := range ttCases { ttCases[i].url = strings.Replace(tc.url, "{NEW_FILE_ID}", newFileID, 1) } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png", false) require.NoError(t, err) ttCases := ttCasesF() for i, tc := range ttCases { ttCases[i].url = strings.Replace(tc.url, "{NEW_FILE_ID}", newFileID, 1) } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsCreateSubscription(t *testing.T) { ttCases := func() []TestCase { subscription := func(userID string) string { return toJSON(t, model.Subscription{ BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userID, CreateAt: model.GetMillis(), }) } return []TestCase{ {"/subscriptions", methodPost, subscription(""), userAnon, http.StatusUnauthorized, 0}, {"/subscriptions", methodPost, subscription(userNoTeamMemberID), userNoTeamMember, http.StatusOK, 1}, {"/subscriptions", methodPost, subscription(userTeamMemberID), userTeamMember, http.StatusOK, 1}, {"/subscriptions", methodPost, subscription(userViewerID), userViewer, http.StatusOK, 1}, {"/subscriptions", methodPost, subscription(userCommenterID), userCommenter, http.StatusOK, 1}, {"/subscriptions", methodPost, subscription(userEditorID), userEditor, http.StatusOK, 1}, {"/subscriptions", methodPost, subscription(userAdminID), userAdmin, http.StatusOK, 1}, {"/subscriptions", methodPost, subscription(userGuestID), userGuest, http.StatusOK, 1}, } } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases(), testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases(), testData, clients) }) } func TestPermissionsGetSubscriptions(t *testing.T) { ttCases := []TestCase{ {"/subscriptions/{USER_ANON_ID}", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/subscriptions/{USER_NO_TEAM_MEMBER_ID}", methodGet, "", userNoTeamMember, http.StatusOK, 0}, {"/subscriptions/{USER_TEAM_MEMBER_ID}", methodGet, "", userTeamMember, http.StatusOK, 0}, {"/subscriptions/{USER_VIEWER_ID}", methodGet, "", userViewer, http.StatusOK, 0}, {"/subscriptions/{USER_COMMENTER_ID}", methodGet, "", userCommenter, http.StatusOK, 0}, {"/subscriptions/{USER_EDITOR_ID}", methodGet, "", userEditor, http.StatusOK, 0}, {"/subscriptions/{USER_ADMIN_ID}", methodGet, "", userAdmin, http.StatusOK, 0}, {"/subscriptions/{USER_GUEST_ID}", methodGet, "", userGuest, http.StatusOK, 0}, {"/subscriptions/other", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/subscriptions/other", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/subscriptions/other", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/subscriptions/other", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/subscriptions/other", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/subscriptions/other", methodGet, "", userAdmin, http.StatusForbidden, 0}, {"/subscriptions/other", methodGet, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsDeleteSubscription(t *testing.T) { ttCases := []TestCase{ {"/subscriptions/block-3/{USER_ANON_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0}, {"/subscriptions/block-3/{USER_NO_TEAM_MEMBER_ID}", methodDelete, "", userNoTeamMember, http.StatusOK, 0}, {"/subscriptions/block-3/{USER_TEAM_MEMBER_ID}", methodDelete, "", userTeamMember, http.StatusOK, 0}, {"/subscriptions/block-3/{USER_VIEWER_ID}", methodDelete, "", userViewer, http.StatusOK, 0}, {"/subscriptions/block-3/{USER_COMMENTER_ID}", methodDelete, "", userCommenter, http.StatusOK, 0}, {"/subscriptions/block-3/{USER_EDITOR_ID}", methodDelete, "", userEditor, http.StatusOK, 0}, {"/subscriptions/block-3/{USER_ADMIN_ID}", methodDelete, "", userAdmin, http.StatusOK, 0}, {"/subscriptions/block-3/{USER_GUEST_ID}", methodDelete, "", userGuest, http.StatusOK, 0}, {"/subscriptions/block-3/other", methodDelete, "", userNoTeamMember, http.StatusForbidden, 0}, {"/subscriptions/block-3/other", methodDelete, "", userTeamMember, http.StatusForbidden, 0}, {"/subscriptions/block-3/other", methodDelete, "", userViewer, http.StatusForbidden, 0}, {"/subscriptions/block-3/other", methodDelete, "", userCommenter, http.StatusForbidden, 0}, {"/subscriptions/block-3/other", methodDelete, "", userEditor, http.StatusForbidden, 0}, {"/subscriptions/block-3/other", methodDelete, "", userAdmin, http.StatusForbidden, 0}, {"/subscriptions/block-3/other", methodDelete, "", userGuest, http.StatusForbidden, 0}, } extraSetup := func(t *testing.T, th *TestHelper) { _, err := th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userNoTeamMemberID, CreateAt: model.GetMillis()}, ) require.NoError(t, err) _, err = th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userTeamMemberID, CreateAt: model.GetMillis()}, ) require.NoError(t, err) _, err = th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userViewerID, CreateAt: model.GetMillis()}, ) require.NoError(t, err) _, err = th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userCommenterID, CreateAt: model.GetMillis()}, ) require.NoError(t, err) _, err = th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userEditorID, CreateAt: model.GetMillis()}, ) require.NoError(t, err) _, err = th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userAdminID, CreateAt: model.GetMillis()}, ) require.NoError(t, err) _, err = th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: userGuestID, CreateAt: model.GetMillis()}, ) require.NoError(t, err) _, err = th.Server.App().CreateSubscription( &model.Subscription{BlockType: "card", BlockID: "block-3", SubscriberType: "user", SubscriberID: "other", CreateAt: model.GetMillis()}, ) require.NoError(t, err) } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) extraSetup(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) extraSetup(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsOnboard(t *testing.T) { ttCases := []TestCase{ {"/teams/test-team/onboard", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/onboard", methodPost, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/onboard", methodPost, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/onboard", methodPost, "", userViewer, http.StatusOK, 1}, {"/teams/test-team/onboard", methodPost, "", userCommenter, http.StatusOK, 1}, {"/teams/test-team/onboard", methodPost, "", userEditor, http.StatusOK, 1}, {"/teams/test-team/onboard", methodPost, "", userAdmin, http.StatusOK, 1}, {"/teams/test-team/onboard", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) err := th.Server.App().InitTemplates() require.NoError(t, err, "InitTemplates should not fail") runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) err := th.Server.App().InitTemplates() require.NoError(t, err, "InitTemplates should not fail") ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsBoardArchiveExport(t *testing.T) { ttCases := []TestCase{ {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/archive/export", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/archive/export", methodGet, "", userGuest, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/archive/export", methodGet, "", userGuest, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/archive/export", methodGet, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsBoardArchiveImport(t *testing.T) { ttCases := []TestCase{ {"/teams/test-team/archive/import", methodPost, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/archive/import", methodPost, "", userNoTeamMember, http.StatusForbidden, 1}, {"/teams/test-team/archive/import", methodPost, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/archive/import", methodPost, "", userViewer, http.StatusOK, 1}, {"/teams/test-team/archive/import", methodPost, "", userCommenter, http.StatusOK, 1}, {"/teams/test-team/archive/import", methodPost, "", userEditor, http.StatusOK, 1}, {"/teams/test-team/archive/import", methodPost, "", userAdmin, http.StatusOK, 1}, {"/teams/test-team/archive/import", methodPost, "", userGuest, http.StatusForbidden, 0}, } t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases[1].expectedStatusCode = http.StatusOK ttCases[1].totalResults = 1 runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsMinimumRolesApplied(t *testing.T) { ttCasesF := func(t *testing.T, th *TestHelper, minimumRole model.BoardRole, testData TestData) []TestCase { counter := 0 newBlockJSON := func(boardID string) string { counter++ return toJSON(t, []*model.Block{{ ID: fmt.Sprintf("%d", counter), Title: "Board To Create", BoardID: boardID, Type: "card", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }}) } _, err := th.Server.App().PatchBoard(&model.BoardPatch{MinimumRole: &minimumRole}, testData.privateBoard.ID, userAdminID) require.NoError(t, err) _, err = th.Server.App().PatchBoard(&model.BoardPatch{MinimumRole: &minimumRole}, testData.publicBoard.ID, userAdminID) require.NoError(t, err) _, err = th.Server.App().PatchBoard(&model.BoardPatch{MinimumRole: &minimumRole}, testData.privateTemplate.ID, userAdminID) require.NoError(t, err) _, err = th.Server.App().PatchBoard(&model.BoardPatch{MinimumRole: &minimumRole}, testData.publicTemplate.ID, userAdminID) require.NoError(t, err) if minimumRole == "viewer" || minimumRole == "commenter" { return []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userViewer, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userCommenter, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAdmin, http.StatusOK, 1}, } } else { return []TestCase{ {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userViewer, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAdmin, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAnon, http.StatusUnauthorized, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userNoTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userTeamMember, http.StatusForbidden, 0}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userViewer, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userCommenter, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userEditor, http.StatusOK, 1}, {"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAdmin, http.StatusOK, 1}, } } } t.Run("plugin", func(t *testing.T) { t.Run("minimum role viewer", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "viewer", testData) runTestCases(t, ttCases, testData, clients) }) t.Run("minimum role commenter", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "commenter", testData) runTestCases(t, ttCases, testData, clients) }) t.Run("minimum role editor", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "editor", testData) runTestCases(t, ttCases, testData, clients) }) t.Run("minimum role admin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "admin", testData) runTestCases(t, ttCases, testData, clients) }) }) t.Run("local", func(t *testing.T) { t.Run("minimum role viewer", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "viewer", testData) runTestCases(t, ttCases, testData, clients) }) t.Run("minimum role commenter", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "commenter", testData) runTestCases(t, ttCases, testData, clients) }) t.Run("minimum role editor", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "editor", testData) runTestCases(t, ttCases, testData, clients) }) t.Run("minimum role admin", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := ttCasesF(t, th, "admin", testData) runTestCases(t, ttCases, testData, clients) }) }) } func TestPermissionsChannels(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/channels", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/channels", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/channels", methodGet, "", userTeamMember, http.StatusOK, 2}, {"/teams/test-team/channels", methodGet, "", userViewer, http.StatusOK, 2}, {"/teams/test-team/channels", methodGet, "", userCommenter, http.StatusOK, 2}, {"/teams/test-team/channels", methodGet, "", userEditor, http.StatusOK, 2}, {"/teams/test-team/channels", methodGet, "", userAdmin, http.StatusOK, 2}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/channels", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/channels", methodGet, "", userNoTeamMember, http.StatusNotImplemented, 0}, {"/teams/test-team/channels", methodGet, "", userTeamMember, http.StatusNotImplemented, 0}, {"/teams/test-team/channels", methodGet, "", userViewer, http.StatusNotImplemented, 0}, {"/teams/test-team/channels", methodGet, "", userCommenter, http.StatusNotImplemented, 0}, {"/teams/test-team/channels", methodGet, "", userEditor, http.StatusNotImplemented, 0}, {"/teams/test-team/channels", methodGet, "", userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsChannel(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/channels/valid-channel-id", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userTeamMember, http.StatusOK, 1}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userViewer, http.StatusOK, 1}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userCommenter, http.StatusOK, 1}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userEditor, http.StatusOK, 1}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userAdmin, http.StatusOK, 1}, {"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userAdmin, http.StatusForbidden, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/teams/test-team/channels/valid-channel-id", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userNoTeamMember, http.StatusNotImplemented, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userTeamMember, http.StatusNotImplemented, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userViewer, http.StatusNotImplemented, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userCommenter, http.StatusNotImplemented, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userEditor, http.StatusNotImplemented, 0}, {"/teams/test-team/channels/valid-channel-id", methodGet, "", userAdmin, http.StatusNotImplemented, 0}, } runTestCases(t, ttCases, testData, clients) }) } func TestPermissionsGetStatistics(t *testing.T) { t.Run("plugin", func(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/statistics", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/statistics", methodGet, "", userNoTeamMember, http.StatusForbidden, 0}, {"/statistics", methodGet, "", userTeamMember, http.StatusForbidden, 0}, {"/statistics", methodGet, "", userViewer, http.StatusForbidden, 0}, {"/statistics", methodGet, "", userCommenter, http.StatusForbidden, 0}, {"/statistics", methodGet, "", userEditor, http.StatusForbidden, 0}, {"/statistics", methodGet, "", userAdmin, http.StatusOK, 1}, {"/statistics", methodGet, "", userGuest, http.StatusForbidden, 0}, } runTestCases(t, ttCases, testData, clients) }) t.Run("local", func(t *testing.T) { th := SetupTestHelperLocalMode(t) defer th.TearDown() clients := setupLocalClients(th) testData := setupData(t, th) ttCases := []TestCase{ {"/statistics", methodGet, "", userAnon, http.StatusUnauthorized, 0}, {"/statistics", methodGet, "", userNoTeamMember, http.StatusNotImplemented, 0}, {"/statistics", methodGet, "", userTeamMember, http.StatusNotImplemented, 0}, {"/statistics", methodGet, "", userViewer, http.StatusNotImplemented, 0}, {"/statistics", methodGet, "", userCommenter, http.StatusNotImplemented, 0}, {"/statistics", methodGet, "", userEditor, http.StatusNotImplemented, 0}, {"/statistics", methodGet, "", userAdmin, http.StatusNotImplemented, 1}, {"/statistics", methodGet, "", userGuest, http.StatusForbidden, 0}, } runTestCases(t, ttCases, testData, clients) }) } ================================================ FILE: server/integrationtests/pluginteststore.go ================================================ package integrationtests import ( "errors" "os" "strconv" "strings" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" mmModel "github.com/mattermost/mattermost/server/public/model" ) var errTestStore = errors.New("plugin test store error") type PluginTestStore struct { store.Store users map[string]*model.User testTeam *model.Team otherTeam *model.Team emptyTeam *model.Team baseTeam *model.Team } func NewPluginTestStore(innerStore store.Store) *PluginTestStore { return &PluginTestStore{ Store: innerStore, users: map[string]*model.User{ "no-team-member": { ID: "no-team-member", Username: "no-team-member", Email: "no-team-member@sample.com", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }, "team-member": { ID: "team-member", Username: "team-member", Email: "team-member@sample.com", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }, "viewer": { ID: "viewer", Username: "viewer", Email: "viewer@sample.com", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }, "commenter": { ID: "commenter", Username: "commenter", Email: "commenter@sample.com", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }, "editor": { ID: "editor", Username: "editor", Email: "editor@sample.com", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }, "admin": { ID: "admin", Username: "admin", Email: "admin@sample.com", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), }, "guest": { ID: "guest", Username: "guest", Email: "guest@sample.com", CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), IsGuest: true, }, }, testTeam: &model.Team{ID: "test-team", Title: "Test Team"}, otherTeam: &model.Team{ID: "other-team", Title: "Other Team"}, emptyTeam: &model.Team{ID: "empty-team", Title: "Empty Team"}, baseTeam: &model.Team{ID: "0", Title: "Base Team"}, } } func (s *PluginTestStore) GetTeam(id string) (*model.Team, error) { switch id { case "0": return s.baseTeam, nil case "other-team": return s.otherTeam, nil case "test-team", testTeamID: return s.testTeam, nil case "empty-team": return s.emptyTeam, nil } return nil, errTestStore } func (s *PluginTestStore) GetTeamsForUser(userID string) ([]*model.Team, error) { switch userID { case "no-team-member": return []*model.Team{}, nil case "team-member": return []*model.Team{s.testTeam, s.otherTeam}, nil case "viewer": return []*model.Team{s.testTeam, s.otherTeam}, nil case "commenter": return []*model.Team{s.testTeam, s.otherTeam}, nil case "editor": return []*model.Team{s.testTeam, s.otherTeam}, nil case "admin": return []*model.Team{s.testTeam, s.otherTeam}, nil case "guest": return []*model.Team{s.testTeam}, nil } return nil, errTestStore } func (s *PluginTestStore) GetUserByID(userID string) (*model.User, error) { user := s.users[userID] if user == nil { return nil, errTestStore } return user, nil } func (s *PluginTestStore) GetUsersList(userIDs []string, showEmail, showName bool) ([]*model.User, error) { var users []*model.User for _, id := range userIDs { user := s.users[id] if user != nil { users = append(users, user) } } return users, nil } func (s *PluginTestStore) GetUserByEmail(email string) (*model.User, error) { for _, user := range s.users { if user.Email == email { return user, nil } } return nil, errTestStore } func (s *PluginTestStore) GetUserByUsername(username string) (*model.User, error) { for _, user := range s.users { if user.Username == username { return user, nil } } return nil, errTestStore } func (s *PluginTestStore) GetUserPreferences(userID string) (mmModel.Preferences, error) { if userID == userTeamMember { return mmModel.Preferences{{ UserId: userTeamMember, Category: "focalboard", Name: "test", Value: "test", }}, nil } return nil, errTestStore } func (s *PluginTestStore) GetUsersByTeam(teamID string, asGuestID string, showEmail, showName bool) ([]*model.User, error) { if asGuestID == "guest" { return []*model.User{ s.users["viewer"], s.users["commenter"], s.users["editor"], s.users["admin"], s.users["guest"], }, nil } switch { case teamID == s.testTeam.ID: return []*model.User{ s.users["team-member"], s.users["viewer"], s.users["commenter"], s.users["editor"], s.users["admin"], s.users["guest"], }, nil case teamID == s.otherTeam.ID: return []*model.User{ s.users["team-member"], s.users["viewer"], s.users["commenter"], s.users["editor"], s.users["admin"], }, nil case teamID == s.emptyTeam.ID: return []*model.User{}, nil } return nil, errTestStore } func (s *PluginTestStore) SearchUsersByTeam(teamID string, searchQuery string, asGuestID string, excludeBots bool, showEmail, showName bool) ([]*model.User, error) { users := []*model.User{} teamUsers, err := s.GetUsersByTeam(teamID, asGuestID, showEmail, showName) if err != nil { return nil, err } for _, user := range teamUsers { if excludeBots && user.IsBot { continue } if strings.Contains(user.Username, searchQuery) { users = append(users, user) } } return users, nil } func (s *PluginTestStore) CanSeeUser(seerID string, seenID string) (bool, error) { user, err := s.GetUserByID(seerID) if err != nil { return false, err } if !user.IsGuest { return true, nil } seerMembers, err := s.GetMembersForUser(seerID) if err != nil { return false, err } seenMembers, err := s.GetMembersForUser(seenID) if err != nil { return false, err } for _, seerMember := range seerMembers { for _, seenMember := range seenMembers { if seerMember.BoardID == seenMember.BoardID { return true, nil } } } return false, nil } func (s *PluginTestStore) SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error) { return []*mmModel.Channel{ { TeamId: teamID, Id: "valid-channel-id", DisplayName: "Valid Channel", Name: "valid-channel", }, { TeamId: teamID, Id: "valid-channel-id-2", DisplayName: "Valid Channel 2", Name: "valid-channel-2", }, }, nil } func (s *PluginTestStore) GetChannel(teamID, channel string) (*mmModel.Channel, error) { if channel == "valid-channel-id" { return &mmModel.Channel{ TeamId: teamID, Id: "valid-channel-id", DisplayName: "Valid Channel", Name: "valid-channel", }, nil } else if channel == "valid-channel-id-2" { return &mmModel.Channel{ TeamId: teamID, Id: "valid-channel-id-2", DisplayName: "Valid Channel 2", Name: "valid-channel-2", }, nil } return nil, errTestStore } func (s *PluginTestStore) SearchBoardsForUser(term string, field model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) { boards, err := s.Store.SearchBoardsForUser(term, field, userID, includePublicBoards) if err != nil { return nil, err } teams, err := s.GetTeamsForUser(userID) if err != nil { return nil, err } resultBoards := []*model.Board{} for _, board := range boards { for _, team := range teams { if team.ID == board.TeamID { resultBoards = append(resultBoards, board) break } } } return resultBoards, nil } func (s *PluginTestStore) GetLicense() *mmModel.License { license := s.Store.GetLicense() if license == nil { license = &mmModel.License{ Id: mmModel.NewId(), StartsAt: mmModel.GetMillis() - 2629746000, // 1 month ExpiresAt: mmModel.GetMillis() + 2629746000, // IssuedAt: mmModel.GetMillis() - 2629746000, Features: &mmModel.Features{}, } license.Features.SetDefaults() } complianceLicense := os.Getenv("FOCALBOARD_UNIT_TESTING_COMPLIANCE") if complianceLicense != "" { if val, err := strconv.ParseBool(complianceLicense); err == nil { license.Features.Compliance = mmModel.NewBool(val) } } return license } ================================================ FILE: server/integrationtests/sharing_test.go ================================================ package integrationtests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func TestSharing(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() var boardID string token := utils.NewID(utils.IDTypeToken) t.Run("an unauthenticated client should not be able to get a sharing", func(t *testing.T) { th.Logout(th.Client) sharing, resp := th.Client.GetSharing("board-id") th.CheckUnauthorized(resp) require.Nil(t, sharing) }) t.Run("Check no initial sharing", func(t *testing.T) { th.Login1() teamID := "0" newBoard := &model.Board{ TeamID: teamID, Type: model.BoardTypeOpen, } board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true) require.NoError(t, err) require.NotNil(t, board) boardID = board.ID s, err := th.Server.App().GetSharing(boardID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, s) sharing, resp := th.Client.GetSharing(boardID) th.CheckNotFound(resp) require.Nil(t, sharing) }) t.Run("POST sharing, config = false", func(t *testing.T) { sharing := model.Sharing{ ID: boardID, Token: token, Enabled: true, UpdateAt: 1, } // it will fail with default config success, resp := th.Client.PostSharing(&sharing) require.False(t, success) require.Error(t, resp.Error) t.Run("GET sharing", func(t *testing.T) { sharing, resp := th.Client.GetSharing(boardID) // Expect empty sharing object th.CheckNotFound(resp) require.Nil(t, sharing) }) }) t.Run("POST sharing, config = true", func(t *testing.T) { th.Server.Config().EnablePublicSharedBoards = true sharing := model.Sharing{ ID: boardID, Token: token, Enabled: true, UpdateAt: 1, } // it will succeed with updated config success, resp := th.Client.PostSharing(&sharing) require.True(t, success) require.NoError(t, resp.Error) t.Run("GET sharing", func(t *testing.T) { sharing, resp := th.Client.GetSharing(boardID) require.NoError(t, resp.Error) require.NotNil(t, sharing) require.Equal(t, sharing.ID, boardID) require.True(t, sharing.Enabled) require.Equal(t, sharing.Token, token) }) }) } ================================================ FILE: server/integrationtests/sidebar_test.go ================================================ package integrationtests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/require" ) func TestSidebar(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() // we'll create a new board. // The board should end up in a default "Boards" category board := th.CreateBoard("team-id", "O") categoryBoards := th.GetUserCategoryBoards("team-id") require.Equal(t, 1, len(categoryBoards)) require.Equal(t, "Boards", categoryBoards[0].Name) require.Equal(t, 1, len(categoryBoards[0].BoardMetadata)) require.Equal(t, board.ID, categoryBoards[0].BoardMetadata[0].BoardID) // create a new category, a new board // and move that board into the new category board2 := th.CreateBoard("team-id", "O") category := th.CreateCategory(model.Category{ Name: "Category 2", TeamID: "team-id", UserID: "single-user", }) th.UpdateCategoryBoard("team-id", category.ID, board2.ID) categoryBoards = th.GetUserCategoryBoards("team-id") // now there should be two categories - boards and the one // we created just now require.Equal(t, 2, len(categoryBoards)) // the newly created category should be the first one array // as new categories end up on top in LHS require.Equal(t, "Category 2", categoryBoards[0].Name) require.Equal(t, 1, len(categoryBoards[0].BoardMetadata)) require.Equal(t, board2.ID, categoryBoards[0].BoardMetadata[0].BoardID) // now we'll delete the custom category we created, "Category 2" // and all it's boards should get moved to the Boards category th.DeleteCategory("team-id", category.ID) categoryBoards = th.GetUserCategoryBoards("team-id") require.Equal(t, 1, len(categoryBoards)) require.Equal(t, "Boards", categoryBoards[0].Name) require.Equal(t, 2, len(categoryBoards[0].BoardMetadata)) require.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: board.ID, Hidden: false}) require.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: board2.ID, Hidden: false}) } func TestHideUnhideBoard(t *testing.T) { th := SetupTestHelperWithToken(t).Start() defer th.TearDown() // we'll create a new board. // The board should end up in a default "Boards" category th.CreateBoard("team-id", "O") // the created board should not be hidden categoryBoards := th.GetUserCategoryBoards("team-id") require.Equal(t, 1, len(categoryBoards)) require.Equal(t, "Boards", categoryBoards[0].Name) require.Equal(t, 1, len(categoryBoards[0].BoardMetadata)) require.False(t, categoryBoards[0].BoardMetadata[0].Hidden) // now we'll hide the board response := th.Client.HideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID) th.CheckOK(response) // verifying if the board has been marked as hidden categoryBoards = th.GetUserCategoryBoards("team-id") require.True(t, categoryBoards[0].BoardMetadata[0].Hidden) // trying to hide the already hidden board.This should have no effect response = th.Client.HideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID) th.CheckOK(response) categoryBoards = th.GetUserCategoryBoards("team-id") require.True(t, categoryBoards[0].BoardMetadata[0].Hidden) // now we'll unhide the board response = th.Client.UnhideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID) th.CheckOK(response) // verifying categoryBoards = th.GetUserCategoryBoards("team-id") require.False(t, categoryBoards[0].BoardMetadata[0].Hidden) // trying to unhide the already visible board.This should have no effect response = th.Client.UnhideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID) th.CheckOK(response) categoryBoards = th.GetUserCategoryBoards("team-id") require.False(t, categoryBoards[0].BoardMetadata[0].Hidden) } ================================================ FILE: server/integrationtests/subscriptions_test.go ================================================ package integrationtests import ( "fmt" "testing" "github.com/mattermost/focalboard/server/client" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func createTestSubscriptions(client *client.Client, num int) ([]*model.Subscription, string, error) { newSubs := make([]*model.Subscription, 0, num) user, resp := client.GetMe() if resp.Error != nil { return nil, "", fmt.Errorf("cannot get current user: %w", resp.Error) } board := &model.Board{ TeamID: "0", Type: model.BoardTypeOpen, CreateAt: 1, UpdateAt: 1, } board, resp = client.CreateBoard(board) if resp.Error != nil { return nil, "", fmt.Errorf("cannot insert test board block: %w", resp.Error) } for n := 0; n < num; n++ { newBlock := &model.Block{ ID: utils.NewID(utils.IDTypeCard), BoardID: board.ID, CreateAt: 1, UpdateAt: 1, Type: model.TypeCard, } newBlocks, resp := client.InsertBlocks(board.ID, []*model.Block{newBlock}, false) if resp.Error != nil { return nil, "", fmt.Errorf("cannot insert test card block: %w", resp.Error) } newBlock = newBlocks[0] sub := &model.Subscription{ BlockType: newBlock.Type, BlockID: newBlock.ID, SubscriberType: model.SubTypeUser, SubscriberID: user.ID, } subNew, resp := client.CreateSubscription(sub) if resp.Error != nil { return nil, "", resp.Error } newSubs = append(newSubs, subNew) } return newSubs, user.ID, nil } func TestCreateSubscription(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() t.Run("Create valid subscription", func(t *testing.T) { subs, userID, err := createTestSubscriptions(th.Client, 5) require.NoError(t, err) require.Len(t, subs, 5) // fetch the newly created subscriptions and compare subsFound, resp := th.Client.GetSubscriptions(userID) require.NoError(t, resp.Error) require.Len(t, subsFound, 5) assert.ElementsMatch(t, subs, subsFound) }) t.Run("Create invalid subscription", func(t *testing.T) { user, resp := th.Client.GetMe() require.NoError(t, resp.Error) sub := &model.Subscription{ SubscriberID: user.ID, } _, resp = th.Client.CreateSubscription(sub) require.Error(t, resp.Error) }) t.Run("Create subscription for another user", func(t *testing.T) { sub := &model.Subscription{ SubscriberID: utils.NewID(utils.IDTypeUser), } _, resp := th.Client.CreateSubscription(sub) require.Error(t, resp.Error) }) } func TestGetSubscriptions(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() t.Run("Get subscriptions for user", func(t *testing.T) { mySubs, user1ID, err := createTestSubscriptions(th.Client, 5) require.NoError(t, err) require.Len(t, mySubs, 5) // create more subscriptions with different user otherSubs, _, err := createTestSubscriptions(th.Client2, 10) require.NoError(t, err) require.Len(t, otherSubs, 10) // fetch the newly created subscriptions for current user, making sure only // the ones created for the current user are returned. subsFound, resp := th.Client.GetSubscriptions(user1ID) require.NoError(t, resp.Error) require.Len(t, subsFound, 5) assert.ElementsMatch(t, mySubs, subsFound) }) } func TestDeleteSubscription(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() t.Run("Delete valid subscription", func(t *testing.T) { subs, userID, err := createTestSubscriptions(th.Client, 3) require.NoError(t, err) require.Len(t, subs, 3) resp := th.Client.DeleteSubscription(subs[1].BlockID, userID) require.NoError(t, resp.Error) // fetch the subscriptions and ensure the list is correct subsFound, resp := th.Client.GetSubscriptions(userID) require.NoError(t, resp.Error) require.Len(t, subsFound, 2) assert.Contains(t, subsFound, subs[0]) assert.Contains(t, subsFound, subs[2]) assert.NotContains(t, subsFound, subs[1]) }) t.Run("Delete invalid subscription", func(t *testing.T) { user, resp := th.Client.GetMe() require.NoError(t, resp.Error) resp = th.Client.DeleteSubscription("bogus", user.ID) require.Error(t, resp.Error) }) } ================================================ FILE: server/integrationtests/teststore.go ================================================ package integrationtests import ( "github.com/mattermost/focalboard/server/services/store" mmModel "github.com/mattermost/mattermost/server/public/model" ) type TestStore struct { store.Store license *mmModel.License } func NewTestEnterpriseStore(store store.Store) *TestStore { usersValue := 10000 trueValue := true falseValue := false license := &mmModel.License{ Features: &mmModel.Features{ Users: &usersValue, LDAP: &trueValue, LDAPGroups: &trueValue, MFA: &trueValue, GoogleOAuth: &trueValue, Office365OAuth: &trueValue, OpenId: &trueValue, Compliance: &trueValue, Cluster: &trueValue, Metrics: &trueValue, MHPNS: &trueValue, SAML: &trueValue, Elasticsearch: &trueValue, Announcement: &trueValue, ThemeManagement: &trueValue, EmailNotificationContents: &trueValue, DataRetention: &trueValue, MessageExport: &trueValue, CustomPermissionsSchemes: &trueValue, CustomTermsOfService: &trueValue, GuestAccounts: &trueValue, GuestAccountsPermissions: &trueValue, IDLoadedPushNotifications: &trueValue, LockTeammateNameDisplay: &trueValue, EnterprisePlugins: &trueValue, AdvancedLogging: &trueValue, Cloud: &falseValue, SharedChannels: &trueValue, RemoteClusterService: &trueValue, FutureFeatures: &trueValue, }, } testStore := &TestStore{ Store: store, license: license, } return testStore } func NewTestProfessionalStore(store store.Store) *TestStore { usersValue := 10000 trueValue := true falseValue := false license := &mmModel.License{ Features: &mmModel.Features{ Users: &usersValue, LDAP: &falseValue, LDAPGroups: &falseValue, MFA: &trueValue, GoogleOAuth: &trueValue, Office365OAuth: &trueValue, OpenId: &trueValue, Compliance: &falseValue, Cluster: &falseValue, Metrics: &trueValue, MHPNS: &trueValue, SAML: &trueValue, Elasticsearch: &trueValue, Announcement: &trueValue, ThemeManagement: &trueValue, EmailNotificationContents: &trueValue, DataRetention: &trueValue, MessageExport: &trueValue, CustomPermissionsSchemes: &trueValue, CustomTermsOfService: &trueValue, GuestAccounts: &trueValue, GuestAccountsPermissions: &trueValue, IDLoadedPushNotifications: &trueValue, LockTeammateNameDisplay: &trueValue, EnterprisePlugins: &falseValue, AdvancedLogging: &trueValue, Cloud: &falseValue, SharedChannels: &trueValue, RemoteClusterService: &falseValue, FutureFeatures: &trueValue, }, } testStore := &TestStore{ Store: store, license: license, } return testStore } func (s *TestStore) GetLicense() *mmModel.License { return s.license } ================================================ FILE: server/integrationtests/user_test.go ================================================ package integrationtests import ( "bytes" "crypto/rand" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) const ( fakeUsername = "fakeUsername" fakeEmail = "mock@test.com" ) func TestUserRegister(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() // register registerRequest := &model.RegisterRequest{ Username: fakeUsername, Email: fakeEmail, Password: utils.NewID(utils.IDTypeNone), } success, resp := th.Client.Register(registerRequest) require.NoError(t, resp.Error) require.True(t, success) // register again will fail success, resp = th.Client.Register(registerRequest) require.Error(t, resp.Error) require.False(t, success) } func TestUserLogin(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() t.Run("with nonexist user", func(t *testing.T) { loginRequest := &model.LoginRequest{ Type: "normal", Username: "nonexistuser", Email: "", Password: utils.NewID(utils.IDTypeNone), } data, resp := th.Client.Login(loginRequest) require.Error(t, resp.Error) require.Nil(t, data) }) t.Run("with registered user", func(t *testing.T) { password := utils.NewID(utils.IDTypeNone) // register registerRequest := &model.RegisterRequest{ Username: fakeUsername, Email: fakeEmail, Password: password, } success, resp := th.Client.Register(registerRequest) require.NoError(t, resp.Error) require.True(t, success) // login loginRequest := &model.LoginRequest{ Type: "normal", Username: fakeUsername, Email: fakeEmail, Password: password, } data, resp := th.Client.Login(loginRequest) require.NoError(t, resp.Error) require.NotNil(t, data) require.NotNil(t, data.Token) }) } func TestGetMe(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() t.Run("not login yet", func(t *testing.T) { me, resp := th.Client.GetMe() require.Error(t, resp.Error) require.Nil(t, me) }) t.Run("logged in", func(t *testing.T) { // register password := utils.NewID(utils.IDTypeNone) registerRequest := &model.RegisterRequest{ Username: fakeUsername, Email: fakeEmail, Password: password, } success, resp := th.Client.Register(registerRequest) require.NoError(t, resp.Error) require.True(t, success) // login loginRequest := &model.LoginRequest{ Type: "normal", Username: fakeUsername, Email: fakeEmail, Password: password, } data, resp := th.Client.Login(loginRequest) require.NoError(t, resp.Error) require.NotNil(t, data) require.NotNil(t, data.Token) // get user me me, resp := th.Client.GetMe() require.NoError(t, resp.Error) require.NotNil(t, me) require.Equal(t, "", me.Email) require.Equal(t, registerRequest.Username, me.Username) }) } func TestGetUser(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() // register password := utils.NewID(utils.IDTypeNone) registerRequest := &model.RegisterRequest{ Username: fakeUsername, Email: fakeEmail, Password: password, } success, resp := th.Client.Register(registerRequest) require.NoError(t, resp.Error) require.True(t, success) // login loginRequest := &model.LoginRequest{ Type: "normal", Username: fakeUsername, Email: fakeEmail, Password: password, } data, resp := th.Client.Login(loginRequest) require.NoError(t, resp.Error) require.NotNil(t, data) require.NotNil(t, data.Token) me, resp := th.Client.GetMe() require.NoError(t, resp.Error) require.NotNil(t, me) t.Run("me's id", func(t *testing.T) { user, resp := th.Client.GetUser(me.ID) require.NoError(t, resp.Error) require.NotNil(t, user) require.Equal(t, me.ID, user.ID) require.Equal(t, me.Username, user.Username) }) t.Run("nonexist user", func(t *testing.T) { user, resp := th.Client.GetUser("nonexistid") require.Error(t, resp.Error) require.Nil(t, user) }) } func TestGetUserList(t *testing.T) { th := SetupTestHelperPluginMode(t) defer th.TearDown() clients := setupClients(th) th.Client = clients.TeamMember th.Client2 = clients.Editor me, resp := th.Client.GetMe() require.NoError(t, resp.Error) require.NotNil(t, me) userID1 := me.ID userID2 := th.GetUser2().ID // Admin user should return both returnUsers, resp := clients.Admin.GetUserList([]string{userID1, userID2}) require.NoError(t, resp.Error) require.NotNil(t, returnUsers) require.Equal(t, 2, len(returnUsers)) // Guest user should return none returnUsers2, resp := clients.Guest.GetUserList([]string{userID1, userID2}) require.NoError(t, resp.Error) require.NotNil(t, returnUsers2) require.Equal(t, 0, len(returnUsers2)) newBoard := &model.Board{ Title: "title", Type: model.BoardTypeOpen, TeamID: testTeamID, } board, err := th.Server.App().CreateBoard(newBoard, userID1, true) require.NoError(t, err) // add Guest as board member newGuestMember := &model.BoardMember{ UserID: userGuestID, BoardID: board.ID, SchemeViewer: true, SchemeCommenter: true, SchemeEditor: true, SchemeAdmin: false, } guestMember, err := th.Server.App().AddMemberToBoard(newGuestMember) require.NoError(t, err) require.NotNil(t, guestMember) // Guest user should now return one of members guestUsers, resp := clients.Guest.GetUserList([]string{th.GetUser1().ID, th.GetUser2().ID}) require.NoError(t, resp.Error) require.NotNil(t, guestUsers) require.Equal(t, 1, len(guestUsers)) // add other user as board member newBoardMember := &model.BoardMember{ UserID: userID2, BoardID: board.ID, SchemeViewer: true, SchemeCommenter: true, SchemeEditor: true, SchemeAdmin: false, } newMember, err := th.Server.App().AddMemberToBoard(newBoardMember) require.NoError(t, err) require.NotNil(t, newMember) // Guest user should now return both guestUsers, resp = clients.Guest.GetUserList([]string{th.GetUser1().ID, th.GetUser2().ID}) require.NoError(t, resp.Error) require.NotNil(t, guestUsers) require.Equal(t, 2, len(guestUsers)) } func TestUserChangePassword(t *testing.T) { th := SetupTestHelper(t).Start() defer th.TearDown() // register password := utils.NewID(utils.IDTypeNone) registerRequest := &model.RegisterRequest{ Username: fakeUsername, Email: fakeEmail, Password: password, } success, resp := th.Client.Register(registerRequest) require.NoError(t, resp.Error) require.True(t, success) // login loginRequest := &model.LoginRequest{ Type: "normal", Username: fakeUsername, Email: fakeEmail, Password: password, } data, resp := th.Client.Login(loginRequest) require.NoError(t, resp.Error) require.NotNil(t, data) require.NotNil(t, data.Token) originalMe, resp := th.Client.GetMe() require.NoError(t, resp.Error) require.NotNil(t, originalMe) // change password success, resp = th.Client.UserChangePassword(originalMe.ID, &model.ChangePasswordRequest{ OldPassword: password, NewPassword: utils.NewID(utils.IDTypeNone), }) require.NoError(t, resp.Error) require.True(t, success) } func randomBytes(t *testing.T, n int) []byte { bb := make([]byte, n) _, err := rand.Read(bb) require.NoError(t, err) return bb } func TestTeamUploadFile(t *testing.T) { t.Run("no permission", func(t *testing.T) { // native auth, but not login th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := "0" boardID := utils.NewID(utils.IDTypeBoard) data := randomBytes(t, 1024) result, resp := th.Client.TeamUploadFile(teamID, boardID, bytes.NewReader(data)) require.Error(t, resp.Error) require.Nil(t, result) }) t.Run("a board admin should be able to update a file", func(t *testing.T) { // single token auth th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := "0" newBoard := &model.Board{ Type: model.BoardTypeOpen, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NotNil(t, board) data := randomBytes(t, 1024) result, resp := th.Client.TeamUploadFile(teamID, board.ID, bytes.NewReader(data)) th.CheckOK(resp) require.NotNil(t, result) require.NotEmpty(t, result.FileID) // TODO get the uploaded file }) t.Run("user that doesn't belong to the board should not be able to upload a file", func(t *testing.T) { th := SetupTestHelper(t).InitBasic() defer th.TearDown() teamID := "0" newBoard := &model.Board{ Type: model.BoardTypeOpen, TeamID: teamID, } board, resp := th.Client.CreateBoard(newBoard) th.CheckOK(resp) require.NotNil(t, board) data := randomBytes(t, 1024) // a user that doesn't belong to the board tries to upload the file result, resp := th.Client2.TeamUploadFile(teamID, board.ID, bytes.NewReader(data)) th.CheckForbidden(resp) require.Nil(t, result) }) } ================================================ FILE: server/integrationtests/work_template_test.go ================================================ package integrationtests import ( "testing" "github.com/stretchr/testify/require" ) // This test is there to guarantee that the board templates needed for // the work template are present in the default templates. // If this fails, you might need to sync with the channels team. func TestGetTemplatesForWorkTemplate(t *testing.T) { // map[name]trackingTemplateId knownInWorkTemplates := map[string]string{ "Company Goals & OKRs": "7ba22ccfdfac391d63dea5c4b8cde0de", "Competitive Analysis": "06f4bff367a7c2126fab2380c9dec23c", "Content Calendar": "c75fbd659d2258b5183af2236d176ab4", "Meeting Agenda ": "54fcf9c610f0ac5e4c522c0657c90602", "Personal Goals ": "7f32dc8d2ae008cfe56554e9363505cc", "Personal Tasls ": "dfb70c146a4584b8a21837477c7b5431", "Project Tasks ": "a4ec399ab4f2088b1051c3cdf1dde4c3", "Roadmap ": "b728c6ca730e2cfc229741c5a4712b65", "Sales Pipeline CRM": "ecc250bb7dff0fe02247f1110f097544", "Sprint Planner ": "99b74e26d2f5d0a9b346d43c0a7bfb09", "Team Retrospective": "e4f03181c4ced8edd4d53d33d569a086", "User Research Sessions": "6c345c7f50f6833f78b7d0f08ce450a3", } th := SetupTestHelper(t).InitBasic() defer th.TearDown() err := th.Server.App().InitTemplates() require.NoError(t, err, "InitTemplates should not fail") rBoards, resp := th.Client.GetTemplatesForTeam("0") th.CheckOK(resp) require.NotNil(t, rBoards) trackingTemplateIDs := []string{} for _, board := range rBoards { property, _ := board.GetPropertyString("trackingTemplateId") if property != "" { trackingTemplateIDs = append(trackingTemplateIDs, property) } } // make sure all known templates are in trackingTemplateIds for name, ttID := range knownInWorkTemplates { found := false for _, trackingTemplateID := range trackingTemplateIDs { if trackingTemplateID == ttID { found = true break } } require.True(t, found, "trackingTemplateId %s for %s not found", ttID, name) } } ================================================ FILE: server/main/doc.go ================================================ // Package classification Focalboard Server // // Focalboard Server // // Schemes: http, https // Host: localhost // BasePath: /api/v2 // Version: 2.0.0 // License: Custom https://github.com/mattermost/focalboard/blob/main/LICENSE.txt // Contact: Focalboard https://www.focalboard.com // // Consumes: // - application/json // // Produces: // - application/json // // securityDefinitions: // BearerAuth: // type: apiKey // name: Authorization // in: header // description: 'Pass session token using Bearer authentication, e.g. set header "Authorization: Bearer "' // // swagger:meta package main ================================================ FILE: server/main/main.go ================================================ // Server for Focalboard package main import ( "C" "flag" "log" "os" "os/signal" "syscall" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/server" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/permissions/localpermissions" ) import ( "github.com/mattermost/mattermost/server/public/shared/mlog" ) // Active server used with shared code (dll) var pServer *server.Server const ( timeBetweenPidMonitoringChecks = 2 * time.Second ) func isProcessRunning(pid int) bool { process, err := os.FindProcess(pid) if err != nil { return false } err = process.Signal(syscall.Signal(0)) return err == nil } // monitorPid is used to keep the server lifetime in sync with another (client app) process func monitorPid(pid int, logger *mlog.Logger) { logger.Info("Monitoring PID", mlog.Int("pid", pid)) go func() { for { if !isProcessRunning(pid) { logger.Info("Monitored process not found, exiting.") os.Exit(1) } time.Sleep(timeBetweenPidMonitoringChecks) } }() } func main() { // Command line args pMonitorPid := flag.Int("monitorpid", -1, "a process ID") pPort := flag.Int("port", 0, "the port number") pSingleUser := flag.Bool("single-user", false, "single user mode") pDBType := flag.String("dbtype", "", "Database type") pDBConfig := flag.String("dbconfig", "", "Database config") pConfigFilePath := flag.String( "config", "", "Location of the JSON config file", ) flag.Parse() config, err := config.ReadConfigFile(*pConfigFilePath) if err != nil { log.Fatal("Unable to read the config file: ", err) return } logger, _ := mlog.NewLogger() cfgJSON := config.LoggingCfgJSON if config.LoggingCfgFile == "" && cfgJSON == "" { // if no logging defined, use default config (console output) cfgJSON = defaultLoggingConfig() } err = logger.Configure(config.LoggingCfgFile, cfgJSON, nil) if err != nil { log.Fatal("Error in config file for logger: ", err) return } defer func() { _ = logger.Shutdown() }() if logger.HasTargets() { restore := logger.RedirectStdLog(mlog.LvlInfo, mlog.String("src", "stdlog")) defer restore() } model.LogServerInfo(logger) singleUser := false if pSingleUser != nil { singleUser = *pSingleUser } singleUserToken := "" if singleUser { singleUserToken = os.Getenv("FOCALBOARD_SINGLE_USER_TOKEN") if len(singleUserToken) < 1 { logger.Fatal("The FOCALBOARD_SINGLE_USER_TOKEN environment variable must be set for single user mode ") return } logger.Info("Single user mode") } if pMonitorPid != nil && *pMonitorPid > 0 { monitorPid(*pMonitorPid, logger) } // Override config from commandline if pDBType != nil && len(*pDBType) > 0 { config.DBType = *pDBType logger.Info("DBType from commandline", mlog.String("DBType", *pDBType)) } if pDBConfig != nil && len(*pDBConfig) > 0 { config.DBConfigString = *pDBConfig // Don't echo, as the confix string may contain passwords logger.Info("DBConfigString overridden from commandline") } if pPort != nil && *pPort > 0 && *pPort != config.Port { // Override port logger.Info("Port from commandline", mlog.Int("port", *pPort)) config.Port = *pPort } db, err := server.NewStore(config, singleUser, logger) if err != nil { logger.Fatal("server.NewStore ERROR", mlog.Err(err)) } permissionsService := localpermissions.New(db, logger) params := server.Params{ Cfg: config, SingleUserToken: singleUserToken, DBStore: db, Logger: logger, PermissionsService: permissionsService, } server, err := server.New(params) if err != nil { logger.Fatal("server.New ERROR", mlog.Err(err)) } if err := server.Start(); err != nil { logger.Fatal("server.Start ERROR", mlog.Err(err)) } // Setting up signal capturing stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt) // Waiting for SIGINT (pkill -2) <-stop _ = server.Shutdown() } // StartServer starts the server // //export StartServer func StartServer(webPath *C.char, filesPath *C.char, port int, singleUserToken, dbConfigString, configFilePath *C.char) { startServer( C.GoString(webPath), C.GoString(filesPath), port, C.GoString(singleUserToken), C.GoString(dbConfigString), C.GoString(configFilePath), ) } // StopServer stops the server // //export StopServer func StopServer() { stopServer() } func startServer(webPath string, filesPath string, port int, singleUserToken, dbConfigString, configFilePath string) { if pServer != nil { stopServer() pServer = nil } // config.json file config, err := config.ReadConfigFile(configFilePath) if err != nil { log.Fatal("Unable to read the config file: ", err) return } logger, _ := mlog.NewLogger() err = logger.Configure(config.LoggingCfgFile, config.LoggingCfgJSON, nil) if err != nil { log.Fatal("Error in config file for logger: ", err) return } model.LogServerInfo(logger) if len(filesPath) > 0 { config.FilesPath = filesPath } if len(webPath) > 0 { config.WebPath = webPath } if port > 0 { config.Port = port } if len(dbConfigString) > 0 { config.DBConfigString = dbConfigString } singleUser := len(singleUserToken) > 0 db, err := server.NewStore(config, singleUser, logger) if err != nil { logger.Fatal("server.NewStore ERROR", mlog.Err(err)) } permissionsService := localpermissions.New(db, logger) params := server.Params{ Cfg: config, SingleUserToken: singleUserToken, DBStore: db, Logger: logger, PermissionsService: permissionsService, } pServer, err = server.New(params) if err != nil { logger.Fatal("server.New ERROR", mlog.Err(err)) } if err := pServer.Start(); err != nil { logger.Fatal("server.Start ERROR", mlog.Err(err)) } } func stopServer() { if pServer == nil { return } logger := pServer.Logger() err := pServer.Shutdown() if err != nil { logger.Error("server.Shutdown ERROR", mlog.Err(err)) } if l, ok := logger.(*mlog.Logger); ok { _ = l.Shutdown() } pServer = nil } func defaultLoggingConfig() string { return ` { "def": { "type": "console", "options": { "out": "stdout" }, "format": "plain", "format_options": { "delim": " ", "min_level_len": 5, "min_msg_len": 40, "enable_color": true, "enable_caller": true }, "levels": [ {"id": 5, "name": "debug"}, {"id": 4, "name": "info", "color": 36}, {"id": 3, "name": "warn"}, {"id": 2, "name": "error", "color": 31}, {"id": 1, "name": "fatal", "stacktrace": true}, {"id": 0, "name": "panic", "stacktrace": true} ] } }` } ================================================ FILE: server/model/auth.go ================================================ package model import ( "encoding/json" "fmt" "io" "strings" "github.com/mattermost/focalboard/server/services/auth" ) const ( MinimumPasswordLength = 8 ) func NewErrAuthParam(msg string) *ErrAuthParam { return &ErrAuthParam{ msg: msg, } } type ErrAuthParam struct { msg string } func (pe *ErrAuthParam) Error() string { return pe.msg } // LoginRequest is a login request // swagger:model type LoginRequest struct { // Type of login, currently must be set to "normal" // required: true Type string `json:"type"` // If specified, login using username // required: false Username string `json:"username"` // If specified, login using email // required: false Email string `json:"email"` // Password // required: true Password string `json:"password"` // MFA token // required: false // swagger:ignore MfaToken string `json:"mfa_token"` } // LoginResponse is a login response // swagger:model type LoginResponse struct { // Session token // required: true Token string `json:"token"` } func LoginResponseFromJSON(data io.Reader) (*LoginResponse, error) { var resp LoginResponse if err := json.NewDecoder(data).Decode(&resp); err != nil { return nil, err } return &resp, nil } // RegisterRequest is a user registration request // swagger:model type RegisterRequest struct { // User name // required: true Username string `json:"username"` // User's email // required: true Email string `json:"email"` // Password // required: true Password string `json:"password"` // Registration authorization token // required: true Token string `json:"token"` } func (rd *RegisterRequest) IsValid() error { if strings.TrimSpace(rd.Username) == "" { return NewErrAuthParam("username is required") } if strings.TrimSpace(rd.Email) == "" { return NewErrAuthParam("email is required") } if !auth.IsEmailValid(rd.Email) { return NewErrAuthParam("invalid email format") } if rd.Password == "" { return NewErrAuthParam("password is required") } return isValidPassword(rd.Password) } // ChangePasswordRequest is a user password change request // swagger:model type ChangePasswordRequest struct { // Old password // required: true OldPassword string `json:"oldPassword"` // New password // required: true NewPassword string `json:"newPassword"` } // IsValid validates a password change request. func (rd *ChangePasswordRequest) IsValid() error { if rd.OldPassword == "" { return NewErrAuthParam("old password is required") } if rd.NewPassword == "" { return NewErrAuthParam("new password is required") } return isValidPassword(rd.NewPassword) } func isValidPassword(password string) error { if len(password) < MinimumPasswordLength { return NewErrAuthParam(fmt.Sprintf("password must be at least %d characters", MinimumPasswordLength)) } return nil } ================================================ FILE: server/model/block.go ================================================ package model import ( "encoding/json" "errors" "io" "strconv" "unicode/utf8" "github.com/mattermost/focalboard/server/services/audit" ) const ( BlockTitleMaxBytes = 65535 // Maximum size of a TEXT column in MySQL BlockTitleMaxRunes = BlockTitleMaxBytes / 4 // Assume a worst-case representation BlockFieldsMaxRunes = 800000 ) var ( ErrBlockEmptyBoardID = errors.New("boardID is empty") ErrBlockTitleSizeLimitExceeded = errors.New("block title size limit exceeded") ErrBlockFieldsSizeLimitExceeded = errors.New("block fields size limit exceeded") ) // Block is the basic data unit // swagger:model type Block struct { // The id for this block // required: true ID string `json:"id"` // The id for this block's parent block. Empty for root blocks // required: false ParentID string `json:"parentId"` // The id for user who created this block // required: true CreatedBy string `json:"createdBy"` // The id for user who last modified this block // required: true ModifiedBy string `json:"modifiedBy"` // The schema version of this block // required: true Schema int64 `json:"schema"` // The block type // required: true Type BlockType `json:"type"` // The display title // required: false Title string `json:"title"` // The block fields // required: false Fields map[string]interface{} `json:"fields"` // The creation time in miliseconds since the current epoch // required: true CreateAt int64 `json:"createAt"` // The last modified time in miliseconds since the current epoch // required: true UpdateAt int64 `json:"updateAt"` // The deleted time in miliseconds since the current epoch. Set to indicate this block is deleted // required: false DeleteAt int64 `json:"deleteAt"` // Deprecated. The workspace id that the block belongs to // required: false WorkspaceID string `json:"-"` // The board id that the block belongs to // required: true BoardID string `json:"boardId"` // Indicates if the card is limited // required: false Limited bool `json:"limited,omitempty"` } // BlockPatch is a patch for modify blocks // swagger:model type BlockPatch struct { // The id for this block's parent block. Empty for root blocks // required: false ParentID *string `json:"parentId"` // The schema version of this block // required: false Schema *int64 `json:"schema"` // The block type // required: false Type *BlockType `json:"type"` // The display title // required: false Title *string `json:"title"` // The block updated fields // required: false UpdatedFields map[string]interface{} `json:"updatedFields"` // The block removed fields // required: false DeletedFields []string `json:"deletedFields"` } // BlockPatchBatch is a batch of IDs and patches for modify blocks // swagger:model type BlockPatchBatch struct { // The id's for of the blocks to patch BlockIDs []string `json:"block_ids"` // The BlockPatches to be applied BlockPatches []BlockPatch `json:"block_patches"` } // BoardModifier is a callback that can modify each board during an import. // A cache of arbitrary data will be passed for each call and any changes // to the cache will be preserved for the next call. // Return true to import the block or false to skip import. type BoardModifier func(board *Board, cache map[string]interface{}) bool // BlockModifier is a callback that can modify each block during an import. // A cache of arbitrary data will be passed for each call and any changes // to the cache will be preserved for the next call. // Return true to import the block or false to skip import. type BlockModifier func(block *Block, cache map[string]interface{}) bool func BlocksFromJSON(data io.Reader) []*Block { var blocks []*Block _ = json.NewDecoder(data).Decode(&blocks) return blocks } // IsValid checks the block for errors before inserting, and makes // sure it complies with the requirements of a valid block. func (b *Block) IsValid() error { if b.BoardID == "" { return ErrBlockEmptyBoardID } if utf8.RuneCountInString(b.Title) > BlockTitleMaxRunes { return ErrBlockTitleSizeLimitExceeded } fieldsJSON, err := json.Marshal(b.Fields) if err != nil { return err } if utf8.RuneCountInString(string(fieldsJSON)) > BlockFieldsMaxRunes { return ErrBlockFieldsSizeLimitExceeded } return nil } // LogClone implements the `mlog.LogCloner` interface to provide a subset of Block fields for logging. func (b *Block) LogClone() interface{} { return struct { ID string ParentID string BoardID string Type BlockType }{ ID: b.ID, ParentID: b.ParentID, BoardID: b.BoardID, Type: b.Type, } } // Patch returns an update version of the block. func (p *BlockPatch) Patch(block *Block) *Block { if p.ParentID != nil { block.ParentID = *p.ParentID } if p.Schema != nil { block.Schema = *p.Schema } if p.Type != nil { block.Type = *p.Type } if p.Title != nil { block.Title = *p.Title } for key, field := range p.UpdatedFields { block.Fields[key] = field } for _, key := range p.DeletedFields { delete(block.Fields, key) } return block } type QueryBlocksOptions struct { BoardID string // if not empty then filter for blocks belonging to specified board ParentID string // if not empty then filter for blocks belonging to specified parent BlockType BlockType // if not empty and not `TypeUnknown` then filter for records of specified block type Page int // page number to select when paginating PerPage int // number of blocks per page (default=-1, meaning unlimited) } // QuerySubtreeOptions are query options that can be passed to GetSubTree methods. type QuerySubtreeOptions struct { BeforeUpdateAt int64 // if non-zero then filter for records with update_at less than BeforeUpdateAt AfterUpdateAt int64 // if non-zero then filter for records with update_at greater than AfterUpdateAt Limit uint64 // if non-zero then limit the number of returned records } // QueryBlockHistoryOptions are query options that can be passed to GetBlockHistory. type QueryBlockHistoryOptions struct { BeforeUpdateAt int64 // if non-zero then filter for records with update_at less than BeforeUpdateAt AfterUpdateAt int64 // if non-zero then filter for records with update_at greater than AfterUpdateAt Limit uint64 // if non-zero then limit the number of returned records Descending bool // if true then the records are sorted by insert_at in descending order } // QueryBoardHistoryOptions are query options that can be passed to GetBoardHistory. type QueryBoardHistoryOptions struct { BeforeUpdateAt int64 // if non-zero then filter for records with update_at less than BeforeUpdateAt AfterUpdateAt int64 // if non-zero then filter for records with update_at greater than AfterUpdateAt Limit uint64 // if non-zero then limit the number of returned records Descending bool // if true then the records are sorted by insert_at in descending order } // QueryBlockHistoryOptions are query options that can be passed to GetBlockHistory. type QueryBlockHistoryChildOptions struct { BeforeUpdateAt int64 // if non-zero then filter for records with update_at less than BeforeUpdateAt AfterUpdateAt int64 // if non-zero then filter for records with update_at greater than AfterUpdateAt Page int // page number to select when paginating PerPage int // number of blocks per page (default=-1, meaning unlimited) } func StampModificationMetadata(userID string, blocks []*Block, auditRec *audit.Record) { if userID == SingleUser { userID = "" } now := GetMillis() for i := range blocks { blocks[i].ModifiedBy = userID blocks[i].UpdateAt = now if auditRec != nil { auditRec.AddMeta("block_"+strconv.FormatInt(int64(i), 10), blocks[i]) } } } func (b *Block) ShouldBeLimited(cardLimitTimestamp int64) bool { return b.Type == TypeCard && b.UpdateAt < cardLimitTimestamp } // Returns a limited version of the block that doesn't contain the // contents of the block, only its IDs and type. func (b *Block) GetLimited() *Block { newBlock := &Block{ Title: b.Title, ID: b.ID, ParentID: b.ParentID, BoardID: b.BoardID, Schema: b.Schema, Type: b.Type, CreateAt: b.CreateAt, UpdateAt: b.UpdateAt, DeleteAt: b.DeleteAt, WorkspaceID: b.WorkspaceID, Limited: true, } if iconField, ok := b.Fields["icon"]; ok { newBlock.Fields = map[string]interface{}{ "icon": iconField, } } return newBlock } ================================================ FILE: server/model/block_test.go ================================================ package model import ( "testing" "github.com/stretchr/testify/assert" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func TestGenerateBlockIDs(t *testing.T) { t.Run("Should generate a new ID for a single block with no references", func(t *testing.T) { blockID := utils.NewID(utils.IDTypeBlock) blocks := []*Block{{ID: blockID}} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) require.NotEqual(t, blockID, blocks[0].ID) require.Zero(t, blocks[0].BoardID) require.Zero(t, blocks[0].ParentID) }) t.Run("Should generate a new ID for a single block with references", func(t *testing.T) { blockID := utils.NewID(utils.IDTypeBlock) boardID := utils.NewID(utils.IDTypeBlock) parentID := utils.NewID(utils.IDTypeBlock) blocks := []*Block{{ID: blockID, BoardID: boardID, ParentID: parentID}} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) require.NotEqual(t, blockID, blocks[0].ID) require.Equal(t, boardID, blocks[0].BoardID) require.Equal(t, parentID, blocks[0].ParentID) }) t.Run("Should generate IDs and link multiple blocks with existing references", func(t *testing.T) { blockID1 := utils.NewID(utils.IDTypeBlock) boardID1 := utils.NewID(utils.IDTypeBlock) parentID1 := utils.NewID(utils.IDTypeBlock) block1 := &Block{ID: blockID1, BoardID: boardID1, ParentID: parentID1} blockID2 := utils.NewID(utils.IDTypeBlock) boardID2 := blockID1 parentID2 := utils.NewID(utils.IDTypeBlock) block2 := &Block{ID: blockID2, BoardID: boardID2, ParentID: parentID2} blocks := []*Block{block1, block2} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) require.NotEqual(t, blockID1, blocks[0].ID) require.Equal(t, boardID1, blocks[0].BoardID) require.Equal(t, parentID1, blocks[0].ParentID) require.NotEqual(t, blockID2, blocks[1].ID) require.NotEqual(t, boardID2, blocks[1].BoardID) require.Equal(t, parentID2, blocks[1].ParentID) // blockID1 was referenced, so it should still be after the ID // changes require.Equal(t, blocks[0].ID, blocks[1].BoardID) }) t.Run("Should generate new IDs but not modify nonexisting references", func(t *testing.T) { blockID1 := utils.NewID(utils.IDTypeBlock) boardID1 := "" parentID1 := utils.NewID(utils.IDTypeBlock) block1 := &Block{ID: blockID1, BoardID: boardID1, ParentID: parentID1} blockID2 := utils.NewID(utils.IDTypeBlock) boardID2 := utils.NewID(utils.IDTypeBlock) parentID2 := "" block2 := &Block{ID: blockID2, BoardID: boardID2, ParentID: parentID2} blocks := []*Block{block1, block2} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) // only the IDs should have changed require.NotEqual(t, blockID1, blocks[0].ID) require.Zero(t, blocks[0].BoardID) require.Equal(t, parentID1, blocks[0].ParentID) require.NotEqual(t, blockID2, blocks[1].ID) require.Equal(t, boardID2, blocks[1].BoardID) require.Zero(t, blocks[1].ParentID) }) t.Run("Should modify correctly multiple blocks with existing and nonexisting references", func(t *testing.T) { blockID1 := utils.NewID(utils.IDTypeBlock) boardID1 := utils.NewID(utils.IDTypeBlock) parentID1 := utils.NewID(utils.IDTypeBlock) block1 := &Block{ID: blockID1, BoardID: boardID1, ParentID: parentID1} // linked to 1 blockID2 := utils.NewID(utils.IDTypeBlock) boardID2 := blockID1 parentID2 := utils.NewID(utils.IDTypeBlock) block2 := &Block{ID: blockID2, BoardID: boardID2, ParentID: parentID2} // linked to 2 blockID3 := utils.NewID(utils.IDTypeBlock) boardID3 := blockID2 parentID3 := utils.NewID(utils.IDTypeBlock) block3 := &Block{ID: blockID3, BoardID: boardID3, ParentID: parentID3} // linked to 1 blockID4 := utils.NewID(utils.IDTypeBlock) boardID4 := blockID1 parentID4 := utils.NewID(utils.IDTypeBlock) block4 := &Block{ID: blockID4, BoardID: boardID4, ParentID: parentID4} // blocks are shuffled blocks := []*Block{block4, block2, block1, block3} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) // block 1 require.NotEqual(t, blockID1, blocks[2].ID) require.Equal(t, boardID1, blocks[2].BoardID) require.Equal(t, parentID1, blocks[2].ParentID) // block 2 require.NotEqual(t, blockID2, blocks[1].ID) require.NotEqual(t, boardID2, blocks[1].BoardID) require.Equal(t, blocks[2].ID, blocks[1].BoardID) // link to 1 require.Equal(t, parentID2, blocks[1].ParentID) // block 3 require.NotEqual(t, blockID3, blocks[3].ID) require.NotEqual(t, boardID3, blocks[3].BoardID) require.Equal(t, blocks[1].ID, blocks[3].BoardID) // link to 2 require.Equal(t, parentID3, blocks[3].ParentID) // block 4 require.NotEqual(t, blockID4, blocks[0].ID) require.NotEqual(t, boardID4, blocks[0].BoardID) require.Equal(t, blocks[2].ID, blocks[0].BoardID) // link to 1 require.Equal(t, parentID4, blocks[0].ParentID) }) t.Run("Should update content order", func(t *testing.T) { blockID1 := utils.NewID(utils.IDTypeBlock) boardID1 := utils.NewID(utils.IDTypeBlock) parentID1 := utils.NewID(utils.IDTypeBlock) block1 := &Block{ ID: blockID1, BoardID: boardID1, ParentID: parentID1, } blockID2 := utils.NewID(utils.IDTypeBlock) boardID2 := utils.NewID(utils.IDTypeBlock) parentID2 := utils.NewID(utils.IDTypeBlock) block2 := &Block{ ID: blockID2, BoardID: boardID2, ParentID: parentID2, Fields: map[string]interface{}{ "contentOrder": []interface{}{ blockID1, }, }, } blocks := []*Block{block1, block2} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) require.NotEqual(t, blockID1, blocks[0].ID) require.Equal(t, boardID1, blocks[0].BoardID) require.Equal(t, parentID1, blocks[0].ParentID) require.NotEqual(t, blockID2, blocks[1].ID) require.Equal(t, boardID2, blocks[1].BoardID) require.Equal(t, parentID2, blocks[1].ParentID) // since block 1 was referenced in block 2, // the ID should have been changed in content order block2ContentOrder, ok := block2.Fields["contentOrder"].([]interface{}) require.True(t, ok) require.NotEqual(t, blockID1, block2ContentOrder[0].(string)) require.Equal(t, blocks[0].ID, block2ContentOrder[0].(string)) }) t.Run("Should update content order when it contain slices", func(t *testing.T) { blockID1 := utils.NewID(utils.IDTypeBlock) boardID1 := utils.NewID(utils.IDTypeBlock) parentID1 := utils.NewID(utils.IDTypeBlock) block1 := &Block{ ID: blockID1, BoardID: boardID1, ParentID: parentID1, } blockID2 := utils.NewID(utils.IDTypeBlock) block2 := &Block{ ID: blockID2, BoardID: boardID1, ParentID: parentID1, } blockID3 := utils.NewID(utils.IDTypeBlock) block3 := &Block{ ID: blockID3, BoardID: boardID1, ParentID: parentID1, } blockID4 := utils.NewID(utils.IDTypeBlock) boardID2 := utils.NewID(utils.IDTypeBlock) parentID2 := utils.NewID(utils.IDTypeBlock) block4 := &Block{ ID: blockID4, BoardID: boardID2, ParentID: parentID2, Fields: map[string]interface{}{ "contentOrder": []interface{}{ blockID1, []interface{}{ blockID2, blockID3, }, }, }, } blocks := []*Block{block1, block2, block3, block4} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) require.NotEqual(t, blockID1, blocks[0].ID) require.Equal(t, boardID1, blocks[0].BoardID) require.Equal(t, parentID1, blocks[0].ParentID) require.NotEqual(t, blockID4, blocks[3].ID) require.Equal(t, boardID2, blocks[3].BoardID) require.Equal(t, parentID2, blocks[3].ParentID) // since block 1 was referenced in block 2, // the ID should have been changed in content order block4ContentOrder, ok := block4.Fields["contentOrder"].([]interface{}) require.True(t, ok) require.NotEqual(t, blockID1, block4ContentOrder[0].(string)) require.NotEqual(t, blockID2, block4ContentOrder[1].([]interface{})[0]) require.NotEqual(t, blockID3, block4ContentOrder[1].([]interface{})[1]) require.Equal(t, blocks[0].ID, block4ContentOrder[0].(string)) require.Equal(t, blocks[1].ID, block4ContentOrder[1].([]interface{})[0]) require.Equal(t, blocks[2].ID, block4ContentOrder[1].([]interface{})[1]) }) t.Run("Should update Id of default template view", func(t *testing.T) { blockID1 := utils.NewID(utils.IDTypeBlock) boardID1 := utils.NewID(utils.IDTypeBlock) parentID1 := utils.NewID(utils.IDTypeBlock) block1 := &Block{ ID: blockID1, BoardID: boardID1, ParentID: parentID1, } blockID2 := utils.NewID(utils.IDTypeBlock) boardID2 := utils.NewID(utils.IDTypeBlock) parentID2 := utils.NewID(utils.IDTypeBlock) block2 := &Block{ ID: blockID2, BoardID: boardID2, ParentID: parentID2, Fields: map[string]interface{}{ "defaultTemplateId": blockID1, }, } blocks := []*Block{block1, block2} blocks = GenerateBlockIDs(blocks, &mlog.Logger{}) require.NotEqual(t, blockID1, blocks[0].ID) require.Equal(t, boardID1, blocks[0].BoardID) require.Equal(t, parentID1, blocks[0].ParentID) require.NotEqual(t, blockID2, blocks[1].ID) require.Equal(t, boardID2, blocks[1].BoardID) require.Equal(t, parentID2, blocks[1].ParentID) block2DefaultTemplateID, ok := block2.Fields["defaultTemplateId"].(string) require.True(t, ok) require.NotEqual(t, blockID1, block2DefaultTemplateID) require.Equal(t, blocks[0].ID, block2DefaultTemplateID) }) } func TestStampModificationMetadata(t *testing.T) { t.Run("base case", func(t *testing.T) { block := &Block{} blocks := []*Block{block} assert.Empty(t, block.ModifiedBy) assert.Empty(t, block.UpdateAt) StampModificationMetadata("user_id_1", blocks, nil) assert.Equal(t, "user_id_1", blocks[0].ModifiedBy) assert.NotEmpty(t, blocks[0].UpdateAt) }) } ================================================ FILE: server/model/blockid.go ================================================ package model import ( "fmt" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // GenerateBlockIDs generates new IDs for all the blocks of the list, // keeping consistent any references that other blocks would made to // the original IDs, so a tree of blocks can get new IDs and maintain // its shape. func GenerateBlockIDs(blocks []*Block, logger mlog.LoggerIFace) []*Block { blockIDs := map[string]BlockType{} referenceIDs := map[string]bool{} for _, block := range blocks { if _, ok := blockIDs[block.ID]; !ok { blockIDs[block.ID] = block.Type } if _, ok := referenceIDs[block.BoardID]; !ok { referenceIDs[block.BoardID] = true } if _, ok := referenceIDs[block.ParentID]; !ok { referenceIDs[block.ParentID] = true } if _, ok := block.Fields["contentOrder"]; ok { contentOrder, typeOk := block.Fields["contentOrder"].([]interface{}) if !typeOk { logger.Warn( "type assertion failed for content order when saving reference block IDs", mlog.String("blockID", block.ID), mlog.String("actionType", fmt.Sprintf("%T", block.Fields["contentOrder"])), mlog.String("expectedType", "[]interface{}"), mlog.String("contentOrder", fmt.Sprintf("%v", block.Fields["contentOrder"])), ) continue } for _, blockID := range contentOrder { switch v := blockID.(type) { case []interface{}: for _, columnBlockID := range v { referenceIDs[columnBlockID.(string)] = true } case string: referenceIDs[v] = true default: } } } if _, ok := block.Fields["defaultTemplateId"]; ok { defaultTemplateID, typeOk := block.Fields["defaultTemplateId"].(string) if !typeOk { logger.Warn( "type assertion failed for default template ID when saving reference block IDs", mlog.String("blockID", block.ID), mlog.String("actionType", fmt.Sprintf("%T", block.Fields["defaultTemplateId"])), mlog.String("expectedType", "string"), mlog.String("defaultTemplateId", fmt.Sprintf("%v", block.Fields["defaultTemplateId"])), ) continue } referenceIDs[defaultTemplateID] = true } } newIDs := map[string]string{} for id, blockType := range blockIDs { for referenceID := range referenceIDs { if id == referenceID { newIDs[id] = utils.NewID(BlockType2IDType(blockType)) continue } } } getExistingOrOldID := func(id string) string { if existingID, ok := newIDs[id]; ok { return existingID } return id } getExistingOrNewID := func(id string) string { if existingID, ok := newIDs[id]; ok { return existingID } return utils.NewID(BlockType2IDType(blockIDs[id])) } newBlocks := make([]*Block, len(blocks)) for i, block := range blocks { block.ID = getExistingOrNewID(block.ID) block.BoardID = getExistingOrOldID(block.BoardID) block.ParentID = getExistingOrOldID(block.ParentID) blockMod := block if _, ok := blockMod.Fields["contentOrder"]; ok { fixFieldIDs(blockMod, "contentOrder", getExistingOrOldID, logger) } if _, ok := blockMod.Fields["cardOrder"]; ok { fixFieldIDs(blockMod, "cardOrder", getExistingOrOldID, logger) } if _, ok := blockMod.Fields["defaultTemplateId"]; ok { defaultTemplateID, typeOk := blockMod.Fields["defaultTemplateId"].(string) if !typeOk { logger.Warn( "type assertion failed for default template ID when saving reference block IDs", mlog.String("blockID", blockMod.ID), mlog.String("actionType", fmt.Sprintf("%T", blockMod.Fields["defaultTemplateId"])), mlog.String("expectedType", "string"), mlog.String("defaultTemplateId", fmt.Sprintf("%v", blockMod.Fields["defaultTemplateId"])), ) } else { blockMod.Fields["defaultTemplateId"] = getExistingOrOldID(defaultTemplateID) } } newBlocks[i] = blockMod } return newBlocks } func fixFieldIDs(block *Block, fieldName string, getExistingOrOldID func(string) string, logger mlog.LoggerIFace) { field, typeOk := block.Fields[fieldName].([]interface{}) if !typeOk { logger.Warn( "type assertion failed for JSON field when setting new block IDs", mlog.String("blockID", block.ID), mlog.String("fieldName", fieldName), mlog.String("actionType", fmt.Sprintf("%T", block.Fields[fieldName])), mlog.String("expectedType", "[]interface{}"), mlog.String("value", fmt.Sprintf("%v", block.Fields[fieldName])), ) } else { for j := range field { switch v := field[j].(type) { case string: field[j] = getExistingOrOldID(v) case []interface{}: subOrder := field[j].([]interface{}) for k := range v { subOrder[k] = getExistingOrOldID(v[k].(string)) } } } } } ================================================ FILE: server/model/blocktype.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model import ( "errors" "strings" "github.com/mattermost/focalboard/server/utils" ) // BlockType represents a block type. type BlockType string const ( TypeUnknown = "unknown" TypeBoard = "board" TypeCard = "card" TypeView = "view" TypeText = "text" TypeCheckbox = "checkbox" TypeComment = "comment" TypeImage = "image" TypeAttachment = "attachment" TypeDivider = "divider" ) func (bt BlockType) String() string { return string(bt) } // BlockTypeFromString returns an appropriate BlockType for the specified string. func BlockTypeFromString(s string) (BlockType, error) { switch strings.ToLower(s) { case "board": return TypeBoard, nil case "card": return TypeCard, nil case "view": return TypeView, nil case "text": return TypeText, nil case "checkbox": return TypeCheckbox, nil case "comment": return TypeComment, nil case "image": return TypeImage, nil case "attachment": return TypeAttachment, nil case "divider": return TypeDivider, nil } return TypeUnknown, ErrInvalidBlockType{s} } // BlockType2IDType returns an appropriate IDType for the specified BlockType. func BlockType2IDType(blockType BlockType) utils.IDType { switch blockType { case TypeBoard: return utils.IDTypeBoard case TypeCard: return utils.IDTypeCard case TypeView: return utils.IDTypeView case TypeText, TypeCheckbox, TypeComment, TypeDivider: return utils.IDTypeBlock case TypeImage, TypeAttachment: return utils.IDTypeAttachment } return utils.IDTypeNone } // ErrInvalidBlockType is returned wherever an invalid block type was provided. type ErrInvalidBlockType struct { Type string } func (e ErrInvalidBlockType) Error() string { return e.Type + " is an invalid block type." } // IsErrInvalidBlockType returns true if `err` is a IsErrInvalidBlockType or wraps one. func IsErrInvalidBlockType(err error) bool { var eibt *ErrInvalidBlockType return errors.As(err, &eibt) } ================================================ FILE: server/model/board.go ================================================ package model import ( "encoding/json" "io" "time" ) type BoardType string type BoardRole string type BoardSearchField string const ( BoardTypeOpen BoardType = "O" BoardTypePrivate BoardType = "P" ) const ( BoardRoleNone BoardRole = "" BoardRoleViewer BoardRole = "viewer" BoardRoleCommenter BoardRole = "commenter" BoardRoleEditor BoardRole = "editor" BoardRoleAdmin BoardRole = "admin" ) const ( BoardSearchFieldNone BoardSearchField = "" BoardSearchFieldTitle BoardSearchField = "title" BoardSearchFieldPropertyName BoardSearchField = "property_name" ) // Board groups a set of blocks and its layout // swagger:model type Board struct { // The ID for the board // required: true ID string `json:"id"` // The ID of the team that the board belongs to // required: true TeamID string `json:"teamId"` // The ID of the channel that the board was created from // required: false ChannelID string `json:"channelId"` // The ID of the user that created the board // required: true CreatedBy string `json:"createdBy"` // The ID of the last user that updated the board // required: true ModifiedBy string `json:"modifiedBy"` // The type of the board // required: true Type BoardType `json:"type"` // The minimum role applied when somebody joins the board // required: true MinimumRole BoardRole `json:"minimumRole"` // The title of the board // required: false Title string `json:"title"` // The description of the board // required: false Description string `json:"description"` // The icon of the board // required: false Icon string `json:"icon"` // Indicates if the board shows the description on the interface // required: false ShowDescription bool `json:"showDescription"` // Marks the template boards // required: false IsTemplate bool `json:"isTemplate"` // Marks the template boards // required: false TemplateVersion int `json:"templateVersion"` // The properties of the board // required: false Properties map[string]interface{} `json:"properties"` // The properties of the board cards // required: false CardProperties []map[string]interface{} `json:"cardProperties"` // The creation time in miliseconds since the current epoch // required: true CreateAt int64 `json:"createAt"` // The last modified time in miliseconds since the current epoch // required: true UpdateAt int64 `json:"updateAt"` // The deleted time in miliseconds since the current epoch. Set to indicate this block is deleted // required: false DeleteAt int64 `json:"deleteAt"` } // GetPropertyString returns the value of the specified property as a string, // or error if the property does not exist or is not of type string. func (b *Board) GetPropertyString(propName string) (string, error) { val, ok := b.Properties[propName] if !ok { return "", NewErrNotFound(propName) } s, ok := val.(string) if !ok { return "", ErrInvalidPropertyValueType } return s, nil } // BoardPatch is a patch for modify boards // swagger:model type BoardPatch struct { // The type of the board // required: false Type *BoardType `json:"type"` // The minimum role applied when somebody joins the board // required: false MinimumRole *BoardRole `json:"minimumRole"` // The title of the board // required: false Title *string `json:"title"` // The description of the board // required: false Description *string `json:"description"` // The icon of the board // required: false Icon *string `json:"icon"` // Indicates if the board shows the description on the interface // required: false ShowDescription *bool `json:"showDescription"` // Indicates if the board shows the description on the interface // required: false ChannelID *string `json:"channelId"` // The board updated properties // required: false UpdatedProperties map[string]interface{} `json:"updatedProperties"` // The board removed properties // required: false DeletedProperties []string `json:"deletedProperties"` // The board updated card properties // required: false UpdatedCardProperties []map[string]interface{} `json:"updatedCardProperties"` // The board removed card properties // required: false DeletedCardProperties []string `json:"deletedCardProperties"` } // BoardMember stores the information of the membership of a user on a board // swagger:model type BoardMember struct { // The ID of the board // required: true BoardID string `json:"boardId"` // The ID of the user // required: true UserID string `json:"userId"` // The independent roles of the user on the board // required: false Roles string `json:"roles"` // Minimum role because the board configuration // required: false MinimumRole string `json:"minimumRole"` // Marks the user as an admin of the board // required: true SchemeAdmin bool `json:"schemeAdmin"` // Marks the user as an editor of the board // required: true SchemeEditor bool `json:"schemeEditor"` // Marks the user as an commenter of the board // required: true SchemeCommenter bool `json:"schemeCommenter"` // Marks the user as an viewer of the board // required: true SchemeViewer bool `json:"schemeViewer"` // Marks the membership as generated by an access group // required: true Synthetic bool `json:"synthetic"` } // BoardMetadata contains metadata for a Board // swagger:model type BoardMetadata struct { // The ID for the board // required: true BoardID string `json:"boardId"` // The most recent time a descendant of this board was added, modified, or deleted // required: true DescendantLastUpdateAt int64 `json:"descendantLastUpdateAt"` // The earliest time a descendant of this board was added, modified, or deleted // required: true DescendantFirstUpdateAt int64 `json:"descendantFirstUpdateAt"` // The ID of the user that created the board // required: true CreatedBy string `json:"createdBy"` // The ID of the user that last modified the most recently modified descendant // required: true LastModifiedBy string `json:"lastModifiedBy"` } func BoardFromJSON(data io.Reader) *Board { var board *Board _ = json.NewDecoder(data).Decode(&board) return board } func BoardsFromJSON(data io.Reader) []*Board { var boards []*Board _ = json.NewDecoder(data).Decode(&boards) return boards } func BoardMemberFromJSON(data io.Reader) *BoardMember { var boardMember *BoardMember _ = json.NewDecoder(data).Decode(&boardMember) return boardMember } func BoardMembersFromJSON(data io.Reader) []*BoardMember { var boardMembers []*BoardMember _ = json.NewDecoder(data).Decode(&boardMembers) return boardMembers } func BoardMetadataFromJSON(data io.Reader) *BoardMetadata { var boardMetadata *BoardMetadata _ = json.NewDecoder(data).Decode(&boardMetadata) return boardMetadata } // Patch returns an updated version of the board. func (p *BoardPatch) Patch(board *Board) *Board { if p.Type != nil { board.Type = *p.Type } if p.Title != nil { board.Title = *p.Title } if p.MinimumRole != nil { board.MinimumRole = *p.MinimumRole } if p.Description != nil { board.Description = *p.Description } if p.Icon != nil { board.Icon = *p.Icon } if p.ShowDescription != nil { board.ShowDescription = *p.ShowDescription } if p.ChannelID != nil { board.ChannelID = *p.ChannelID } for key, property := range p.UpdatedProperties { board.Properties[key] = property } for _, key := range p.DeletedProperties { delete(board.Properties, key) } if len(p.UpdatedCardProperties) != 0 || len(p.DeletedCardProperties) != 0 { // first we accumulate all properties indexed by, and maintain their order keyOrder := []string{} cardPropertyMap := map[string]map[string]interface{}{} for _, prop := range board.CardProperties { id, ok := prop["id"].(string) if !ok { // bad property, skipping continue } cardPropertyMap[id] = prop keyOrder = append(keyOrder, id) } // if there are properties marked for removal, we delete them for _, propertyID := range p.DeletedCardProperties { delete(cardPropertyMap, propertyID) } // if there are properties marked for update, we replace the // existing ones or add them for _, newprop := range p.UpdatedCardProperties { id, ok := newprop["id"].(string) if !ok { // bad new property, skipping continue } _, exists := cardPropertyMap[id] if !exists { keyOrder = append(keyOrder, id) } cardPropertyMap[id] = newprop } // and finally we flatten and save the updated properties newCardProperties := []map[string]interface{}{} for _, key := range keyOrder { p, exists := cardPropertyMap[key] if exists { newCardProperties = append(newCardProperties, p) } } board.CardProperties = newCardProperties } return board } func IsBoardTypeValid(t BoardType) bool { return t == BoardTypeOpen || t == BoardTypePrivate } func IsBoardMinimumRoleValid(r BoardRole) bool { return r == BoardRoleNone || r == BoardRoleAdmin || r == BoardRoleEditor || r == BoardRoleCommenter || r == BoardRoleViewer } func (p *BoardPatch) IsValid() error { if p.Type != nil && !IsBoardTypeValid(*p.Type) { return InvalidBoardErr{"invalid-board-type"} } if p.MinimumRole != nil && !IsBoardMinimumRoleValid(*p.MinimumRole) { return InvalidBoardErr{"invalid-board-minimum-role"} } return nil } type InvalidBoardErr struct { msg string } func (ibe InvalidBoardErr) Error() string { return ibe.msg } func (b *Board) IsValid() error { if b.TeamID == "" { return InvalidBoardErr{"empty-team-id"} } if !IsBoardTypeValid(b.Type) { return InvalidBoardErr{"invalid-board-type"} } if !IsBoardMinimumRoleValid(b.MinimumRole) { return InvalidBoardErr{"invalid-board-minimum-role"} } return nil } // BoardMemberHistoryEntry stores the information of the membership of a user on a board // swagger:model type BoardMemberHistoryEntry struct { // The ID of the board // required: true BoardID string `json:"boardId"` // The ID of the user // required: true UserID string `json:"userId"` // The action that added this history entry (created or deleted) // required: false Action string `json:"action"` // The insertion time // required: true InsertAt time.Time `json:"insertAt"` } func BoardSearchFieldFromString(field string) (BoardSearchField, error) { switch field { case string(BoardSearchFieldTitle): return BoardSearchFieldTitle, nil case string(BoardSearchFieldPropertyName): return BoardSearchFieldPropertyName, nil } return BoardSearchFieldNone, ErrInvalidBoardSearchField } ================================================ FILE: server/model/board_statistics.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model // BoardsStatistics is the representation of the statistics for the Boards server // swagger:model type BoardsStatistics struct { // The maximum number of cards on the server // required: true Boards int `json:"board_count"` // The maximum number of cards on the server // required: true Cards int `json:"card_count"` } ================================================ FILE: server/model/boards_and_blocks.go ================================================ package model import ( "encoding/json" "errors" "fmt" "io" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var ErrNoBoardsInBoardsAndBlocks = errors.New("at least one board is required") var ErrNoBlocksInBoardsAndBlocks = errors.New("at least one block is required") var ErrNoTeamInBoardsAndBlocks = errors.New("team ID cannot be empty") var ErrBoardIDsAndPatchesMissmatchInBoardsAndBlocks = errors.New("board ids and patches need to match") var ErrBlockIDsAndPatchesMissmatchInBoardsAndBlocks = errors.New("block ids and patches need to match") type BlockDoesntBelongToAnyBoardErr struct { blockID string } func (e BlockDoesntBelongToAnyBoardErr) Error() string { return fmt.Sprintf("block %s doesn't belong to any board", e.blockID) } // BoardsAndBlocks is used to operate over boards and blocks at the // same time // swagger:model type BoardsAndBlocks struct { // The boards // required: false Boards []*Board `json:"boards"` // The blocks // required: false Blocks []*Block `json:"blocks"` } func (bab *BoardsAndBlocks) IsValid() error { if len(bab.Boards) == 0 { return ErrNoBoardsInBoardsAndBlocks } if len(bab.Blocks) == 0 { return ErrNoBlocksInBoardsAndBlocks } boardsMap := map[string]bool{} for _, board := range bab.Boards { boardsMap[board.ID] = true } for _, block := range bab.Blocks { if _, ok := boardsMap[block.BoardID]; !ok { return BlockDoesntBelongToAnyBoardErr{block.ID} } } return nil } // DeleteBoardsAndBlocks is used to list the boards and blocks to // delete on a request // swagger:model type DeleteBoardsAndBlocks struct { // The boards // required: true Boards []string `json:"boards"` // The blocks // required: true Blocks []string `json:"blocks"` } func NewDeleteBoardsAndBlocksFromBabs(babs *BoardsAndBlocks) *DeleteBoardsAndBlocks { boardIDs := make([]string, 0, len(babs.Boards)) blockIDs := make([]string, 0, len(babs.Boards)) for _, board := range babs.Boards { boardIDs = append(boardIDs, board.ID) } for _, block := range babs.Blocks { blockIDs = append(blockIDs, block.ID) } return &DeleteBoardsAndBlocks{ Boards: boardIDs, Blocks: blockIDs, } } func (dbab *DeleteBoardsAndBlocks) IsValid() error { if len(dbab.Boards) == 0 { return ErrNoBoardsInBoardsAndBlocks } return nil } // PatchBoardsAndBlocks is used to patch multiple boards and blocks on // a single request // swagger:model type PatchBoardsAndBlocks struct { // The board IDs to patch // required: true BoardIDs []string `json:"boardIDs"` // The board patches // required: true BoardPatches []*BoardPatch `json:"boardPatches"` // The block IDs to patch // required: true BlockIDs []string `json:"blockIDs"` // The block patches // required: true BlockPatches []*BlockPatch `json:"blockPatches"` } func (dbab *PatchBoardsAndBlocks) IsValid() error { if len(dbab.BoardIDs) == 0 { return ErrNoBoardsInBoardsAndBlocks } if len(dbab.BoardIDs) != len(dbab.BoardPatches) { return ErrBoardIDsAndPatchesMissmatchInBoardsAndBlocks } if len(dbab.BlockIDs) != len(dbab.BlockPatches) { return ErrBlockIDsAndPatchesMissmatchInBoardsAndBlocks } return nil } func GenerateBoardsAndBlocksIDs(bab *BoardsAndBlocks, logger mlog.LoggerIFace) (*BoardsAndBlocks, error) { if err := bab.IsValid(); err != nil { return nil, err } blocksByBoard := map[string][]*Block{} for _, block := range bab.Blocks { blocksByBoard[block.BoardID] = append(blocksByBoard[block.BoardID], block) } boards := []*Board{} blocks := []*Block{} for _, board := range bab.Boards { newID := utils.NewID(utils.IDTypeBoard) for _, block := range blocksByBoard[board.ID] { block.BoardID = newID blocks = append(blocks, block) } board.ID = newID boards = append(boards, board) } newBab := &BoardsAndBlocks{ Boards: boards, Blocks: GenerateBlockIDs(blocks, logger), } return newBab, nil } func BoardsAndBlocksFromJSON(data io.Reader) *BoardsAndBlocks { var bab *BoardsAndBlocks _ = json.NewDecoder(data).Decode(&bab) return bab } ================================================ FILE: server/model/boards_and_blocks_test.go ================================================ package model import ( "testing" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func TestIsValidBoardsAndBlocks(t *testing.T) { t.Run("no boards", func(t *testing.T) { bab := &BoardsAndBlocks{ Blocks: []*Block{ {ID: "block-id-1", BoardID: "board-id-1", Type: TypeCard}, {ID: "block-id-2", BoardID: "board-id-2", Type: TypeCard}, }, } require.ErrorIs(t, bab.IsValid(), ErrNoBoardsInBoardsAndBlocks) }) t.Run("no blocks", func(t *testing.T) { bab := &BoardsAndBlocks{ Boards: []*Board{ {ID: "board-id-1", Type: BoardTypeOpen}, {ID: "board-id-2", Type: BoardTypePrivate}, }, } require.ErrorIs(t, bab.IsValid(), ErrNoBlocksInBoardsAndBlocks) }) t.Run("block that doesn't belong to the boards", func(t *testing.T) { bab := &BoardsAndBlocks{ Boards: []*Board{ {ID: "board-id-1", Type: BoardTypeOpen}, {ID: "board-id-2", Type: BoardTypePrivate}, }, Blocks: []*Block{ {ID: "block-id-1", BoardID: "board-id-1", Type: TypeCard}, {ID: "block-id-3", BoardID: "board-id-3", Type: TypeCard}, {ID: "block-id-2", BoardID: "board-id-2", Type: TypeCard}, }, } require.ErrorIs(t, bab.IsValid(), BlockDoesntBelongToAnyBoardErr{"block-id-3"}) }) t.Run("valid boards and blocks", func(t *testing.T) { bab := &BoardsAndBlocks{ Boards: []*Board{ {ID: "board-id-1", Type: BoardTypeOpen}, {ID: "board-id-2", Type: BoardTypePrivate}, }, Blocks: []*Block{ {ID: "block-id-1", BoardID: "board-id-1", Type: TypeCard}, {ID: "block-id-3", BoardID: "board-id-2", Type: TypeCard}, {ID: "block-id-2", BoardID: "board-id-2", Type: TypeCard}, }, } require.NoError(t, bab.IsValid()) }) } func TestGenerateBoardsAndBlocksIDs(t *testing.T) { logger, err := mlog.NewLogger() require.NoError(t, err) getBlockByType := func(blocks []*Block, blockType BlockType) *Block { for _, b := range blocks { if b.Type == blockType { return b } } return &Block{} } getBoardByTitle := func(boards []*Board, title string) *Board { for _, b := range boards { if b.Title == title { return b } } return nil } t.Run("invalid boards and blocks", func(t *testing.T) { bab := &BoardsAndBlocks{ Blocks: []*Block{ {ID: "block-id-1", BoardID: "board-id-1", Type: TypeCard}, {ID: "block-id-2", BoardID: "board-id-2", Type: TypeCard}, }, } rBab, err := GenerateBoardsAndBlocksIDs(bab, logger) require.Error(t, err) require.Nil(t, rBab) }) t.Run("correctly generates IDs for all the boards and links the blocks to them, with new IDs too", func(t *testing.T) { bab := &BoardsAndBlocks{ Boards: []*Board{ {ID: "board-id-1", Type: BoardTypeOpen, Title: "board1"}, {ID: "board-id-2", Type: BoardTypePrivate, Title: "board2"}, {ID: "board-id-3", Type: BoardTypeOpen, Title: "board3"}, }, Blocks: []*Block{ {ID: "block-id-1", BoardID: "board-id-1", Type: TypeCard}, {ID: "block-id-2", BoardID: "board-id-2", Type: TypeView}, {ID: "block-id-3", BoardID: "board-id-2", Type: TypeText}, }, } rBab, err := GenerateBoardsAndBlocksIDs(bab, logger) require.NoError(t, err) require.NotNil(t, rBab) // all boards and blocks should have refreshed their IDs, and // blocks should be correctly linked to the new board IDs board1 := getBoardByTitle(rBab.Boards, "board1") require.NotNil(t, board1) require.NotEmpty(t, board1.ID) require.NotEqual(t, "board-id-1", board1.ID) board2 := getBoardByTitle(rBab.Boards, "board2") require.NotNil(t, board2) require.NotEmpty(t, board2.ID) require.NotEqual(t, "board-id-2", board2.ID) board3 := getBoardByTitle(rBab.Boards, "board3") require.NotNil(t, board3) require.NotEmpty(t, board3.ID) require.NotEqual(t, "board-id-3", board3.ID) block1 := getBlockByType(rBab.Blocks, TypeCard) require.NotNil(t, block1) require.NotEmpty(t, block1.ID) require.NotEqual(t, "block-id-1", block1.ID) require.Equal(t, board1.ID, block1.BoardID) block2 := getBlockByType(rBab.Blocks, TypeView) require.NotNil(t, block2) require.NotEmpty(t, block2.ID) require.NotEqual(t, "block-id-2", block2.ID) require.Equal(t, board2.ID, block2.BoardID) block3 := getBlockByType(rBab.Blocks, TypeText) require.NotNil(t, block3) require.NotEmpty(t, block3.ID) require.NotEqual(t, "block-id-3", block3.ID) require.Equal(t, board2.ID, block3.BoardID) }) } func TestIsValidPatchBoardsAndBlocks(t *testing.T) { newTitle := "new title" newDescription := "new description" var schema int64 = 1 t.Run("no board ids", func(t *testing.T) { pbab := &PatchBoardsAndBlocks{ BoardIDs: []string{}, BlockIDs: []string{"block-id-1"}, BlockPatches: []*BlockPatch{ {Title: &newTitle}, {Schema: &schema}, }, } require.ErrorIs(t, pbab.IsValid(), ErrNoBoardsInBoardsAndBlocks) }) t.Run("missmatch board IDs and patches", func(t *testing.T) { pbab := &PatchBoardsAndBlocks{ BoardIDs: []string{"board-id-1", "board-id-2"}, BoardPatches: []*BoardPatch{ {Title: &newTitle}, }, BlockIDs: []string{"block-id-1"}, BlockPatches: []*BlockPatch{ {Title: &newTitle}, }, } require.ErrorIs(t, pbab.IsValid(), ErrBoardIDsAndPatchesMissmatchInBoardsAndBlocks) }) t.Run("missmatch block IDs and patches", func(t *testing.T) { pbab := &PatchBoardsAndBlocks{ BoardIDs: []string{"board-id-1", "board-id-2"}, BoardPatches: []*BoardPatch{ {Title: &newTitle}, {Description: &newDescription}, }, BlockIDs: []string{"block-id-1"}, BlockPatches: []*BlockPatch{ {Title: &newTitle}, {Schema: &schema}, }, } require.ErrorIs(t, pbab.IsValid(), ErrBlockIDsAndPatchesMissmatchInBoardsAndBlocks) }) t.Run("valid", func(t *testing.T) { pbab := &PatchBoardsAndBlocks{ BoardIDs: []string{"board-id-1", "board-id-2"}, BoardPatches: []*BoardPatch{ {Title: &newTitle}, {Description: &newDescription}, }, BlockIDs: []string{"block-id-1"}, BlockPatches: []*BlockPatch{ {Title: &newTitle}, }, } require.NoError(t, pbab.IsValid()) }) } func TestIsValidDeleteBoardsAndBlocks(t *testing.T) { /* TODO fix this t.Run("no board ids", func(t *testing.T) { dbab := &DeleteBoardsAndBlocks{ TeamID: "team-id", Blocks: []string{"block-id-1"}, } require.ErrorIs(t, dbab.IsValid(), NoBoardsInBoardsAndBlocksErr) }) t.Run("no block ids", func(t *testing.T) { dbab := &DeleteBoardsAndBlocks{ TeamID: "team-id", Boards: []string{"board-id-1", "board-id-2"}, } require.ErrorIs(t, dbab.IsValid(), NoBlocksInBoardsAndBlocksErr) }) t.Run("valid", func(t *testing.T) { dbab := &DeleteBoardsAndBlocks{ TeamID: "team-id", Boards: []string{"board-id-1", "board-id-2"}, Blocks: []string{"block-id-1"}, } require.NoError(t, dbab.IsValid()) }) */ } ================================================ FILE: server/model/card.go ================================================ package model import ( "errors" "fmt" "github.com/mattermost/focalboard/server/utils" "github.com/rivo/uniseg" ) var ErrBoardIDMismatch = errors.New("Board IDs do not match") type ErrInvalidCard struct { msg string } func NewErrInvalidCard(msg string) ErrInvalidCard { return ErrInvalidCard{ msg: msg, } } func (e ErrInvalidCard) Error() string { return fmt.Sprintf("invalid card, %s", e.msg) } var ErrNotCardBlock = errors.New("not a card block") type ErrInvalidFieldType struct { field string } func (e ErrInvalidFieldType) Error() string { return fmt.Sprintf("invalid type for field '%s'", e.field) } // Card represents a group of content blocks and properties. // swagger:model type Card struct { // The id for this card // required: false ID string `json:"id"` // The id for board this card belongs to. // required: false BoardID string `json:"boardId"` // The id for user who created this card // required: false CreatedBy string `json:"createdBy"` // The id for user who last modified this card // required: false ModifiedBy string `json:"modifiedBy"` // The display title // required: false Title string `json:"title"` // An array of content block ids specifying the ordering of content for this card. // required: false ContentOrder []string `json:"contentOrder"` // The icon of the card // required: false Icon string `json:"icon"` // True if this card belongs to a template // required: false IsTemplate bool `json:"isTemplate"` // A map of property ids to property values (option ids, strings, array of option ids) // required: false Properties map[string]any `json:"properties"` // The creation time in milliseconds since the current epoch // required: false CreateAt int64 `json:"createAt"` // The last modified time in milliseconds since the current epoch // required: false UpdateAt int64 `json:"updateAt"` // The deleted time in milliseconds since the current epoch. Set to indicate this card is deleted // required: false DeleteAt int64 `json:"deleteAt"` } // Populate populates a Card with default values. func (c *Card) Populate() { if c.ID == "" { c.ID = utils.NewID(utils.IDTypeCard) } if c.ContentOrder == nil { c.ContentOrder = make([]string, 0) } if c.Properties == nil { c.Properties = make(map[string]any) } now := utils.GetMillis() if c.CreateAt == 0 { c.CreateAt = now } if c.UpdateAt == 0 { c.UpdateAt = now } } func (c *Card) PopulateWithBoardID(boardID string) { c.BoardID = boardID c.Populate() } // CheckValid returns an error if the Card has invalid field values. func (c *Card) CheckValid() error { if c.ID == "" { return ErrInvalidCard{"ID is missing"} } if c.BoardID == "" { return ErrInvalidCard{"BoardID is missing"} } if c.ContentOrder == nil { return ErrInvalidCard{"ContentOrder is missing"} } if uniseg.GraphemeClusterCount(c.Icon) > 1 { return ErrInvalidCard{"Icon can have only one grapheme"} } if c.Properties == nil { return ErrInvalidCard{"Properties"} } if c.CreateAt == 0 { return ErrInvalidCard{"CreateAt"} } if c.UpdateAt == 0 { return ErrInvalidCard{"UpdateAt"} } return nil } // CardPatch is a patch for modifying cards // swagger:model type CardPatch struct { // The display title // required: false Title *string `json:"title"` // An array of content block ids specifying the ordering of content for this card. // required: false ContentOrder *[]string `json:"contentOrder"` // The icon of the card // required: false Icon *string `json:"icon"` // A map of property ids to property option ids to be updated // required: false UpdatedProperties map[string]any `json:"updatedProperties"` } // Patch returns an updated version of the card. func (p *CardPatch) Patch(card *Card) *Card { if p.Title != nil { card.Title = *p.Title } if p.ContentOrder != nil { card.ContentOrder = *p.ContentOrder } if p.Icon != nil { card.Icon = *p.Icon } if card.Properties == nil { card.Properties = make(map[string]any) } // if there are properties marked for update, we replace the // existing ones or add them for propID, propVal := range p.UpdatedProperties { card.Properties[propID] = propVal } return card } // CheckValid returns an error if the CardPatch has invalid field values. func (p *CardPatch) CheckValid() error { if p.Icon != nil && uniseg.GraphemeClusterCount(*p.Icon) > 1 { return ErrInvalidCard{"Icon can have only one grapheme"} } return nil } // Card2Block converts a card to block using a shallow copy. Not needed once cards are first class entities. func Card2Block(card *Card) *Block { fields := make(map[string]interface{}) fields["contentOrder"] = card.ContentOrder fields["icon"] = card.Icon fields["isTemplate"] = card.IsTemplate fields["properties"] = card.Properties return &Block{ ID: card.ID, ParentID: card.BoardID, CreatedBy: card.CreatedBy, ModifiedBy: card.ModifiedBy, Schema: 1, Type: TypeCard, Title: card.Title, Fields: fields, CreateAt: card.CreateAt, UpdateAt: card.UpdateAt, DeleteAt: card.DeleteAt, BoardID: card.BoardID, } } // Block2Card converts a block to a card. Not needed once cards are first class entities. func Block2Card(block *Block) (*Card, error) { if block.Type != TypeCard { return nil, fmt.Errorf("cannot convert block to card: %w", ErrNotCardBlock) } contentOrder := make([]string, 0) icon := "" isTemplate := false properties := make(map[string]any) if co, ok := block.Fields["contentOrder"]; ok { switch arr := co.(type) { case []any: for _, str := range arr { if id, ok := str.(string); ok { contentOrder = append(contentOrder, id) } else { return nil, ErrInvalidFieldType{"contentOrder item"} } } case []string: contentOrder = append(contentOrder, arr...) default: return nil, ErrInvalidFieldType{"contentOrder"} } } if iconAny, ok := block.Fields["icon"]; ok { if id, ok := iconAny.(string); ok { icon = id } else { return nil, ErrInvalidFieldType{"icon"} } } if isTemplateAny, ok := block.Fields["isTemplate"]; ok { if b, ok := isTemplateAny.(bool); ok { isTemplate = b } else { return nil, ErrInvalidFieldType{"isTemplate"} } } if props, ok := block.Fields["properties"]; ok { if propMap, ok := props.(map[string]any); ok { for k, v := range propMap { properties[k] = v } } else { return nil, ErrInvalidFieldType{"properties"} } } card := &Card{ ID: block.ID, BoardID: block.BoardID, CreatedBy: block.CreatedBy, ModifiedBy: block.ModifiedBy, Title: block.Title, ContentOrder: contentOrder, Icon: icon, IsTemplate: isTemplate, Properties: properties, CreateAt: block.CreateAt, UpdateAt: block.UpdateAt, DeleteAt: block.DeleteAt, } card.Populate() return card, nil } // CardPatch2BlockPatch converts a CardPatch to a BlockPatch. Not needed once cards are first class entities. func CardPatch2BlockPatch(cardPatch *CardPatch) (*BlockPatch, error) { if err := cardPatch.CheckValid(); err != nil { return nil, err } blockPatch := &BlockPatch{ Title: cardPatch.Title, } updatedFields := make(map[string]any, 0) if cardPatch.ContentOrder != nil { updatedFields["contentOrder"] = cardPatch.ContentOrder } if cardPatch.Icon != nil { updatedFields["icon"] = cardPatch.Icon } properties := make(map[string]any) for k, v := range cardPatch.UpdatedProperties { properties[k] = v } if len(properties) != 0 { updatedFields["properties"] = cardPatch.UpdatedProperties } blockPatch.UpdatedFields = updatedFields return blockPatch, nil } ================================================ FILE: server/model/card_test.go ================================================ package model import ( "encoding/json" "testing" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBlock2Card(t *testing.T) { blockID := utils.NewID(utils.IDTypeCard) boardID := utils.NewID(utils.IDTypeBoard) userID := utils.NewID(utils.IDTypeUser) now := utils.GetMillis() var fields map[string]any err := json.Unmarshal([]byte(sampleBlockFieldsJSON), &fields) require.NoError(t, err) block := &Block{ ID: blockID, ParentID: boardID, CreatedBy: userID, ModifiedBy: userID, Schema: 1, Type: TypeCard, Title: "My card title", Fields: fields, CreateAt: now, UpdateAt: now, DeleteAt: 0, BoardID: boardID, } t.Run("Good block", func(t *testing.T) { card, err := Block2Card(block) require.NoError(t, err) assert.Equal(t, block.ID, card.ID) assert.Equal(t, []string{"acdxa8r8aht85pyoeuj1ed7tu8w", "73urm1huoupd4idzkdq5yaeuyay", "ay6sogs9owtd9xbyn49qt3395ko"}, card.ContentOrder) assert.EqualValues(t, fields["icon"], card.Icon) assert.EqualValues(t, fields["isTemplate"], card.IsTemplate) assert.EqualValues(t, fields["properties"], card.Properties) }) t.Run("Not a card", func(t *testing.T) { blockNotCard := &Block{} card, err := Block2Card(blockNotCard) require.Error(t, err) require.Nil(t, card) }) } const sampleBlockFieldsJSON = ` { "contentOrder":[ "acdxa8r8aht85pyoeuj1ed7tu8w", "73urm1huoupd4idzkdq5yaeuyay", "ay6sogs9owtd9xbyn49qt3395ko" ], "icon":"🎨", "isTemplate":false, "properties":{ "aa7swu9zz3ofdkcna3h867cum4y":"212-444-1234", "af6fcbb8-ca56-4b73-83eb-37437b9a667d":"77c539af-309c-4db1-8329-d20ef7e9eacd", "aiwt9ibi8jjrf9hzi1xzk8no8mo":"foo", "aj65h4s6ghr6wgh3bnhqbzzmiaa":"77", "ajy6xbebzopojaenbnmfpgtdwso":"{\"from\":1660046400000}", "amc8wnk1xqj54rymkoqffhtw7ie":"zhqsoeqs1pg9i8gk81k9ryy83h", "aooz77t119y7xtfmoyeiy4up75c":"someone@example.com", "auskzaoaccsn55icuwarf4o3tfe":"https://www.google.com", "aydsk41h6cs1z7nmghaw16jqcia":[ "aw565znut6zphbxqhbwyawiuggy", "aefd3pxciomrkur4rc6smg1usoc", "a6c96kwrqaskbtochq9wunmzweh", "atyexeuq993fwwb84bxoqixxqqr" ], "d6b1249b-bc18-45fc-889e-bec48fce80ef":"9a090e33-b110-4268-8909-132c5002c90e", "d9725d14-d5a8-48e5-8de1-6f8c004a9680":"3245a32d-f688-463b-87f4-8e7142c1b397" } }` ================================================ FILE: server/model/category.go ================================================ package model import ( "encoding/json" "fmt" "io" "strings" "github.com/mattermost/focalboard/server/utils" ) const ( CategoryTypeSystem = "system" CategoryTypeCustom = "custom" ) // Category is a board category // swagger:model type Category struct { // The id for this category // required: true ID string `json:"id"` // The name for this category // required: true Name string `json:"name"` // The user's id for this category // required: true UserID string `json:"userID"` // The team id for this category // required: true TeamID string `json:"teamID"` // The creation time in miliseconds since the current epoch // required: true CreateAt int64 `json:"createAt"` // The last modified time in miliseconds since the current epoch // required: true UpdateAt int64 `json:"updateAt"` // The deleted time in miliseconds since the current epoch. Set to indicate this category is deleted // required: false DeleteAt int64 `json:"deleteAt"` // Category's state in client side // required: true Collapsed bool `json:"collapsed"` // Inter-category sort order per user // required: true SortOrder int `json:"sortOrder"` // The sorting method applied on this category // required: true Sorting string `json:"sorting"` // Category's type // required: true Type string `json:"type"` } func (c *Category) Hydrate() { if c.ID == "" { c.ID = utils.NewID(utils.IDTypeNone) } if c.CreateAt == 0 { c.CreateAt = utils.GetMillis() } if c.UpdateAt == 0 { c.UpdateAt = c.CreateAt } if c.SortOrder < 0 { c.SortOrder = 0 } if strings.TrimSpace(c.Type) == "" { c.Type = CategoryTypeCustom } } func (c *Category) IsValid() error { if strings.TrimSpace(c.ID) == "" { return NewErrInvalidCategory("category ID cannot be empty") } if strings.TrimSpace(c.Name) == "" { return NewErrInvalidCategory("category name cannot be empty") } if strings.TrimSpace(c.UserID) == "" { return NewErrInvalidCategory("category user ID cannot be empty") } if strings.TrimSpace(c.TeamID) == "" { return NewErrInvalidCategory("category team id ID cannot be empty") } if c.Type != CategoryTypeCustom && c.Type != CategoryTypeSystem { return NewErrInvalidCategory(fmt.Sprintf("category type is invalid. Allowed types: %s and %s", CategoryTypeSystem, CategoryTypeCustom)) } return nil } func CategoryFromJSON(data io.Reader) *Category { var category *Category _ = json.NewDecoder(data).Decode(&category) return category } ================================================ FILE: server/model/category_boards.go ================================================ package model const CategoryBoardsSortOrderGap = 10 // CategoryBoards is a board category and associated boards // swagger:model type CategoryBoards struct { Category // The IDs of boards in this category // required: true BoardMetadata []CategoryBoardMetadata `json:"boardMetadata"` // The relative sort order of this board in its category // required: true SortOrder int `json:"sortOrder"` } type BoardCategoryWebsocketData struct { BoardID string `json:"boardID"` CategoryID string `json:"categoryID"` Hidden bool `json:"hidden"` } type CategoryBoardMetadata struct { BoardID string `json:"boardID"` Hidden bool `json:"hidden"` } ================================================ FILE: server/model/clientConfig.go ================================================ package model // ClientConfig is the client configuration // swagger:model type ClientConfig struct { // Is telemetry enabled // required: true Telemetry bool `json:"telemetry"` // The telemetry ID // required: true TelemetryID string `json:"telemetryid"` // Is public shared boards enabled // required: true EnablePublicSharedBoards bool `json:"enablePublicSharedBoards"` // Is public shared boards enabled // required: true TeammateNameDisplay string `json:"teammateNameDisplay"` // The server feature flags // required: true FeatureFlags map[string]string `json:"featureFlags"` // Required for file upload to check the size of the file // required: true MaxFileSize int64 `json:"maxFileSize"` } ================================================ FILE: server/model/cloud.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model const LimitUnlimited = 0 // BoardsCloudLimits is the representation of the limits for the // Boards server // swagger:model type BoardsCloudLimits struct { // The maximum number of cards on the server // required: true Cards int `json:"cards"` // The current number of cards on the server // required: true UsedCards int `json:"used_cards"` // The updated_at timestamp of the limit card // required: true CardLimitTimestamp int64 `json:"card_limit_timestamp"` // The maximum number of views for each board // required: true Views int `json:"views"` } ================================================ FILE: server/model/compliance.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model // BaordsComplianceResponse is the response body to a request for boards. // swagger:model type BoardsComplianceResponse struct { // True if there is a next page for pagination // required: true HasNext bool `json:"hasNext"` // The array of board records. // required: true Results []*Board `json:"results"` } // BoardsComplianceHistoryResponse is the response body to a request for boards history. // swagger:model type BoardsComplianceHistoryResponse struct { // True if there is a next page for pagination // required: true HasNext bool `json:"hasNext"` // The array of BoardHistory records. // required: true Results []*BoardHistory `json:"results"` } // BlocksComplianceHistoryResponse is the response body to a request for blocks history. // swagger:model type BlocksComplianceHistoryResponse struct { // True if there is a next page for pagination // required: true HasNext bool `json:"hasNext"` // The array of BlockHistory records. // required: true Results []*BlockHistory `json:"results"` } // BoardHistory provides information about the history of a board. // swagger:model type BoardHistory struct { ID string `json:"id"` TeamID string `json:"teamId"` IsDeleted bool `json:"isDeleted"` DescendantLastUpdateAt int64 `json:"descendantLastUpdateAt"` DescendantFirstUpdateAt int64 `json:"descendantFirstUpdateAt"` CreatedBy string `json:"createdBy"` LastModifiedBy string `json:"lastModifiedBy"` } // BlockHistory provides information about the history of a block. // swagger:model type BlockHistory struct { ID string `json:"id"` TeamID string `json:"teamId"` BoardID string `json:"boardId"` Type string `json:"type"` IsDeleted bool `json:"isDeleted"` LastUpdateAt int64 `json:"lastUpdateAt"` FirstUpdateAt int64 `json:"firstUpdateAt"` CreatedBy string `json:"createdBy"` LastModifiedBy string `json:"lastModifiedBy"` } type QueryBoardsForComplianceOptions struct { TeamID string // if not empty then filter for specific team, otherwise all teams are included Page int // page number to select when paginating PerPage int // number of blocks per page (default=60) } type QueryBoardsComplianceHistoryOptions struct { ModifiedSince int64 // if non-zero then filter for records with update_at greater than ModifiedSince IncludeDeleted bool // if true then deleted blocks are included TeamID string // if not empty then filter for specific team, otherwise all teams are included Page int // page number to select when paginating PerPage int // number of blocks per page (default=60) } type QueryBlocksComplianceHistoryOptions struct { ModifiedSince int64 // if non-zero then filter for records with update_at greater than ModifiedSince IncludeDeleted bool // if true then deleted blocks are included TeamID string // if not empty then filter for specific team, otherwise all teams are included BoardID string // if not empty then filter for specific board, otherwise all boards are included Page int // page number to select when paginating PerPage int // number of blocks per page (default=60) } ================================================ FILE: server/model/database.go ================================================ package model const ( SqliteDBType = "sqlite3" PostgresDBType = "postgres" MysqlDBType = "mysql" ) ================================================ FILE: server/model/error.go ================================================ package model import ( "database/sql" "errors" "fmt" "net/http" "strings" mmModel "github.com/mattermost/mattermost/server/public/model" pluginapi "github.com/mattermost/mattermost/server/public/pluginapi" ) var ( ErrViewsLimitReached = errors.New("views limit reached for board") ErrPatchUpdatesLimitedCards = errors.New("patch updates cards that are limited") ErrInsufficientLicense = errors.New("appropriate license required") ErrCategoryPermissionDenied = errors.New("category doesn't belong to user") ErrCategoryDeleted = errors.New("category is deleted") ErrBoardMemberIsLastAdmin = errors.New("cannot leave a board with no admins") ErrRequestEntityTooLarge = errors.New("request entity too large") ErrInvalidBoardSearchField = errors.New("invalid board search field") ) // ErrNotFound is an error type that can be returned by store APIs // when a query unexpectedly fetches no records. type ErrNotFound struct { entity string } // NewErrNotFound creates a new ErrNotFound instance. func NewErrNotFound(entity string) *ErrNotFound { return &ErrNotFound{ entity: entity, } } func (nf *ErrNotFound) Error() string { return fmt.Sprintf("{%s} not found", nf.entity) } // ErrNotAllFound is an error type that can be returned by store APIs // when a query that should fetch a certain amount of records // unexpectedly fetches less. type ErrNotAllFound struct { entity string resources []string } func NewErrNotAllFound(entity string, resources []string) *ErrNotAllFound { return &ErrNotAllFound{ entity: entity, resources: resources, } } func (naf *ErrNotAllFound) Error() string { return fmt.Sprintf("not all instances of {%s} in {%s} found", naf.entity, strings.Join(naf.resources, ", ")) } // ErrBadRequest can be returned when the API handler receives a // malformed request. type ErrBadRequest struct { reason string } // NewErrNotFound creates a new ErrNotFound instance. func NewErrBadRequest(reason string) *ErrBadRequest { return &ErrBadRequest{ reason: reason, } } func (br *ErrBadRequest) Error() string { return br.reason } // ErrUnauthorized can be returned when requester has provided an // invalid authorization for a given resource or has not provided any. type ErrUnauthorized struct { reason string } // NewErrUnauthorized creates a new ErrUnauthorized instance. func NewErrUnauthorized(reason string) *ErrUnauthorized { return &ErrUnauthorized{ reason: reason, } } func (br *ErrUnauthorized) Error() string { return br.reason } // ErrPermission can be returned when requester lacks a permission for // a given resource. type ErrPermission struct { reason string } // NewErrPermission creates a new ErrPermission instance. func NewErrPermission(reason string) *ErrPermission { return &ErrPermission{ reason: reason, } } func (br *ErrPermission) Error() string { return br.reason } // ErrForbidden can be returned when requester doesn't have access to // a given resource. type ErrForbidden struct { reason string } // NewErrForbidden creates a new ErrForbidden instance. func NewErrForbidden(reason string) *ErrForbidden { return &ErrForbidden{ reason: reason, } } func (br *ErrForbidden) Error() string { return br.reason } type ErrInvalidCategory struct { msg string } func NewErrInvalidCategory(msg string) *ErrInvalidCategory { return &ErrInvalidCategory{ msg: msg, } } func (e *ErrInvalidCategory) Error() string { return e.msg } type ErrNotImplemented struct { msg string } func NewErrNotImplemented(msg string) *ErrNotImplemented { return &ErrNotImplemented{ msg: msg, } } func (ni *ErrNotImplemented) Error() string { return ni.msg } // IsErrBadRequest returns true if `err` is or wraps one of: // - model.ErrBadRequest // - model.ErrViewsLimitReached // - model.ErrAuthParam // - model.ErrInvalidCategory // - model.ErrBoardMemberIsLastAdmin // - model.ErrBoardIDMismatch // - model.ErrBlockTitleSizeLimitExceeded // - model.ErrBlockFieldsSizeLimitExceeded. func IsErrBadRequest(err error) bool { if err == nil { return false } // check if this is a model.ErrBadRequest var br *ErrBadRequest if errors.As(err, &br) { return true } // check if this is a model.ErrViewsLimitReached if errors.Is(err, ErrViewsLimitReached) { return true } // check if this is a model.ErrAuthParam var ap *ErrAuthParam if errors.As(err, &ap) { return true } // check if this is a model.ErrInvalidCategory var ic *ErrInvalidCategory if errors.As(err, &ic) { return true } // check if this is a model.ErrBoardMemberIsLastAdmin if errors.Is(err, ErrBoardMemberIsLastAdmin) { return true } // check if this is a model.ErrBoardIDMismatch if errors.Is(err, ErrBoardIDMismatch) { return true } // check if this is a model.ErrBlockTitleSizeLimitExceeded if errors.Is(err, ErrBlockTitleSizeLimitExceeded) { return true } // check if this is a model.ErrBlockTitleSizeLimitExceeded return errors.Is(err, ErrBlockFieldsSizeLimitExceeded) } // IsErrUnauthorized returns true if `err` is or wraps one of: // - model.ErrUnauthorized. func IsErrUnauthorized(err error) bool { if err == nil { return false } // check if this is a model.ErrUnauthorized var u *ErrUnauthorized return errors.As(err, &u) } // IsErrForbidden returns true if `err` is or wraps one of: // - model.ErrForbidden // - model.ErrPermission // - model.ErrPatchUpdatesLimitedCards // - model.ErrorCategoryPermissionDenied. func IsErrForbidden(err error) bool { if err == nil { return false } // check if this is a model.ErrForbidden var f *ErrForbidden if errors.As(err, &f) { return true } // check if this is a model.ErrPermission var p *ErrPermission if errors.As(err, &p) { return true } // check if this is a model.ErrPatchUpdatesLimitedCards if errors.Is(err, ErrPatchUpdatesLimitedCards) { return true } // check if this is a model.ErrCategoryPermissionDenied return errors.Is(err, ErrCategoryPermissionDenied) } // IsErrNotFound returns true if `err` is or wraps one of: // - model.ErrNotFound // - model.ErrNotAllFound // - sql.ErrNoRows // - mattermost-plugin-api/ErrNotFound. // - model.ErrCategoryDeleted. func IsErrNotFound(err error) bool { if err == nil { return false } // check if this is a model.ErrNotFound var nf *ErrNotFound if errors.As(err, &nf) { return true } // check if this is a model.ErrNotAllFound var naf *ErrNotAllFound if errors.As(err, &naf) { return true } // check if this is a sql.ErrNotFound if errors.Is(err, sql.ErrNoRows) { return true } // check if this is a plugin API error if errors.Is(err, pluginapi.ErrNotFound) { return true } // check if this is a Mattermost AppError with a Not Found status var appErr *mmModel.AppError if errors.As(err, &appErr) { if appErr.StatusCode == http.StatusNotFound { return true } } // check if this is a model.ErrCategoryDeleted return errors.Is(err, ErrCategoryDeleted) } // IsErrRequestEntityTooLarge returns true if `err` is or wraps one of: // - model.ErrRequestEntityTooLarge. func IsErrRequestEntityTooLarge(err error) bool { // check if this is a model.ErrRequestEntityTooLarge return errors.Is(err, ErrRequestEntityTooLarge) } // IsErrNotImplemented returns true if `err` is or wraps one of: // - model.ErrNotImplemented // - model.ErrInsufficientLicense. func IsErrNotImplemented(err error) bool { if err == nil { return false } // check if this is a model.ErrNotImplemented var eni *ErrNotImplemented if errors.As(err, &eni) { return true } // check if this is a model.ErrInsufficientLicense return errors.Is(err, ErrInsufficientLicense) } ================================================ FILE: server/model/errorResponse.go ================================================ package model // ErrorResponse is an error response // swagger:model type ErrorResponse struct { // The error message // required: false Error string `json:"error"` // The error code // required: false ErrorCode int `json:"errorCode"` } ================================================ FILE: server/model/file.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model import ( "mime" "path/filepath" "strings" "github.com/mattermost/focalboard/server/utils" mm_model "github.com/mattermost/mattermost/server/public/model" ) func NewFileInfo(name string) *mm_model.FileInfo { extension := strings.ToLower(filepath.Ext(name)) now := utils.GetMillis() return &mm_model.FileInfo{ CreatorId: "boards", CreateAt: now, UpdateAt: now, Name: name, Extension: extension, MimeType: mime.TypeByExtension(extension), } } ================================================ FILE: server/model/import_export.go ================================================ package model import ( "encoding/json" "errors" "fmt" ) var ( ErrInvalidImageBlock = errors.New("invalid image block") ) // Archive is an import / export archive. // TODO: remove once default templates are converted to new archive format. type Archive struct { Version int64 `json:"version"` Date int64 `json:"date"` Blocks []Block `json:"blocks"` } // ArchiveHeader is the content of the first file (`version.json`) within an archive. type ArchiveHeader struct { Version int `json:"version"` Date int64 `json:"date"` } // ArchiveLine is any line in an archive. type ArchiveLine struct { Type string `json:"type"` Data json.RawMessage `json:"data"` } // ExportArchiveOptions provides options when exporting one or more boards // to an archive. type ExportArchiveOptions struct { TeamID string // BoardIDs is the list of boards to include in the archive. // Empty slice means export all boards from workspace/team. BoardIDs []string } // ImportArchiveOptions provides options when importing an archive. type ImportArchiveOptions struct { TeamID string ModifiedBy string BoardModifier BoardModifier BlockModifier BlockModifier } // ErrUnsupportedArchiveVersion is an error returned when trying to import an // archive with a version that this server does not support. type ErrUnsupportedArchiveVersion struct { got int want int } // NewErrUnsupportedArchiveVersion creates a ErrUnsupportedArchiveVersion error. func NewErrUnsupportedArchiveVersion(got int, want int) ErrUnsupportedArchiveVersion { return ErrUnsupportedArchiveVersion{ got: got, want: want, } } func (e ErrUnsupportedArchiveVersion) Error() string { return fmt.Sprintf("unsupported archive version; got %d, want %d", e.got, e.want) } // ErrUnsupportedArchiveLineType is an error returned when trying to import an // archive containing an unsupported line type. type ErrUnsupportedArchiveLineType struct { line int got string } // NewErrUnsupportedArchiveLineType creates a ErrUnsupportedArchiveLineType error. func NewErrUnsupportedArchiveLineType(line int, got string) ErrUnsupportedArchiveLineType { return ErrUnsupportedArchiveLineType{ line: line, got: got, } } func (e ErrUnsupportedArchiveLineType) Error() string { return fmt.Sprintf("unsupported archive line type; got %s, line %d", e.got, e.line) } ================================================ FILE: server/model/log_level.go ================================================ package model import "github.com/mattermost/logr/v2" var LvlFBTelemetry = logr.Level{ ID: 9000, Name: "telemetry", } ================================================ FILE: server/model/mocks/mockservicesapi.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/focalboard/server/model (interfaces: ServicesAPI) // Package mocks is a generated GoMock package. package mocks import ( sql "database/sql" reflect "reflect" gomock "github.com/golang/mock/gomock" mux "github.com/gorilla/mux" model "github.com/mattermost/mattermost/server/public/model" mlog "github.com/mattermost/mattermost/server/public/shared/mlog" ) // MockServicesAPI is a mock of ServicesAPI interface. type MockServicesAPI struct { ctrl *gomock.Controller recorder *MockServicesAPIMockRecorder } // MockServicesAPIMockRecorder is the mock recorder for MockServicesAPI. type MockServicesAPIMockRecorder struct { mock *MockServicesAPI } // NewMockServicesAPI creates a new mock instance. func NewMockServicesAPI(ctrl *gomock.Controller) *MockServicesAPI { mock := &MockServicesAPI{ctrl: ctrl} mock.recorder = &MockServicesAPIMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockServicesAPI) EXPECT() *MockServicesAPIMockRecorder { return m.recorder } // CreateMember mocks base method. func (m *MockServicesAPI) CreateMember(arg0, arg1 string) (*model.TeamMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateMember", arg0, arg1) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateMember indicates an expected call of CreateMember. func (mr *MockServicesAPIMockRecorder) CreateMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMember", reflect.TypeOf((*MockServicesAPI)(nil).CreateMember), arg0, arg1) } // CreatePost mocks base method. func (m *MockServicesAPI) CreatePost(arg0 *model.Post) (*model.Post, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePost", arg0) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(error) return ret0, ret1 } // CreatePost indicates an expected call of CreatePost. func (mr *MockServicesAPIMockRecorder) CreatePost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePost", reflect.TypeOf((*MockServicesAPI)(nil).CreatePost), arg0) } // DeletePreferencesForUser mocks base method. func (m *MockServicesAPI) DeletePreferencesForUser(arg0 string, arg1 model.Preferences) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeletePreferencesForUser", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeletePreferencesForUser indicates an expected call of DeletePreferencesForUser. func (mr *MockServicesAPIMockRecorder) DeletePreferencesForUser(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePreferencesForUser", reflect.TypeOf((*MockServicesAPI)(nil).DeletePreferencesForUser), arg0, arg1) } // EnsureBot mocks base method. func (m *MockServicesAPI) EnsureBot(arg0 *model.Bot) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "EnsureBot", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // EnsureBot indicates an expected call of EnsureBot. func (mr *MockServicesAPIMockRecorder) EnsureBot(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureBot", reflect.TypeOf((*MockServicesAPI)(nil).EnsureBot), arg0) } // GetChannelByID mocks base method. func (m *MockServicesAPI) GetChannelByID(arg0 string) (*model.Channel, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelByID", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(error) return ret0, ret1 } // GetChannelByID indicates an expected call of GetChannelByID. func (mr *MockServicesAPIMockRecorder) GetChannelByID(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByID", reflect.TypeOf((*MockServicesAPI)(nil).GetChannelByID), arg0) } // GetChannelMember mocks base method. func (m *MockServicesAPI) GetChannelMember(arg0, arg1 string) (*model.ChannelMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMember", arg0, arg1) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetChannelMember indicates an expected call of GetChannelMember. func (mr *MockServicesAPIMockRecorder) GetChannelMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMember", reflect.TypeOf((*MockServicesAPI)(nil).GetChannelMember), arg0, arg1) } // GetChannelsForTeamForUser mocks base method. func (m *MockServicesAPI) GetChannelsForTeamForUser(arg0, arg1 string, arg2 bool) (model.ChannelList, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelsForTeamForUser", arg0, arg1, arg2) ret0, _ := ret[0].(model.ChannelList) ret1, _ := ret[1].(error) return ret0, ret1 } // GetChannelsForTeamForUser indicates an expected call of GetChannelsForTeamForUser. func (mr *MockServicesAPIMockRecorder) GetChannelsForTeamForUser(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelsForTeamForUser", reflect.TypeOf((*MockServicesAPI)(nil).GetChannelsForTeamForUser), arg0, arg1, arg2) } // GetConfig mocks base method. func (m *MockServicesAPI) GetConfig() *model.Config { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetConfig") ret0, _ := ret[0].(*model.Config) return ret0 } // GetConfig indicates an expected call of GetConfig. func (mr *MockServicesAPIMockRecorder) GetConfig() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockServicesAPI)(nil).GetConfig)) } // GetDiagnosticID mocks base method. func (m *MockServicesAPI) GetDiagnosticID() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDiagnosticID") ret0, _ := ret[0].(string) return ret0 } // GetDiagnosticID indicates an expected call of GetDiagnosticID. func (mr *MockServicesAPIMockRecorder) GetDiagnosticID() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDiagnosticID", reflect.TypeOf((*MockServicesAPI)(nil).GetDiagnosticID)) } // GetDirectChannel mocks base method. func (m *MockServicesAPI) GetDirectChannel(arg0, arg1 string) (*model.Channel, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDirectChannel", arg0, arg1) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(error) return ret0, ret1 } // GetDirectChannel indicates an expected call of GetDirectChannel. func (mr *MockServicesAPIMockRecorder) GetDirectChannel(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDirectChannel", reflect.TypeOf((*MockServicesAPI)(nil).GetDirectChannel), arg0, arg1) } // GetDirectChannelOrCreate mocks base method. func (m *MockServicesAPI) GetDirectChannelOrCreate(arg0, arg1 string) (*model.Channel, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDirectChannelOrCreate", arg0, arg1) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(error) return ret0, ret1 } // GetDirectChannelOrCreate indicates an expected call of GetDirectChannelOrCreate. func (mr *MockServicesAPIMockRecorder) GetDirectChannelOrCreate(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDirectChannelOrCreate", reflect.TypeOf((*MockServicesAPI)(nil).GetDirectChannelOrCreate), arg0, arg1) } // GetFileInfo mocks base method. func (m *MockServicesAPI) GetFileInfo(arg0 string) (*model.FileInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileInfo", arg0) ret0, _ := ret[0].(*model.FileInfo) ret1, _ := ret[1].(error) return ret0, ret1 } // GetFileInfo indicates an expected call of GetFileInfo. func (mr *MockServicesAPIMockRecorder) GetFileInfo(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileInfo", reflect.TypeOf((*MockServicesAPI)(nil).GetFileInfo), arg0) } // GetLicense mocks base method. func (m *MockServicesAPI) GetLicense() *model.License { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLicense") ret0, _ := ret[0].(*model.License) return ret0 } // GetLicense indicates an expected call of GetLicense. func (mr *MockServicesAPIMockRecorder) GetLicense() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLicense", reflect.TypeOf((*MockServicesAPI)(nil).GetLicense)) } // GetLogger mocks base method. func (m *MockServicesAPI) GetLogger() mlog.LoggerIFace { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLogger") ret0, _ := ret[0].(mlog.LoggerIFace) return ret0 } // GetLogger indicates an expected call of GetLogger. func (mr *MockServicesAPIMockRecorder) GetLogger() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogger", reflect.TypeOf((*MockServicesAPI)(nil).GetLogger)) } // GetMasterDB mocks base method. func (m *MockServicesAPI) GetMasterDB() (*sql.DB, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetMasterDB") ret0, _ := ret[0].(*sql.DB) ret1, _ := ret[1].(error) return ret0, ret1 } // GetMasterDB indicates an expected call of GetMasterDB. func (mr *MockServicesAPIMockRecorder) GetMasterDB() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMasterDB", reflect.TypeOf((*MockServicesAPI)(nil).GetMasterDB)) } // GetPreferencesForUser mocks base method. func (m *MockServicesAPI) GetPreferencesForUser(arg0 string) (model.Preferences, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPreferencesForUser", arg0) ret0, _ := ret[0].(model.Preferences) ret1, _ := ret[1].(error) return ret0, ret1 } // GetPreferencesForUser indicates an expected call of GetPreferencesForUser. func (mr *MockServicesAPIMockRecorder) GetPreferencesForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPreferencesForUser", reflect.TypeOf((*MockServicesAPI)(nil).GetPreferencesForUser), arg0) } // GetTeamMember mocks base method. func (m *MockServicesAPI) GetTeamMember(arg0, arg1 string) (*model.TeamMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMember", arg0, arg1) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTeamMember indicates an expected call of GetTeamMember. func (mr *MockServicesAPIMockRecorder) GetTeamMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMember", reflect.TypeOf((*MockServicesAPI)(nil).GetTeamMember), arg0, arg1) } // GetUserByEmail mocks base method. func (m *MockServicesAPI) GetUserByEmail(arg0 string) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByEmail", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserByEmail indicates an expected call of GetUserByEmail. func (mr *MockServicesAPIMockRecorder) GetUserByEmail(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockServicesAPI)(nil).GetUserByEmail), arg0) } // GetUserByID mocks base method. func (m *MockServicesAPI) GetUserByID(arg0 string) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByID", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserByID indicates an expected call of GetUserByID. func (mr *MockServicesAPIMockRecorder) GetUserByID(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByID", reflect.TypeOf((*MockServicesAPI)(nil).GetUserByID), arg0) } // GetUserByUsername mocks base method. func (m *MockServicesAPI) GetUserByUsername(arg0 string) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByUsername", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserByUsername indicates an expected call of GetUserByUsername. func (mr *MockServicesAPIMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockServicesAPI)(nil).GetUserByUsername), arg0) } // GetUsersFromProfiles mocks base method. func (m *MockServicesAPI) GetUsersFromProfiles(arg0 *model.UserGetOptions) ([]*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersFromProfiles", arg0) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUsersFromProfiles indicates an expected call of GetUsersFromProfiles. func (mr *MockServicesAPIMockRecorder) GetUsersFromProfiles(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersFromProfiles", reflect.TypeOf((*MockServicesAPI)(nil).GetUsersFromProfiles), arg0) } // HasPermissionTo mocks base method. func (m *MockServicesAPI) HasPermissionTo(arg0 string, arg1 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionTo", arg0, arg1) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionTo indicates an expected call of HasPermissionTo. func (mr *MockServicesAPIMockRecorder) HasPermissionTo(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionTo", reflect.TypeOf((*MockServicesAPI)(nil).HasPermissionTo), arg0, arg1) } // HasPermissionToChannel mocks base method. func (m *MockServicesAPI) HasPermissionToChannel(arg0, arg1 string, arg2 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionToChannel", arg0, arg1, arg2) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionToChannel indicates an expected call of HasPermissionToChannel. func (mr *MockServicesAPIMockRecorder) HasPermissionToChannel(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToChannel", reflect.TypeOf((*MockServicesAPI)(nil).HasPermissionToChannel), arg0, arg1, arg2) } // HasPermissionToTeam mocks base method. func (m *MockServicesAPI) HasPermissionToTeam(arg0, arg1 string, arg2 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionToTeam", arg0, arg1, arg2) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionToTeam indicates an expected call of HasPermissionToTeam. func (mr *MockServicesAPIMockRecorder) HasPermissionToTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToTeam", reflect.TypeOf((*MockServicesAPI)(nil).HasPermissionToTeam), arg0, arg1, arg2) } // KVSetWithOptions mocks base method. func (m *MockServicesAPI) KVSetWithOptions(arg0 string, arg1 []byte, arg2 model.PluginKVSetOptions) (bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVSetWithOptions", arg0, arg1, arg2) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(error) return ret0, ret1 } // KVSetWithOptions indicates an expected call of KVSetWithOptions. func (mr *MockServicesAPIMockRecorder) KVSetWithOptions(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVSetWithOptions", reflect.TypeOf((*MockServicesAPI)(nil).KVSetWithOptions), arg0, arg1, arg2) } // PublishPluginClusterEvent mocks base method. func (m *MockServicesAPI) PublishPluginClusterEvent(arg0 model.PluginClusterEvent, arg1 model.PluginClusterEventSendOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PublishPluginClusterEvent", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // PublishPluginClusterEvent indicates an expected call of PublishPluginClusterEvent. func (mr *MockServicesAPIMockRecorder) PublishPluginClusterEvent(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishPluginClusterEvent", reflect.TypeOf((*MockServicesAPI)(nil).PublishPluginClusterEvent), arg0, arg1) } // PublishWebSocketEvent mocks base method. func (m *MockServicesAPI) PublishWebSocketEvent(arg0 string, arg1 map[string]interface{}, arg2 *model.WebsocketBroadcast) { m.ctrl.T.Helper() m.ctrl.Call(m, "PublishWebSocketEvent", arg0, arg1, arg2) } // PublishWebSocketEvent indicates an expected call of PublishWebSocketEvent. func (mr *MockServicesAPIMockRecorder) PublishWebSocketEvent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishWebSocketEvent", reflect.TypeOf((*MockServicesAPI)(nil).PublishWebSocketEvent), arg0, arg1, arg2) } // RegisterRouter mocks base method. func (m *MockServicesAPI) RegisterRouter(arg0 *mux.Router) { m.ctrl.T.Helper() m.ctrl.Call(m, "RegisterRouter", arg0) } // RegisterRouter indicates an expected call of RegisterRouter. func (mr *MockServicesAPIMockRecorder) RegisterRouter(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterRouter", reflect.TypeOf((*MockServicesAPI)(nil).RegisterRouter), arg0) } // UpdatePreferencesForUser mocks base method. func (m *MockServicesAPI) UpdatePreferencesForUser(arg0 string, arg1 model.Preferences) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdatePreferencesForUser", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UpdatePreferencesForUser indicates an expected call of UpdatePreferencesForUser. func (mr *MockServicesAPIMockRecorder) UpdatePreferencesForUser(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePreferencesForUser", reflect.TypeOf((*MockServicesAPI)(nil).UpdatePreferencesForUser), arg0, arg1) } // UpdateUser mocks base method. func (m *MockServicesAPI) UpdateUser(arg0 *model.User) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateUser indicates an expected call of UpdateUser. func (mr *MockServicesAPIMockRecorder) UpdateUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockServicesAPI)(nil).UpdateUser), arg0) } ================================================ FILE: server/model/mocks/propValueResolverMock.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/focalboard/server/model (interfaces: PropValueResolver) // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/focalboard/server/model" ) // MockPropValueResolver is a mock of PropValueResolver interface. type MockPropValueResolver struct { ctrl *gomock.Controller recorder *MockPropValueResolverMockRecorder } // MockPropValueResolverMockRecorder is the mock recorder for MockPropValueResolver. type MockPropValueResolverMockRecorder struct { mock *MockPropValueResolver } // NewMockPropValueResolver creates a new mock instance. func NewMockPropValueResolver(ctrl *gomock.Controller) *MockPropValueResolver { mock := &MockPropValueResolver{ctrl: ctrl} mock.recorder = &MockPropValueResolverMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPropValueResolver) EXPECT() *MockPropValueResolverMockRecorder { return m.recorder } // GetUserByID mocks base method. func (m *MockPropValueResolver) GetUserByID(arg0 string) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByID", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserByID indicates an expected call of GetUserByID. func (mr *MockPropValueResolverMockRecorder) GetUserByID(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByID", reflect.TypeOf((*MockPropValueResolver)(nil).GetUserByID), arg0) } ================================================ FILE: server/model/notification.go ================================================ package model import ( "time" "github.com/mattermost/mattermost/server/v8/channels/utils" ) // NotificationHint provides a hint that a block has been modified and has subscribers that // should be notified. // swagger:model type NotificationHint struct { // BlockType is the block type of the entity (e.g. board, card) that was updated // required: true BlockType BlockType `json:"block_type"` // BlockID is id of the entity that was updated // required: true BlockID string `json:"block_id"` // ModifiedByID is the id of the user who made the block change ModifiedByID string `json:"modified_by_id"` // CreatedAt is the timestamp this notification hint was created in miliseconds since the current epoch // required: true CreateAt int64 `json:"create_at"` // NotifyAt is the timestamp this notification should be scheduled in miliseconds since the current epoch // required: true NotifyAt int64 `json:"notify_at"` } func (s *NotificationHint) IsValid() error { if s == nil { return ErrInvalidNotificationHint{"cannot be nil"} } if s.BlockID == "" { return ErrInvalidNotificationHint{"missing block id"} } if s.BlockType == "" { return ErrInvalidNotificationHint{"missing block type"} } if s.ModifiedByID == "" { return ErrInvalidNotificationHint{"missing modified_by id"} } return nil } func (s *NotificationHint) Copy() *NotificationHint { return &NotificationHint{ BlockType: s.BlockType, BlockID: s.BlockID, ModifiedByID: s.ModifiedByID, CreateAt: s.CreateAt, NotifyAt: s.NotifyAt, } } func (s *NotificationHint) LogClone() interface{} { return struct { BlockType BlockType `json:"block_type"` BlockID string `json:"block_id"` ModifiedByID string `json:"modified_by_id"` CreateAt string `json:"create_at"` NotifyAt string `json:"notify_at"` }{ BlockType: s.BlockType, BlockID: s.BlockID, ModifiedByID: s.ModifiedByID, CreateAt: utils.TimeFromMillis(s.CreateAt).Format(time.StampMilli), NotifyAt: utils.TimeFromMillis(s.NotifyAt).Format(time.StampMilli), } } type ErrInvalidNotificationHint struct { msg string } func (e ErrInvalidNotificationHint) Error() string { return e.msg } ================================================ FILE: server/model/permission.go ================================================ package model import ( mmModel "github.com/mattermost/mattermost/server/public/model" ) var ( PermissionViewTeam = mmModel.PermissionViewTeam PermissionManageTeam = mmModel.PermissionManageTeam PermissionManageSystem = mmModel.PermissionManageSystem PermissionReadChannel = mmModel.PermissionReadChannel PermissionCreatePost = mmModel.PermissionCreatePost PermissionViewMembers = mmModel.PermissionViewMembers PermissionCreatePublicChannel = mmModel.PermissionCreatePublicChannel PermissionCreatePrivateChannel = mmModel.PermissionCreatePrivateChannel PermissionManageBoardType = &mmModel.Permission{Id: "manage_board_type", Name: "", Description: "", Scope: ""} PermissionDeleteBoard = &mmModel.Permission{Id: "delete_board", Name: "", Description: "", Scope: ""} PermissionViewBoard = &mmModel.Permission{Id: "view_board", Name: "", Description: "", Scope: ""} PermissionManageBoardRoles = &mmModel.Permission{Id: "manage_board_roles", Name: "", Description: "", Scope: ""} PermissionShareBoard = &mmModel.Permission{Id: "share_board", Name: "", Description: "", Scope: ""} PermissionManageBoardCards = &mmModel.Permission{Id: "manage_board_cards", Name: "", Description: "", Scope: ""} PermissionManageBoardProperties = &mmModel.Permission{Id: "manage_board_properties", Name: "", Description: "", Scope: ""} PermissionCommentBoardCards = &mmModel.Permission{Id: "comment_board_cards", Name: "", Description: "", Scope: ""} PermissionDeleteOthersComments = &mmModel.Permission{Id: "delete_others_comments", Name: "", Description: "", Scope: ""} ) ================================================ FILE: server/model/properties.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. //go:generate mockgen -destination=mocks/propValueResolverMock.go -package mocks . PropValueResolver package model import ( "encoding/json" "errors" "fmt" "strings" "github.com/mattermost/focalboard/server/utils" ) var ErrInvalidBoardBlock = errors.New("invalid board block") var ErrInvalidPropSchema = errors.New("invalid property schema") var ErrInvalidProperty = errors.New("invalid property") var ErrInvalidPropertyValue = errors.New("invalid property value") var ErrInvalidPropertyValueType = errors.New("invalid property value type") var ErrInvalidDate = errors.New("invalid date property") // PropValueResolver allows PropDef.GetValue to further decode property values, such as // looking up usernames from ids. type PropValueResolver interface { GetUserByID(userID string) (*User, error) } // BlockProperties is a map of Prop's keyed by property id. type BlockProperties map[string]BlockProp // BlockProp represent a property attached to a block (typically a card). type BlockProp struct { ID string `json:"id"` Index int `json:"index"` Name string `json:"name"` Value string `json:"value"` } // PropSchema is a map of PropDef's keyed by property id. type PropSchema map[string]PropDef // PropDefOption represents an option within a property definition. type PropDefOption struct { ID string `json:"id"` Index int `json:"index"` Color string `json:"color"` Value string `json:"value"` } // PropDef represents a property definition as defined in a board's Fields member. type PropDef struct { ID string `json:"id"` Index int `json:"index"` Name string `json:"name"` Type string `json:"type"` Options map[string]PropDefOption `json:"options"` } // GetValue resolves the value of a property if the passed value is an ID for an option, // otherwise returns the original value. func (pd PropDef) GetValue(v interface{}, resolver PropValueResolver) (string, error) { switch pd.Type { case "select": // v is the id of an option id, ok := v.(string) if !ok { return "", ErrInvalidPropertyValueType } opt, ok := pd.Options[id] if !ok { return "", ErrInvalidPropertyValue } return strings.ToUpper(opt.Value), nil case "date": // v is a JSON string date, ok := v.(string) if !ok { return "", ErrInvalidPropertyValueType } return pd.ParseDate(date) case "person": // v is a userid userID, ok := v.(string) if !ok { return "", ErrInvalidPropertyValueType } if resolver != nil { user, err := resolver.GetUserByID(userID) if err != nil { return "", err } if user == nil { return userID, nil } return user.Username, nil } return userID, nil case "multiPerson": // v is a slice of user IDs userIDs, ok := v.([]interface{}) if !ok { return "", fmt.Errorf("multiPerson property type: %w", ErrInvalidPropertyValueType) } if resolver != nil { usernames := make([]string, len(userIDs)) for i, userIDInterface := range userIDs { userID := userIDInterface.(string) user, err := resolver.GetUserByID(userID) if err != nil { return "", err } if user == nil { usernames[i] = userID } else { usernames[i] = user.Username } } return strings.Join(usernames, ", "), nil } case "multiSelect": // v is a slice of strings containing option ids ms, ok := v.([]interface{}) if !ok { return "", ErrInvalidPropertyValueType } var sb strings.Builder prefix := "" for _, optid := range ms { id, ok := optid.(string) if !ok { return "", ErrInvalidPropertyValueType } opt, ok := pd.Options[id] if !ok { return "", ErrInvalidPropertyValue } sb.WriteString(prefix) prefix = ", " sb.WriteString(strings.ToUpper(opt.Value)) } return sb.String(), nil } return fmt.Sprintf("%v", v), nil } func (pd PropDef) ParseDate(s string) (string, error) { // s is a JSON snippet of the form: {"from":1642161600000, "to":1642161600000} in milliseconds UTC // The UI does not yet support date ranges. var m map[string]int64 if err := json.Unmarshal([]byte(s), &m); err != nil { return s, err } tsFrom, ok := m["from"] if !ok { return s, ErrInvalidDate } date := utils.GetTimeForMillis(tsFrom).Format("January 02, 2006") tsTo, ok := m["to"] if ok { date += " -> " + utils.GetTimeForMillis(tsTo).Format("January 02, 2006") } return date, nil } // ParsePropertySchema parses a board block's `Fields` to extract the properties // schema for all cards within the board. // The result is provided as a map for quick lookup, and the original order is // preserved via the `Index` field. func ParsePropertySchema(board *Board) (PropSchema, error) { schema := make(map[string]PropDef) for i, prop := range board.CardProperties { pd := PropDef{ ID: getMapString("id", prop), Index: i, Name: getMapString("name", prop), Type: getMapString("type", prop), Options: make(map[string]PropDefOption), } optsIface, ok := prop["options"] if ok { opts, ok := optsIface.([]interface{}) if !ok { return nil, ErrInvalidPropSchema } for j, propOptIface := range opts { propOpt, ok := propOptIface.(map[string]interface{}) if !ok { return nil, ErrInvalidPropSchema } po := PropDefOption{ ID: getMapString("id", propOpt), Index: j, Value: getMapString("value", propOpt), Color: getMapString("color", propOpt), } pd.Options[po.ID] = po } } schema[pd.ID] = pd } return schema, nil } func getMapString(key string, m map[string]interface{}) string { iface, ok := m[key] if !ok { return "" } s, ok := iface.(string) if !ok { return "" } return s } // ParseProperties parses a block's `Fields` to extract the properties. Properties typically exist on // card blocks. A resolver can optionally be provided to fetch usernames for `person` prop type. func ParseProperties(block *Block, schema PropSchema, resolver PropValueResolver) (BlockProperties, error) { props := make(map[string]BlockProp) if block == nil { return props, nil } // `properties` contains a map (untyped at this point). propsIface, ok := block.Fields["properties"] if !ok { return props, nil // this is expected for blocks that don't have any properties. } blockProps, ok := propsIface.(map[string]interface{}) if !ok { return props, fmt.Errorf("`properties` field wrong type: %w", ErrInvalidProperty) } if len(blockProps) == 0 { return props, nil } for k, v := range blockProps { s := fmt.Sprintf("%v", v) prop := BlockProp{ ID: k, Name: k, Value: s, } def, ok := schema[k] if ok { val, err := def.GetValue(v, resolver) if err != nil { return props, fmt.Errorf("could not parse property value (%s): %w", fmt.Sprintf("%v", v), err) } prop.Name = def.Name prop.Value = val prop.Index = def.Index } props[k] = prop } return props, nil } ================================================ FILE: server/model/properties_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model import ( "encoding/json" "testing" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type MockResolver struct{} func (r MockResolver) GetUserByID(userID string) (*User, error) { if userID == "user_id_1" { return &User{ ID: "user_id_1", Username: "username_1", }, nil } else if userID == "user_id_2" { return &User{ ID: "user_id_2", Username: "username_2", }, nil } return nil, nil } func Test_parsePropertySchema(t *testing.T) { board := &Board{ ID: utils.NewID(utils.IDTypeBoard), Title: "Test Board", TeamID: utils.NewID(utils.IDTypeTeam), } err := json.Unmarshal([]byte(cardPropertiesExample), &board.CardProperties) require.NoError(t, err) t.Run("parse schema", func(t *testing.T) { schema, err := ParsePropertySchema(board) require.NoError(t, err) assert.Len(t, schema, 6) prop, ok := schema["7c212e78-9345-4c60-81b5-0b0e37ce463f"] require.True(t, ok) assert.Equal(t, "select", prop.Type) assert.Equal(t, "Type", prop.Name) assert.Len(t, prop.Options, 3) prop, ok = schema["a8spou7if43eo1rqzb9qeq488so"] require.True(t, ok) assert.Equal(t, "date", prop.Type) assert.Equal(t, "MyDate", prop.Name) assert.Empty(t, prop.Options) }) } func Test_GetValue(t *testing.T) { resolver := MockResolver{} propDef := PropDef{ Type: "multiPerson", } value, err := propDef.GetValue([]interface{}{"user_id_1", "user_id_2"}, resolver) require.NoError(t, err) require.Equal(t, "username_1, username_2", value) // trying with only user value, err = propDef.GetValue([]interface{}{"user_id_1"}, resolver) require.NoError(t, err) require.Equal(t, "username_1", value) // trying with unknown user value, err = propDef.GetValue([]interface{}{"user_id_1", "user_id_unknown"}, resolver) require.NoError(t, err) require.Equal(t, "username_1, user_id_unknown", value) // trying with multiple unknown users value, err = propDef.GetValue([]interface{}{"michael_scott", "jim_halpert"}, resolver) require.NoError(t, err) require.Equal(t, "michael_scott, jim_halpert", value) } const ( cardPropertiesExample = `[ { "id":"7c212e78-9345-4c60-81b5-0b0e37ce463f", "name":"Type", "options":[ { "color":"propColorYellow", "id":"31da50ca-f1a9-4d21-8636-17dc387c1a23", "value":"Ad Hoc" }, { "color":"propColorBlue", "id":"def6317c-ec11-410d-8a6b-ea461320f392", "value":"Standup" }, { "color":"propColorPurple", "id":"700f83f8-6a41-46cd-87e2-53e0d0b12cc7", "value":"Weekly Sync" } ], "type":"select" }, { "id":"13d2394a-eb5e-4f22-8c22-6515ec41c4a4", "name":"Summary", "options":[], "type":"text" }, { "id":"566cd860-bbae-4bcd-86a8-7df4db2ba15c", "name":"Color", "options":[ { "color":"propColorDefault", "id":"efb0c783-f9ea-4938-8b86-9cf425296cd1", "value":"RED" }, { "color":"propColorDefault", "id":"2f100e13-e7c4-4ab6-81c9-a17baf98b311", "value":"GREEN" }, { "color":"propColorDefault", "id":"a05bdc80-bd90-45b0-8805-a7e77a4884be", "value":"BLUE" } ], "type":"select" }, { "id":"aawg1s8rxq8o1bbksxmsmpsdd3r", "name":"MyTextProp", "options":[], "type":"text" }, { "id":"awdwfigo4kse63bdfp56mzhip6w", "name":"MyCheckBox", "options":[], "type":"checkbox" }, { "id":"a8spou7if43eo1rqzb9qeq488so", "name":"MyDate", "options":[], "type":"date" } ]` ) ================================================ FILE: server/model/services_api.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. //go:generate mockgen --build_flags= -destination=mocks/mockservicesapi.go -package mocks . ServicesAPI package model import ( "database/sql" "github.com/gorilla/mux" mm_model "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( botUsername = "boards" botDisplayname = "Boards" botDescription = "Created by Boards plugin." ) var FocalboardBot = &mm_model.Bot{ Username: botUsername, DisplayName: botDisplayname, Description: botDescription, OwnerId: SystemUserID, } type ServicesAPI interface { // Channels service GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error) GetChannelByID(channelID string) (*mm_model.Channel, error) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) GetChannelsForTeamForUser(teamID string, userID string, includeDeleted bool) (mm_model.ChannelList, error) // Post service CreatePost(post *mm_model.Post) (*mm_model.Post, error) // User service GetUserByID(userID string) (*mm_model.User, error) GetUserByUsername(name string) (*mm_model.User, error) GetUserByEmail(email string) (*mm_model.User, error) UpdateUser(user *mm_model.User) (*mm_model.User, error) GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error) // Team service GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) // Permissions service HasPermissionTo(userID string, permission *mm_model.Permission) bool HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool HasPermissionToChannel(askingUserID string, channelID string, permission *mm_model.Permission) bool // Bot service EnsureBot(bot *mm_model.Bot) (string, error) // License service GetLicense() *mm_model.License // FileInfoStore service GetFileInfo(fileID string) (*mm_model.FileInfo, error) // Cluster service PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error // Config service GetConfig() *mm_model.Config // Logger service GetLogger() mlog.LoggerIFace // KVStore service KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) // Store service GetMasterDB() (*sql.DB, error) // System service GetDiagnosticID() string // Router service RegisterRouter(sub *mux.Router) // Preferences services GetPreferencesForUser(userID string) (mm_model.Preferences, error) UpdatePreferencesForUser(userID string, preferences mm_model.Preferences) error DeletePreferencesForUser(userID string, preferences mm_model.Preferences) error } ================================================ FILE: server/model/sharing.go ================================================ package model import ( "encoding/json" "io" ) // Sharing is sharing information for a root block // swagger:model type Sharing struct { // ID of the root block // required: true ID string `json:"id"` // Is sharing enabled // required: true Enabled bool `json:"enabled"` // Access token // required: true Token string `json:"token"` // ID of the user who last modified this // required: true ModifiedBy string `json:"modifiedBy"` // Updated time in miliseconds since the current epoch // required: true UpdateAt int64 `json:"update_at,omitempty"` } func SharingFromJSON(data io.Reader) Sharing { var sharing Sharing _ = json.NewDecoder(data).Decode(&sharing) return sharing } ================================================ FILE: server/model/subscription.go ================================================ package model import ( "encoding/json" "io" ) const ( SubTypeUser = "user" SubTypeChannel = "channel" ) type SubscriberType string func (st SubscriberType) IsValid() bool { switch st { case SubTypeUser, SubTypeChannel: return true } return false } // Subscription is a subscription to a board, card, etc, for a user or channel. // swagger:model type Subscription struct { // BlockType is the block type of the entity (e.g. board, card) subscribed to // required: true BlockType BlockType `json:"blockType"` // BlockID is id of the entity being subscribed to // required: true BlockID string `json:"blockId"` // SubscriberType is the type of the entity (e.g. user, channel) that is subscribing // required: true SubscriberType SubscriberType `json:"subscriberType"` // SubscriberID is the id of the entity that is subscribing // required: true SubscriberID string `json:"subscriberId"` // NotifiedAt is the timestamp of the last notification sent for this subscription // required: true NotifiedAt int64 `json:"notifiedAt,omitempty"` // CreatedAt is the timestamp this subscription was created in miliseconds since the current epoch // required: true CreateAt int64 `json:"createAt"` // DeleteAt is the timestamp this subscription was deleted in miliseconds since the current epoch, or zero if not deleted // required: true DeleteAt int64 `json:"deleteAt"` } func (s *Subscription) IsValid() error { if s == nil { return ErrInvalidSubscription{"cannot be nil"} } if s.BlockID == "" { return ErrInvalidSubscription{"missing block id"} } if s.BlockType == "" { return ErrInvalidSubscription{"missing block type"} } if s.SubscriberID == "" { return ErrInvalidSubscription{"missing subscriber id"} } if !s.SubscriberType.IsValid() { return ErrInvalidSubscription{"invalid subscriber type"} } return nil } func SubscriptionFromJSON(data io.Reader) (*Subscription, error) { var subscription Subscription if err := json.NewDecoder(data).Decode(&subscription); err != nil { return nil, err } return &subscription, nil } type ErrInvalidSubscription struct { msg string } func (e ErrInvalidSubscription) Error() string { return e.msg } // Subscriber is an entity (e.g. user, channel) that can subscribe to events from boards, cards, etc // swagger:model type Subscriber struct { // SubscriberType is the type of the entity (e.g. user, channel) that is subscribing // required: true SubscriberType SubscriberType `json:"subscriber_type"` // SubscriberID is the id of the entity that is subscribing // required: true SubscriberID string `json:"subscriber_id"` // NotifiedAt is the timestamp this subscriber was last notified NotifiedAt int64 `json:"notified_at"` } ================================================ FILE: server/model/team.go ================================================ package model import ( "encoding/json" "io" ) // Team is information global to a team // swagger:model type Team struct { // ID of the team // required: true ID string `json:"id"` // Title of the team // required: false Title string `json:"title"` // Token required to register new users // required: true SignupToken string `json:"signupToken"` // Team settings // required: false Settings map[string]interface{} `json:"settings"` // ID of user who last modified this // required: true ModifiedBy string `json:"modifiedBy"` // Updated time in miliseconds since the current epoch // required: true UpdateAt int64 `json:"updateAt"` } func TeamFromJSON(data io.Reader) *Team { var team *Team _ = json.NewDecoder(data).Decode(&team) return team } func TeamsFromJSON(data io.Reader) []*Team { var teams []*Team _ = json.NewDecoder(data).Decode(&teams) return teams } ================================================ FILE: server/model/user.go ================================================ package model import ( "encoding/json" "io" ) const ( SingleUser = "single-user" GlobalTeamID = "0" SystemUserID = "system" PreferencesCategoryFocalboard = "focalboard" ) // User is a user // swagger:model type User struct { // The user ID // required: true ID string `json:"id"` // The user name // required: true Username string `json:"username"` // The user's email // required: true Email string `json:"-"` // The user's nickname Nickname string `json:"nickname"` // The user's first name FirstName string `json:"firstname"` // The user's last name LastName string `json:"lastname"` // swagger:ignore Password string `json:"-"` // swagger:ignore MfaSecret string `json:"-"` // swagger:ignore AuthService string `json:"-"` // swagger:ignore AuthData string `json:"-"` // Created time in miliseconds since the current epoch // required: true CreateAt int64 `json:"create_at,omitempty"` // Updated time in miliseconds since the current epoch // required: true UpdateAt int64 `json:"update_at,omitempty"` // Deleted time in miliseconds since the current epoch, set to indicate user is deleted // required: true DeleteAt int64 `json:"delete_at"` // If the user is a bot or not // required: true IsBot bool `json:"is_bot"` // If the user is a guest or not // required: true IsGuest bool `json:"is_guest"` // Special Permissions the user may have Permissions []string `json:"permissions,omitempty"` Roles string `json:"roles"` } // UserPreferencesPatch is a user property patch // swagger:model type UserPreferencesPatch struct { // The user preference updated fields // required: false UpdatedFields map[string]string `json:"updatedFields"` // The user preference removed fields // required: false DeletedFields []string `json:"deletedFields"` } type Session struct { ID string `json:"id"` Token string `json:"token"` UserID string `json:"user_id"` AuthService string `json:"authService"` Props map[string]interface{} `json:"props"` CreateAt int64 `json:"create_at,omitempty"` UpdateAt int64 `json:"update_at,omitempty"` } func UserFromJSON(data io.Reader) (*User, error) { var user User if err := json.NewDecoder(data).Decode(&user); err != nil { return nil, err } return &user, nil } func (u *User) Sanitize(options map[string]bool) { u.Password = "" u.MfaSecret = "" if len(options) != 0 && !options["email"] { u.Email = "" } if len(options) != 0 && !options["fullname"] { u.FirstName = "" u.LastName = "" } } ================================================ FILE: server/model/util.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model import ( "time" mm_model "github.com/mattermost/mattermost/server/public/model" ) // GetMillis is a convenience method to get milliseconds since epoch. func GetMillis() int64 { return mm_model.GetMillis() } // GetMillisForTime is a convenience method to get milliseconds since epoch for provided Time. func GetMillisForTime(thisTime time.Time) int64 { return mm_model.GetMillisForTime(thisTime) } // GetTimeForMillis is a convenience method to get time.Time for milliseconds since epoch. func GetTimeForMillis(millis int64) time.Time { return mm_model.GetTimeForMillis(millis) } ================================================ FILE: server/model/version.go ================================================ package model import ( "github.com/mattermost/mattermost/server/public/shared/mlog" ) // This is a list of all the current versions including any patches. // It should be maintained in chronological order with most current // release at the front of the list. var versions = []string{ "8.0.0", "7.12.0", "7.11.1", "7.11.0", "7.10.0", "7.9.0", "7.8.0", "7.7.0", "7.6.0", "7.5.0", "7.4.0", "7.3.0", "7.2.0", "7.0.0", "0.16.0", "0.15.0", "0.14.0", "0.12.0", "0.11.0", "0.10.0", "0.9.4", "0.9.3", "0.9.2", "0.9.1", "0.9.0", "0.8.2", "0.8.1", "0.8.0", "0.7.3", "0.7.2", "0.7.1", "0.7.0", "0.6.7", "0.6.6", "0.6.5", "0.6.2", "0.6.1", "0.6.0", "0.5.0", } var ( CurrentVersion = versions[0] BuildNumber string BuildDate string BuildHash string Edition string ) // LogServerInfo logs information about the server instance. func LogServerInfo(logger mlog.LoggerIFace) { logger.Info("Focalboard server", mlog.String("version", CurrentVersion), mlog.String("edition", Edition), mlog.String("build_number", BuildNumber), mlog.String("build_date", BuildDate), mlog.String("build_hash", BuildHash), ) } ================================================ FILE: server/server/initHandlers.go ================================================ package server func (s *Server) initHandlers() { cfg := s.config s.api.MattermostAuth = cfg.AuthMode == MattermostAuthMod } ================================================ FILE: server/server/params.go ================================================ package server import ( "fmt" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/focalboard/server/services/permissions" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/ws" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type Params struct { Cfg *config.Configuration SingleUserToken string DBStore store.Store Logger mlog.LoggerIFace ServerID string WSAdapter ws.Adapter NotifyBackends []notify.Backend PermissionsService permissions.PermissionsService ServicesAPI model.ServicesAPI } func (p Params) CheckValid() error { if p.Cfg == nil { return ErrServerParam{name: "Cfg", issue: "cannot be nil"} } if p.DBStore == nil { return ErrServerParam{name: "DbStore", issue: "cannot be nil"} } if p.Logger == nil { return ErrServerParam{name: "Logger", issue: "cannot be nil"} } if p.PermissionsService == nil { return ErrServerParam{name: "Permissions", issue: "cannot be nil"} } return nil } type ErrServerParam struct { name string issue string } func (e ErrServerParam) Error() string { return fmt.Sprintf("invalid server params: %s %s", e.name, e.issue) } ================================================ FILE: server/server/server.go ================================================ package server import ( "database/sql" "fmt" "net" "net/http" "os" "runtime" "sync" "syscall" "time" "github.com/gorilla/mux" "github.com/pkg/errors" "github.com/mattermost/focalboard/server/api" "github.com/mattermost/focalboard/server/app" "github.com/mattermost/focalboard/server/auth" appModel "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/metrics" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/focalboard/server/services/notify/notifylogger" "github.com/mattermost/focalboard/server/services/scheduler" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/services/store/sqlstore" "github.com/mattermost/focalboard/server/services/telemetry" "github.com/mattermost/focalboard/server/services/webhook" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/web" "github.com/mattermost/focalboard/server/ws" "github.com/oklog/run" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" ) const ( cleanupSessionTaskFrequency = 10 * time.Minute updateMetricsTaskFrequency = 15 * time.Minute minSessionExpiryTime = int64(60 * 60 * 24 * 31) // 31 days MattermostAuthMod = "mattermost" ) type Server struct { config *config.Configuration wsAdapter ws.Adapter webServer *web.Server store store.Store filesBackend filestore.FileBackend telemetry *telemetry.Service logger mlog.LoggerIFace cleanUpSessionsTask *scheduler.ScheduledTask metricsServer *metrics.Service metricsService *metrics.Metrics metricsUpdaterTask *scheduler.ScheduledTask auditService *audit.Audit notificationService *notify.Service servicesStartStopMutex sync.Mutex localRouter *mux.Router localModeServer *http.Server api *api.API app *app.App } func New(params Params) (*Server, error) { if err := params.CheckValid(); err != nil { return nil, err } authenticator := auth.New(params.Cfg, params.DBStore, params.PermissionsService) // if no ws adapter is provided, we spin up a websocket server wsAdapter := params.WSAdapter if wsAdapter == nil { wsAdapter = ws.NewServer(authenticator, params.SingleUserToken, params.Cfg.AuthMode == MattermostAuthMod, params.Logger, params.DBStore) } filesBackendSettings := filestore.FileBackendSettings{} filesBackendSettings.DriverName = params.Cfg.FilesDriver filesBackendSettings.Directory = params.Cfg.FilesPath filesBackendSettings.AmazonS3AccessKeyId = params.Cfg.FilesS3Config.AccessKeyID filesBackendSettings.AmazonS3SecretAccessKey = params.Cfg.FilesS3Config.SecretAccessKey filesBackendSettings.AmazonS3Bucket = params.Cfg.FilesS3Config.Bucket filesBackendSettings.AmazonS3PathPrefix = params.Cfg.FilesS3Config.PathPrefix filesBackendSettings.AmazonS3Region = params.Cfg.FilesS3Config.Region filesBackendSettings.AmazonS3Endpoint = params.Cfg.FilesS3Config.Endpoint filesBackendSettings.AmazonS3SSL = params.Cfg.FilesS3Config.SSL filesBackendSettings.AmazonS3SignV2 = params.Cfg.FilesS3Config.SignV2 filesBackendSettings.AmazonS3SSE = params.Cfg.FilesS3Config.SSE filesBackendSettings.AmazonS3Trace = params.Cfg.FilesS3Config.Trace filesBackendSettings.AmazonS3RequestTimeoutMilliseconds = params.Cfg.FilesS3Config.Timeout filesBackend, appErr := filestore.NewFileBackend(filesBackendSettings) if appErr != nil { params.Logger.Error("Unable to initialize the files storage", mlog.Err(appErr)) return nil, errors.New("unable to initialize the files storage") } webhookClient := webhook.NewClient(params.Cfg, params.Logger) // Init metrics instanceInfo := metrics.InstanceInfo{ Version: appModel.CurrentVersion, BuildNum: appModel.BuildNumber, Edition: appModel.Edition, InstallationID: os.Getenv("MM_CLOUD_INSTALLATION_ID"), } metricsService := metrics.NewMetrics(instanceInfo) // Init audit auditService, errAudit := audit.NewAudit() if errAudit != nil { return nil, fmt.Errorf("unable to create the audit service: %w", errAudit) } if err := auditService.Configure(params.Cfg.AuditCfgFile, params.Cfg.AuditCfgJSON); err != nil { return nil, fmt.Errorf("unable to initialize the audit service: %w", err) } // Init notification services notificationService, errNotify := initNotificationService(params.NotifyBackends, params.Logger) if errNotify != nil { return nil, fmt.Errorf("cannot initialize notification service(s): %w", errNotify) } appServices := app.Services{ Auth: authenticator, Store: params.DBStore, FilesBackend: filesBackend, Webhook: webhookClient, Metrics: metricsService, Notifications: notificationService, Logger: params.Logger, Permissions: params.PermissionsService, ServicesAPI: params.ServicesAPI, SkipTemplateInit: utils.IsRunningUnitTests(), } app := app.New(params.Cfg, wsAdapter, appServices) focalboardAPI := api.NewAPI(app, params.SingleUserToken, params.Cfg.AuthMode, params.PermissionsService, params.Logger, auditService) // Local router for admin APIs localRouter := mux.NewRouter() focalboardAPI.RegisterAdminRoutes(localRouter) // Init team if _, err := app.GetRootTeam(); err != nil { params.Logger.Error("Unable to get root team", mlog.Err(err)) return nil, err } webServer := web.NewServer(params.Cfg.WebPath, params.Cfg.ServerRoot, params.Cfg.Port, params.Cfg.UseSSL, params.Cfg.LocalOnly, params.Logger) // if the adapter is a routed service, register it before the API if routedService, ok := wsAdapter.(web.RoutedService); ok { webServer.AddRoutes(routedService) } webServer.AddRoutes(focalboardAPI) settings, err := params.DBStore.GetSystemSettings() if err != nil { return nil, err } // Init telemetry telemetryID := settings["TelemetryID"] if len(telemetryID) == 0 { telemetryID = utils.NewID(utils.IDTypeNone) if err = params.DBStore.SetSystemSetting("TelemetryID", telemetryID); err != nil { return nil, err } } telemetryOpts := telemetryOptions{ app: app, cfg: params.Cfg, telemetryID: telemetryID, serverID: params.ServerID, logger: params.Logger, singleUser: len(params.SingleUserToken) > 0, } telemetryService := initTelemetry(telemetryOpts) server := Server{ config: params.Cfg, wsAdapter: wsAdapter, webServer: webServer, store: params.DBStore, filesBackend: filesBackend, telemetry: telemetryService, metricsServer: metrics.NewMetricsServer(params.Cfg.PrometheusAddress, metricsService, params.Logger), metricsService: metricsService, auditService: auditService, notificationService: notificationService, logger: params.Logger, localRouter: localRouter, api: focalboardAPI, app: app, } server.initHandlers() return &server, nil } func NewStore(config *config.Configuration, isSingleUser bool, logger mlog.LoggerIFace) (store.Store, error) { sqlDB, err := sql.Open(config.DBType, config.DBConfigString) if err != nil { logger.Error("connectDatabase failed", mlog.Err(err)) return nil, err } err = sqlDB.Ping() if err != nil { logger.Error(`Database Ping failed`, mlog.Err(err)) return nil, err } storeParams := sqlstore.Params{ DBType: config.DBType, DBPingAttempts: config.DBPingAttempts, ConnectionString: config.DBConfigString, TablePrefix: config.DBTablePrefix, Logger: logger, DB: sqlDB, IsSingleUser: isSingleUser, } var db store.Store db, err = sqlstore.New(storeParams) if err != nil { return nil, err } return db, nil } func (s *Server) Start() error { s.logger.Info("Server.Start") s.webServer.Start() s.servicesStartStopMutex.Lock() defer s.servicesStartStopMutex.Unlock() if s.config.EnableLocalMode { if err := s.startLocalModeServer(); err != nil { return err } } if s.config.AuthMode != MattermostAuthMod { s.cleanUpSessionsTask = scheduler.CreateRecurringTask("cleanUpSessions", func() { secondsAgo := minSessionExpiryTime if secondsAgo < s.config.SessionExpireTime { secondsAgo = s.config.SessionExpireTime } if err := s.store.CleanUpSessions(secondsAgo); err != nil { s.logger.Error("Unable to clean up the sessions", mlog.Err(err)) } }, cleanupSessionTaskFrequency) } metricsUpdater := func() { blockCounts, err := s.store.GetBlockCountsByType() if err != nil { s.logger.Error("Error updating metrics", mlog.String("group", "blocks"), mlog.Err(err)) return } s.logger.Debug("Block metrics collected", mlog.Map("block_counts", blockCounts)) for blockType, count := range blockCounts { s.metricsService.ObserveBlockCount(blockType, count) } boardCount, err := s.store.GetBoardCount() if err != nil { s.logger.Error("Error updating metrics", mlog.String("group", "boards"), mlog.Err(err)) return } s.logger.Debug("Board metrics collected", mlog.Int("board_count", boardCount)) s.metricsService.ObserveBoardCount(boardCount) teamCount, err := s.store.GetTeamCount() if err != nil { s.logger.Error("Error updating metrics", mlog.String("group", "teams"), mlog.Err(err)) return } s.logger.Debug("Team metrics collected", mlog.Int("team_count", teamCount)) s.metricsService.ObserveTeamCount(teamCount) } // metricsUpdater() Calling this immediately causes integration unit tests to fail. s.metricsUpdaterTask = scheduler.CreateRecurringTask("updateMetrics", metricsUpdater, updateMetricsTaskFrequency) if s.config.Telemetry { firstRun := utils.GetMillis() s.telemetry.RunTelemetryJob(firstRun) } var group run.Group if s.config.PrometheusAddress != "" { group.Add(func() error { if err := s.metricsServer.Run(); err != nil { return errors.Wrap(err, "PromServer Run") } return nil }, func(error) { _ = s.metricsServer.Shutdown() }) if err := group.Run(); err != nil { return err } } return nil } func (s *Server) Shutdown() error { if err := s.webServer.Shutdown(); err != nil { return err } s.stopLocalModeServer() s.servicesStartStopMutex.Lock() defer s.servicesStartStopMutex.Unlock() if s.cleanUpSessionsTask != nil { s.cleanUpSessionsTask.Cancel() } if s.metricsUpdaterTask != nil { s.metricsUpdaterTask.Cancel() } if err := s.telemetry.Shutdown(); err != nil { s.logger.Warn("Error occurred when shutting down telemetry", mlog.Err(err)) } if err := s.auditService.Shutdown(); err != nil { s.logger.Warn("Error occurred when shutting down audit service", mlog.Err(err)) } if err := s.notificationService.Shutdown(); err != nil { s.logger.Warn("Error occurred when shutting down notification service", mlog.Err(err)) } s.app.Shutdown() defer s.logger.Info("Server.Shutdown") return s.store.Shutdown() } func (s *Server) Config() *config.Configuration { return s.config } func (s *Server) Logger() mlog.LoggerIFace { return s.logger } func (s *Server) App() *app.App { return s.app } func (s *Server) Store() store.Store { return s.store } func (s *Server) UpdateAppConfig() { s.app.SetConfig(s.config) } // Local server func (s *Server) startLocalModeServer() error { s.localModeServer = &http.Server{ //nolint:gosec Handler: s.localRouter, ConnContext: api.SetContextConn, } // TODO: Close and delete socket file on shutdown // Delete existing socket if it exists if _, err := os.Stat(s.config.LocalModeSocketLocation); err == nil { if err := syscall.Unlink(s.config.LocalModeSocketLocation); err != nil { s.logger.Error("Unable to unlink socket.", mlog.Err(err)) } } socket := s.config.LocalModeSocketLocation unixListener, err := net.Listen("unix", socket) if err != nil { return err } if err = os.Chmod(socket, 0600); err != nil { return err } go func() { s.logger.Info("Starting unix socket server") err = s.localModeServer.Serve(unixListener) if err != nil && !errors.Is(err, http.ErrServerClosed) { s.logger.Error("Error starting unix socket server", mlog.Err(err)) } }() return nil } func (s *Server) stopLocalModeServer() { if s.localModeServer != nil { _ = s.localModeServer.Close() s.localModeServer = nil } } func (s *Server) GetRootRouter() *mux.Router { return s.webServer.Router() } type telemetryOptions struct { app *app.App cfg *config.Configuration telemetryID string serverID string logger mlog.LoggerIFace singleUser bool } func initTelemetry(opts telemetryOptions) *telemetry.Service { telemetryService := telemetry.New(opts.telemetryID, opts.logger) telemetryService.RegisterTracker("server", func() (telemetry.Tracker, error) { return map[string]interface{}{ "version": appModel.CurrentVersion, "build_number": appModel.BuildNumber, "build_hash": appModel.BuildHash, "edition": appModel.Edition, "operating_system": runtime.GOOS, "server_id": opts.serverID, }, nil }) telemetryService.RegisterTracker("config", func() (telemetry.Tracker, error) { return map[string]interface{}{ "serverRoot": opts.cfg.ServerRoot == config.DefaultServerRoot, "port": opts.cfg.Port == config.DefaultPort, "useSSL": opts.cfg.UseSSL, "dbType": opts.cfg.DBType, "single_user": opts.singleUser, "allow_public_shared_boards": opts.cfg.EnablePublicSharedBoards, }, nil }) telemetryService.RegisterTracker("activity", func() (telemetry.Tracker, error) { m := make(map[string]interface{}) var count int var err error if count, err = opts.app.GetRegisteredUserCount(); err != nil { return nil, err } m["registered_users"] = count if count, err = opts.app.GetDailyActiveUsers(); err != nil { return nil, err } m["daily_active_users"] = count if count, err = opts.app.GetWeeklyActiveUsers(); err != nil { return nil, err } m["weekly_active_users"] = count if count, err = opts.app.GetMonthlyActiveUsers(); err != nil { return nil, err } m["monthly_active_users"] = count return m, nil }) telemetryService.RegisterTracker("blocks", func() (telemetry.Tracker, error) { blockCounts, err := opts.app.GetBlockCountsByType() if err != nil { return nil, err } m := make(map[string]interface{}) for k, v := range blockCounts { m[k] = v } return m, nil }) telemetryService.RegisterTracker("boards", func() (telemetry.Tracker, error) { boardCount, err := opts.app.GetBoardCount() if err != nil { return nil, err } m := map[string]interface{}{ "boards": boardCount, } return m, nil }) telemetryService.RegisterTracker("teams", func() (telemetry.Tracker, error) { count, err := opts.app.GetTeamCount() if err != nil { return nil, err } m := map[string]interface{}{ "teams": count, } return m, nil }) return telemetryService } func initNotificationService(backends []notify.Backend, logger mlog.LoggerIFace) (*notify.Service, error) { loggerBackend := notifylogger.New(logger, mlog.LvlDebug) backends = append(backends, loggerBackend) service, err := notify.New(logger, backends...) return service, err } ================================================ FILE: server/services/audit/audit.go ================================================ package audit import ( "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( DefMaxQueueSize = 1000 KeyAPIPath = "api_path" KeyEvent = "event" KeyStatus = "status" KeyUserID = "user_id" KeySessionID = "session_id" KeyClient = "client" KeyIPAddress = "ip_address" KeyClusterID = "cluster_id" KeyTeamID = "team_id" Success = "success" Attempt = "attempt" Fail = "fail" ) var ( LevelAuth = mlog.Level{ID: 1000, Name: "auth"} LevelModify = mlog.Level{ID: 1001, Name: "mod"} LevelRead = mlog.Level{ID: 1002, Name: "read"} ) // Audit provides auditing service. type Audit struct { auditLogger *mlog.Logger } // NewAudit creates a new Audit instance which can be configured via `(*Audit).Configure`. func NewAudit(options ...mlog.Option) (*Audit, error) { logger, err := mlog.NewLogger(options...) if err != nil { return nil, err } return &Audit{ auditLogger: logger, }, nil } // Configure provides a new configuration for this audit service. // Zero or more sources of config can be provided: // // cfgFile - path to file containing JSON // cfgEscaped - JSON string probably from ENV var // // For each case JSON containing log targets is provided. Target name collisions are resolved // using the following precedence: // // cfgFile > cfgEscaped func (a *Audit) Configure(cfgFile string, cfgEscaped string) error { return a.auditLogger.Configure(cfgFile, cfgEscaped, nil) } // Shutdown shuts down the audit service after making best efforts to flush any // remaining records. func (a *Audit) Shutdown() error { return a.auditLogger.Shutdown() } // LogRecord emits an audit record with complete info. func (a *Audit) LogRecord(level mlog.Level, rec *Record) { fields := make([]mlog.Field, 0, 7+len(rec.Meta)) fields = append(fields, mlog.String(KeyAPIPath, rec.APIPath)) fields = append(fields, mlog.String(KeyEvent, rec.Event)) fields = append(fields, mlog.String(KeyStatus, rec.Status)) fields = append(fields, mlog.String(KeyUserID, rec.UserID)) fields = append(fields, mlog.String(KeySessionID, rec.SessionID)) fields = append(fields, mlog.String(KeyClient, rec.Client)) fields = append(fields, mlog.String(KeyIPAddress, rec.IPAddress)) for _, meta := range rec.Meta { fields = append(fields, mlog.Any(meta.K, meta.V)) } a.auditLogger.Log(level, "audit "+rec.Event, fields...) } ================================================ FILE: server/services/audit/record.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package audit import "github.com/mattermost/mattermost/server/public/shared/mlog" // Meta represents metadata that can be added to a audit record as name/value pairs. type Meta struct { K string V interface{} } // FuncMetaTypeConv defines a function that can convert meta data types into something // that serializes well for audit records. type FuncMetaTypeConv func(val interface{}) (newVal interface{}, converted bool) // Record provides a consistent set of fields used for all audit logging. type Record struct { APIPath string Event string Status string UserID string SessionID string Client string IPAddress string Meta []Meta metaConv []FuncMetaTypeConv } // Success marks the audit record status as successful. func (rec *Record) Success() { rec.Status = Success } // Success marks the audit record status as failed. func (rec *Record) Fail() { rec.Status = Fail } // AddMeta adds a single name/value pair to this audit record's metadata. func (rec *Record) AddMeta(name string, val interface{}) { if rec.Meta == nil { rec.Meta = []Meta{} } // possibly convert val to something better suited for serializing // via zero or more conversion functions. for _, conv := range rec.metaConv { converted, wasConverted := conv(val) if wasConverted { val = converted break } } lc, ok := val.(mlog.LogCloner) if ok { val = lc.LogClone() } rec.Meta = append(rec.Meta, Meta{K: name, V: val}) } // AddMetaTypeConverter adds a function capable of converting meta field types // into something more suitable for serialization. func (rec *Record) AddMetaTypeConverter(f FuncMetaTypeConv) { rec.metaConv = append(rec.metaConv, f) } ================================================ FILE: server/services/audit/record_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package audit import ( "testing" "github.com/stretchr/testify/require" ) type bloated struct { fld1 string fld2 string fld3 string fld4 string } type wilted struct { wilt1 string } func conv(val interface{}) (interface{}, bool) { if b, ok := val.(*bloated); ok { return &wilted{wilt1: b.fld1}, true } return val, false } func TestRecord_AddMeta(t *testing.T) { type fields struct { metaConv []FuncMetaTypeConv } type args struct { name string val interface{} } tests := []struct { name string fields fields args args wantWilt bool wantVal string }{ {name: "no converter", wantWilt: false, wantVal: "ok", fields: fields{}, args: args{name: "prop", val: "ok"}}, {name: "don't convert", wantWilt: false, wantVal: "ok", fields: fields{metaConv: []FuncMetaTypeConv{conv}}, args: args{name: "prop", val: "ok"}}, {name: "convert", wantWilt: true, wantVal: "1", fields: fields{metaConv: []FuncMetaTypeConv{conv}}, args: args{name: "prop", val: &bloated{ fld1: "1", fld2: "2", fld3: "3", fld4: "4"}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rec := &Record{ metaConv: tt.fields.metaConv, } rec.AddMeta(tt.args.name, tt.args.val) // fetch the prop store in auditRecord meta data var ok bool var got interface{} for _, meta := range rec.Meta { if meta.K == "prop" { ok = true got = meta.V break } } require.True(t, ok) // check if conversion was expected val, ok := got.(*wilted) require.Equal(t, tt.wantWilt, ok) if ok { // if converted to wilt then make sure field was copied require.Equal(t, tt.wantVal, val.wilt1) } else { // if not converted, make sure val is unchanged require.Equal(t, tt.wantVal, got) } }) } } ================================================ FILE: server/services/auth/email.go ================================================ package auth import "regexp" var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") // IsEmailValid checks if the email provided passes the required structure and length. func IsEmailValid(e string) bool { if len(e) < 3 || len(e) > 254 { return false } return emailRegex.MatchString(e) } ================================================ FILE: server/services/auth/password.go ================================================ package auth import ( "fmt" "strings" "golang.org/x/crypto/bcrypt" ) const ( PasswordMaximumLength = 64 PasswordSpecialChars = "!\"\\#$%&'()*+,-./:;<=>?@[]^_`|~" //nolint:gosec PasswordNumbers = "0123456789" PasswordUpperCaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" PasswordLowerCaseLetters = "abcdefghijklmnopqrstuvwxyz" PasswordAllChars = PasswordSpecialChars + PasswordNumbers + PasswordUpperCaseLetters + PasswordLowerCaseLetters InvalidLowercasePassword = "lowercase" InvalidMinLengthPassword = "min-length" InvalidMaxLengthPassword = "max-length" InvalidNumberPassword = "number" InvalidUppercasePassword = "uppercase" InvalidSymbolPassword = "symbol" ) var PasswordHashStrength = 10 // HashPassword generates a hash using the bcrypt.GenerateFromPassword. func HashPassword(password string) string { hash, err := bcrypt.GenerateFromPassword([]byte(password), PasswordHashStrength) if err != nil { panic(err) } return string(hash) } // ComparePassword compares the hash. func ComparePassword(hash, password string) bool { if len(password) == 0 || len(hash) == 0 { return false } err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } type InvalidPasswordError struct { FailingCriterias []string } func (ipe *InvalidPasswordError) Error() string { return fmt.Sprintf("invalid password, failing criteria: %s", strings.Join(ipe.FailingCriterias, ", ")) } type PasswordSettings struct { MinimumLength int Lowercase bool Number bool Uppercase bool Symbol bool } func IsPasswordValid(password string, settings PasswordSettings) error { err := &InvalidPasswordError{ FailingCriterias: []string{}, } if len(password) < settings.MinimumLength { err.FailingCriterias = append(err.FailingCriterias, InvalidMinLengthPassword) } if len(password) > PasswordMaximumLength { err.FailingCriterias = append(err.FailingCriterias, InvalidMaxLengthPassword) } if settings.Lowercase { if !strings.ContainsAny(password, PasswordLowerCaseLetters) { err.FailingCriterias = append(err.FailingCriterias, InvalidLowercasePassword) } } if settings.Uppercase { if !strings.ContainsAny(password, PasswordUpperCaseLetters) { err.FailingCriterias = append(err.FailingCriterias, InvalidUppercasePassword) } } if settings.Number { if !strings.ContainsAny(password, PasswordNumbers) { err.FailingCriterias = append(err.FailingCriterias, InvalidNumberPassword) } } if settings.Symbol { if !strings.ContainsAny(password, PasswordSpecialChars) { err.FailingCriterias = append(err.FailingCriterias, InvalidSymbolPassword) } } if len(err.FailingCriterias) > 0 { return err } return nil } ================================================ FILE: server/services/auth/password_test.go ================================================ package auth import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPasswordHash(t *testing.T) { hash := HashPassword("Test") assert.True(t, ComparePassword(hash, "Test"), "Passwords don't match") assert.False(t, ComparePassword(hash, "Test2"), "Passwords should not have matched") } func TestIsPasswordValidWithSettings(t *testing.T) { for name, tc := range map[string]struct { Password string Settings PasswordSettings ExpectedFailingCriterias []string }{ "Short": { Password: strings.Repeat("x", 3), Settings: PasswordSettings{ MinimumLength: 3, Lowercase: false, Uppercase: false, Number: false, Symbol: false, }, }, "Long": { Password: strings.Repeat("x", PasswordMaximumLength), Settings: PasswordSettings{ MinimumLength: 3, Lowercase: false, Uppercase: false, Number: false, Symbol: false, }, }, "TooShort": { Password: strings.Repeat("x", 2), Settings: PasswordSettings{ MinimumLength: 3, Lowercase: false, Uppercase: false, Number: false, Symbol: false, }, ExpectedFailingCriterias: []string{"min-length"}, }, "TooLong": { Password: strings.Repeat("x", PasswordMaximumLength+1), Settings: PasswordSettings{ MinimumLength: 3, Lowercase: false, Uppercase: false, Number: false, Symbol: false, }, ExpectedFailingCriterias: []string{"max-length"}, }, "MissingLower": { Password: "AAAAAAAAAAASD123!@#", Settings: PasswordSettings{ MinimumLength: 3, Lowercase: true, Uppercase: false, Number: false, Symbol: false, }, ExpectedFailingCriterias: []string{"lowercase"}, }, "MissingUpper": { Password: "aaaaaaaaaaaaasd123!@#", Settings: PasswordSettings{ MinimumLength: 3, Uppercase: true, Lowercase: false, Number: false, Symbol: false, }, ExpectedFailingCriterias: []string{"uppercase"}, }, "MissingNumber": { Password: "asasdasdsadASD!@#", Settings: PasswordSettings{ MinimumLength: 3, Number: true, Lowercase: false, Uppercase: false, Symbol: false, }, ExpectedFailingCriterias: []string{"number"}, }, "MissingSymbol": { Password: "asdasdasdasdasdASD123", Settings: PasswordSettings{ MinimumLength: 3, Symbol: true, Lowercase: false, Uppercase: false, Number: false, }, ExpectedFailingCriterias: []string{"symbol"}, }, "MissingMultiple": { Password: "asdasdasdasdasdasd", Settings: PasswordSettings{ MinimumLength: 3, Lowercase: true, Uppercase: true, Number: true, Symbol: true, }, ExpectedFailingCriterias: []string{"uppercase", "number", "symbol"}, }, "Everything": { Password: "asdASD!@#123", Settings: PasswordSettings{ MinimumLength: 3, Lowercase: true, Uppercase: true, Number: true, Symbol: true, }, }, } { t.Run(name, func(t *testing.T) { err := IsPasswordValid(tc.Password, tc.Settings) if len(tc.ExpectedFailingCriterias) == 0 { assert.NoError(t, err) } else { require.Error(t, err) var errFC *InvalidPasswordError if assert.ErrorAs(t, err, &errFC) { assert.Equal(t, tc.ExpectedFailingCriterias, errFC.FailingCriterias) } } }) } } ================================================ FILE: server/services/auth/request_parser.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package auth import ( "net/http" "strings" ) const ( HeaderToken = "token" HeaderAuth = "Authorization" HeaderBearer = "BEARER" SessionCookieToken = "FOCALBOARDAUTHTOKEN" ) type TokenLocation int const ( TokenLocationNotFound TokenLocation = iota TokenLocationHeader TokenLocationCookie TokenLocationQueryString ) func (tl TokenLocation) String() string { switch tl { case TokenLocationNotFound: return "Not Found" case TokenLocationHeader: return "Header" case TokenLocationCookie: return "Cookie" case TokenLocationQueryString: return "QueryString" default: return "Unknown" } } func ParseAuthTokenFromRequest(r *http.Request) (string, TokenLocation) { authHeader := r.Header.Get(HeaderAuth) // Attempt to parse the token from the cookie if cookie, err := r.Cookie(SessionCookieToken); err == nil { return cookie.Value, TokenLocationCookie } // Parse the token from the header if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == HeaderBearer { // Default session token return authHeader[7:], TokenLocationHeader } if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == HeaderToken { // OAuth token return authHeader[6:], TokenLocationHeader } // Attempt to parse token out of the query string if token := r.URL.Query().Get("access_token"); token != "" { return token, TokenLocationQueryString } return "", TokenLocationNotFound } ================================================ FILE: server/services/auth/request_parser_test.go ================================================ package auth import ( "net/http" "net/http/httptest" "strconv" "testing" "github.com/stretchr/testify/require" ) func TestParseAuthTokenFromRequest(t *testing.T) { cases := []struct { header string cookie string query string expectedToken string expectedLocation TokenLocation }{ {"", "", "", "", TokenLocationNotFound}, {"token mytoken", "", "", "mytoken", TokenLocationHeader}, {"BEARER mytoken", "", "", "mytoken", TokenLocationHeader}, {"", "mytoken", "", "mytoken", TokenLocationCookie}, {"", "", "mytoken", "mytoken", TokenLocationQueryString}, } for testnum, tc := range cases { pathname := "/test/here" if tc.query != "" { pathname += "?access_token=" + tc.query } req := httptest.NewRequest("GET", pathname, nil) if tc.header != "" { req.Header.Add(HeaderAuth, tc.header) } if tc.cookie != "" { req.AddCookie(&http.Cookie{ Name: "FOCALBOARDAUTHTOKEN", Value: tc.cookie, }) } token, location := ParseAuthTokenFromRequest(req) require.Equal(t, tc.expectedToken, token, "Wrong token on test "+strconv.Itoa(testnum)) require.Equal(t, tc.expectedLocation, location, "Wrong location on test "+strconv.Itoa(testnum)) } } ================================================ FILE: server/services/config/config.go ================================================ package config import ( "log" "github.com/spf13/viper" ) const ( DefaultServerRoot = "http://localhost:8000" DefaultPort = 8000 DBPingAttempts = 5 ) type AmazonS3Config struct { AccessKeyID string SecretAccessKey string Bucket string PathPrefix string Region string Endpoint string SSL bool SignV2 bool SSE bool Trace bool Timeout int64 } // Configuration is the app configuration stored in a json file. type Configuration struct { ServerRoot string `json:"serverRoot" mapstructure:"serverRoot"` Port int `json:"port" mapstructure:"port"` DBType string `json:"dbtype" mapstructure:"dbtype"` DBConfigString string `json:"dbconfig" mapstructure:"dbconfig"` DBPingAttempts int `json:"dbpingattempts" mapstructure:"dbpingattempts"` DBTablePrefix string `json:"dbtableprefix" mapstructure:"dbtableprefix"` UseSSL bool `json:"useSSL" mapstructure:"useSSL"` SecureCookie bool `json:"secureCookie" mapstructure:"secureCookie"` WebPath string `json:"webpath" mapstructure:"webpath"` FilesDriver string `json:"filesdriver" mapstructure:"filesdriver"` FilesS3Config AmazonS3Config `json:"filess3config" mapstructure:"filess3config"` FilesPath string `json:"filespath" mapstructure:"filespath"` MaxFileSize int64 `json:"maxfilesize" mapstructure:"maxfilesize"` Telemetry bool `json:"telemetry" mapstructure:"telemetry"` TelemetryID string `json:"telemetryid" mapstructure:"telemetryid"` PrometheusAddress string `json:"prometheusaddress" mapstructure:"prometheusaddress"` WebhookUpdate []string `json:"webhook_update" mapstructure:"webhook_update"` Secret string `json:"secret" mapstructure:"secret"` SessionExpireTime int64 `json:"session_expire_time" mapstructure:"session_expire_time"` SessionRefreshTime int64 `json:"session_refresh_time" mapstructure:"session_refresh_time"` LocalOnly bool `json:"localonly" mapstructure:"localonly"` EnableLocalMode bool `json:"enableLocalMode" mapstructure:"enableLocalMode"` LocalModeSocketLocation string `json:"localModeSocketLocation" mapstructure:"localModeSocketLocation"` EnablePublicSharedBoards bool `json:"enablePublicSharedBoards" mapstructure:"enablePublicSharedBoards"` FeatureFlags map[string]string `json:"featureFlags" mapstructure:"featureFlags"` EnableDataRetention bool `json:"enable_data_retention" mapstructure:"enable_data_retention"` DataRetentionDays int `json:"data_retention_days" mapstructure:"data_retention_days"` TeammateNameDisplay string `json:"teammate_name_display" mapstructure:"teammateNameDisplay"` ShowEmailAddress bool `json:"show_email_address" mapstructure:"showEmailAddress"` ShowFullName bool `json:"show_full_name" mapstructure:"showFullName"` AuthMode string `json:"authMode" mapstructure:"authMode"` LoggingCfgFile string `json:"logging_cfg_file" mapstructure:"logging_cfg_file"` LoggingCfgJSON string `json:"logging_cfg_json" mapstructure:"logging_cfg_json"` AuditCfgFile string `json:"audit_cfg_file" mapstructure:"audit_cfg_file"` AuditCfgJSON string `json:"audit_cfg_json" mapstructure:"audit_cfg_json"` NotifyFreqCardSeconds int `json:"notify_freq_card_seconds" mapstructure:"notify_freq_card_seconds"` NotifyFreqBoardSeconds int `json:"notify_freq_board_seconds" mapstructure:"notify_freq_board_seconds"` } // ReadConfigFile read the configuration from the filesystem. func ReadConfigFile(configFilePath string) (*Configuration, error) { if configFilePath == "" { viper.SetConfigFile("./config.json") } else { viper.SetConfigFile(configFilePath) } viper.SetEnvPrefix("focalboard") viper.AutomaticEnv() // read config values from env like FOCALBOARD_SERVERROOT=... viper.SetDefault("ServerRoot", DefaultServerRoot) viper.SetDefault("DBPingAttempts", DBPingAttempts) viper.SetDefault("Port", DefaultPort) viper.SetDefault("DBType", "sqlite3") viper.SetDefault("DBConfigString", "./focalboard.db") viper.SetDefault("DBTablePrefix", "") viper.SetDefault("SecureCookie", false) viper.SetDefault("WebPath", "./pack") viper.SetDefault("FilesPath", "./files") viper.SetDefault("FilesDriver", "local") viper.SetDefault("Telemetry", true) viper.SetDefault("TelemetryID", "") viper.SetDefault("WebhookUpdate", nil) viper.SetDefault("SessionExpireTime", 60*60*24*30) // 30 days session lifetime viper.SetDefault("SessionRefreshTime", 60*60*5) // 5 minutes session refresh viper.SetDefault("LocalOnly", false) viper.SetDefault("EnableLocalMode", false) viper.SetDefault("LocalModeSocketLocation", "/var/tmp/focalboard_local.socket") viper.SetDefault("EnablePublicSharedBoards", false) viper.SetDefault("AuthMode", "native") viper.SetDefault("NotifyFreqCardSeconds", 120) // 2 minutes after last card edit viper.SetDefault("NotifyFreqBoardSeconds", 86400) // 1 day after last card edit viper.SetDefault("EnableDataRetention", false) viper.SetDefault("FeatureFlags", map[string]string{}) viper.SetDefault("DataRetentionDays", 365) // 1 year is default viper.SetDefault("PrometheusAddress", "") viper.SetDefault("TeammateNameDisplay", "username") viper.SetDefault("ShowEmailAddress", false) viper.SetDefault("ShowFullName", false) err := viper.ReadInConfig() // Find and read the config file if err != nil { // Handle errors reading the config file return nil, err } configuration := Configuration{} err = viper.Unmarshal(&configuration) if err != nil { return nil, err } log.Println("readConfigFile") log.Printf("%+v", removeSecurityData(configuration)) return &configuration, nil } func removeSecurityData(config Configuration) Configuration { clean := config return clean } ================================================ FILE: server/services/metrics/metrics.go ================================================ package metrics import ( "os" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" ) const ( MetricsNamespace = "focalboard" MetricsSubsystemBlocks = "blocks" MetricsSubsystemBoards = "boards" MetricsSubsystemTeams = "teams" MetricsSubsystemSystem = "system" MetricsCloudInstallationLabel = "installationId" ) type InstanceInfo struct { Version string BuildNum string Edition string InstallationID string } // Metrics used to instrumentate metrics in prometheus. type Metrics struct { registry *prometheus.Registry instance *prometheus.GaugeVec startTime prometheus.Gauge loginCount prometheus.Counter logoutCount prometheus.Counter loginFailCount prometheus.Counter blocksInsertedCount prometheus.Counter blocksPatchedCount prometheus.Counter blocksDeletedCount prometheus.Counter blockCount *prometheus.GaugeVec boardCount prometheus.Gauge teamCount prometheus.Gauge blockLastActivity prometheus.Gauge } // NewMetrics Factory method to create a new metrics collector. func NewMetrics(info InstanceInfo) *Metrics { m := &Metrics{} m.registry = prometheus.NewRegistry() options := collectors.ProcessCollectorOpts{ Namespace: MetricsNamespace, } m.registry.MustRegister(collectors.NewProcessCollector(options)) m.registry.MustRegister(collectors.NewGoCollector()) additionalLabels := map[string]string{} if info.InstallationID != "" { additionalLabels[MetricsCloudInstallationLabel] = os.Getenv("MM_CLOUD_INSTALLATION_ID") } m.loginCount = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemSystem, Name: "login_total", Help: "Total number of logins.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.loginCount) m.logoutCount = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemSystem, Name: "logout_total", Help: "Total number of logouts.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.logoutCount) m.loginFailCount = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemSystem, Name: "login_fail_total", Help: "Total number of failed logins.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.loginFailCount) m.instance = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemSystem, Name: "focalboard_instance_info", Help: "Instance information for Focalboard.", ConstLabels: additionalLabels, }, []string{"Version", "BuildNum", "Edition"}) m.registry.MustRegister(m.instance) m.instance.WithLabelValues(info.Version, info.BuildNum, info.Edition).Set(1) m.startTime = prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemSystem, Name: "server_start_time", Help: "The time the server started.", ConstLabels: additionalLabels, }) m.startTime.SetToCurrentTime() m.registry.MustRegister(m.startTime) m.blocksInsertedCount = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemBlocks, Name: "blocks_inserted_total", Help: "Total number of blocks inserted.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.blocksInsertedCount) m.blocksPatchedCount = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemBlocks, Name: "blocks_patched_total", Help: "Total number of blocks patched.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.blocksPatchedCount) m.blocksDeletedCount = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemBlocks, Name: "blocks_deleted_total", Help: "Total number of blocks deleted.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.blocksDeletedCount) m.blockCount = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemBlocks, Name: "blocks_total", Help: "Total number of blocks.", ConstLabels: additionalLabels, }, []string{"BlockType"}) m.registry.MustRegister(m.blockCount) m.boardCount = prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemBoards, Name: "boards_total", Help: "Total number of boards.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.boardCount) m.teamCount = prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemTeams, Name: "teams_total", Help: "Total number of teams.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.teamCount) m.blockLastActivity = prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: MetricsNamespace, Subsystem: MetricsSubsystemBlocks, Name: "blocks_last_activity", Help: "Time of last block insert, update, delete.", ConstLabels: additionalLabels, }) m.registry.MustRegister(m.blockLastActivity) return m } func (m *Metrics) IncrementLoginCount(num int) { if m != nil { m.loginCount.Add(float64(num)) } } func (m *Metrics) IncrementLogoutCount(num int) { if m != nil { m.logoutCount.Add(float64(num)) } } func (m *Metrics) IncrementLoginFailCount(num int) { if m != nil { m.loginFailCount.Add(float64(num)) } } func (m *Metrics) IncrementBlocksInserted(num int) { if m != nil { m.blocksInsertedCount.Add(float64(num)) m.blockLastActivity.SetToCurrentTime() } } func (m *Metrics) IncrementBlocksPatched(num int) { if m != nil { m.blocksPatchedCount.Add(float64(num)) m.blockLastActivity.SetToCurrentTime() } } func (m *Metrics) IncrementBlocksDeleted(num int) { if m != nil { m.blocksDeletedCount.Add(float64(num)) m.blockLastActivity.SetToCurrentTime() } } func (m *Metrics) ObserveBlockCount(blockType string, count int64) { if m != nil { m.blockCount.WithLabelValues(blockType).Set(float64(count)) } } func (m *Metrics) ObserveBoardCount(count int64) { if m != nil { m.boardCount.Set(float64(count)) } } func (m *Metrics) ObserveTeamCount(count int64) { if m != nil { m.teamCount.Set(float64(count)) } } ================================================ FILE: server/services/metrics/service.go ================================================ package metrics import ( "net/http" "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // Service prometheus to run the server. type Service struct { *http.Server } // NewMetricsServer factory method to create a new prometheus server. func NewMetricsServer(address string, metricsService *Metrics, logger mlog.LoggerIFace) *Service { return &Service{ &http.Server{ //nolint:gosec Addr: address, Handler: promhttp.HandlerFor(metricsService.registry, promhttp.HandlerOpts{ ErrorLog: logger.StdLogger(mlog.LvlError), }), }, } } // Run will start the prometheus server. func (h *Service) Run() error { return errors.Wrap(h.Server.ListenAndServe(), "prometheus ListenAndServe") } // Shutdown will shutdown the prometheus server. func (h *Service) Shutdown() error { return errors.Wrap(h.Server.Close(), "prometheus Close") } ================================================ FILE: server/services/notify/notifylogger/logger_backend.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifylogger import ( "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( backendName = "notifyLogger" ) type Backend struct { logger mlog.LoggerIFace level mlog.Level } func New(logger mlog.LoggerIFace, level mlog.Level) *Backend { return &Backend{ logger: logger, level: level, } } func (b *Backend) Start() error { return nil } func (b *Backend) ShutDown() error { _ = b.logger.Flush() return nil } func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { var board string var card string if evt.Board != nil { board = evt.Board.Title } if evt.Card != nil { card = evt.Card.Title } b.logger.Log(b.level, "Block change event", mlog.String("action", string(evt.Action)), mlog.String("board", board), mlog.String("card", card), mlog.String("block_id", evt.BlockChanged.ID), ) return nil } func (b *Backend) Name() string { return backendName } ================================================ FILE: server/services/notify/notifymentions/app_api.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifymentions import "github.com/mattermost/focalboard/server/model" type AppAPI interface { GetMemberForBoard(boardID, userID string) (*model.BoardMember, error) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) } ================================================ FILE: server/services/notify/notifymentions/delivery.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifymentions import ( "github.com/mattermost/focalboard/server/services/notify" mm_model "github.com/mattermost/mattermost/server/public/model" ) // MentionDelivery provides an interface for delivering @mention notifications to other systems, such as // channels server via plugin API. // On success the user id of the user mentioned is returned. type MentionDelivery interface { MentionDeliver(mentionedUser *mm_model.User, extract string, evt notify.BlockChangeEvent) (string, error) UserByUsername(mentionUsername string) (*mm_model.User, error) } ================================================ FILE: server/services/notify/notifymentions/extract.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifymentions import "strings" const ( defPrefixLines = 2 defPrefixMaxChars = 100 defSuffixLines = 2 defSuffixMaxChars = 100 ) type limits struct { prefixLines int prefixMaxChars int suffixLines int suffixMaxChars int } func newLimits() limits { return limits{ prefixLines: defPrefixLines, prefixMaxChars: defPrefixMaxChars, suffixLines: defSuffixLines, suffixMaxChars: defSuffixMaxChars, } } // extractText returns all or a subset of the input string, such that // no more than `prefixLines` lines preceding the mention and `suffixLines` // lines after the mention are returned, and no more than approx // prefixMaxChars+suffixMaxChars are returned. func extractText(s string, mention string, limits limits) string { if !strings.HasPrefix(mention, "@") { mention = "@" + mention } lines := strings.Split(s, "\n") // find first line with mention found := -1 for i, l := range lines { if strings.Contains(l, mention) { found = i break } } if found == -1 { return "" } prefix := safeConcat(lines, found-limits.prefixLines, found) suffix := safeConcat(lines, found+1, found+limits.suffixLines+1) combined := strings.TrimSpace(strings.Join([]string{prefix, lines[found], suffix}, "\n")) // find mention position within pos := strings.Index(combined, mention) pos = max(pos, 0) return safeSubstr(combined, pos-limits.prefixMaxChars, pos+limits.suffixMaxChars) } func safeConcat(lines []string, start int, end int) string { count := len(lines) start = min(max(start, 0), count) end = min(max(end, start), count) var sb strings.Builder for i := start; i < end; i++ { if lines[i] != "" { sb.WriteString(lines[i]) sb.WriteByte('\n') } } return strings.TrimSpace(sb.String()) } func safeSubstr(s string, start int, end int) string { count := len(s) start = min(max(start, 0), count) end = min(max(end, start), count) return s[start:end] } func min(a int, b int) int { if a < b { return a } return b } func max(a int, b int) int { if a > b { return a } return b } ================================================ FILE: server/services/notify/notifymentions/extract_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifymentions import ( "strings" "testing" ) const ( s0 = "Zero is in the mind @billy." s1 = "This is line 1." s2 = "Line two is right here." s3 = "Three is the line I am." s4 = "'Four score and seven years...', said @lincoln." s5 = "Fast Five was arguably the best F&F film." s6 = "Big Hero 6 may have an inflated sense of self." s7 = "The seventh sign, @sarah, will be a failed unit test." ) var ( all = []string{s0, s1, s2, s3, s4, s5, s6, s7} allConcat = strings.Join(all, "\n") extractLimits = limits{ prefixLines: 2, prefixMaxChars: 100, suffixLines: 2, suffixMaxChars: 100, } ) func join(s ...string) string { return strings.Join(s, "\n") } func Test_extractText(t *testing.T) { type args struct { s string mention string limits limits } tests := []struct { name string args args want string }{ {name: "good", want: join(s2, s3, s4, s5, s6), args: args{mention: "@lincoln", limits: extractLimits, s: allConcat}}, {name: "not found", want: "", args: args{mention: "@bogus", limits: extractLimits, s: allConcat}}, {name: "one line", want: join(s4), args: args{mention: "@lincoln", limits: extractLimits, s: s4}}, {name: "two lines", want: join(s4, s5), args: args{mention: "@lincoln", limits: extractLimits, s: join(s4, s5)}}, {name: "zero lines", want: "", args: args{mention: "@lincoln", limits: extractLimits, s: ""}}, {name: "first line mention", want: join(s0, s1, s2), args: args{mention: "@billy", limits: extractLimits, s: allConcat}}, {name: "last line mention", want: join(s5[7:], s6, s7), args: args{mention: "@sarah", limits: extractLimits, s: allConcat}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := extractText(tt.args.s, tt.args.mention, tt.args.limits); got != tt.want { t.Errorf("extractText()\ngot:\n%v\nwant:\n%v\n", got, tt.want) } }) } } func Test_safeConcat(t *testing.T) { type args struct { lines []string start int end int } tests := []struct { name string args args want string }{ {name: "out of range", want: join(s0, s1, s2, s3, s4, s5, s6, s7), args: args{start: -22, end: 99, lines: all}}, {name: "2,3", want: join(s2, s3), args: args{start: 2, end: 4, lines: all}}, {name: "mismatch", want: "", args: args{start: 4, end: 2, lines: all}}, {name: "empty", want: "", args: args{start: 2, end: 4, lines: []string{}}}, {name: "nil", want: "", args: args{start: 2, end: 4, lines: nil}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := safeConcat(tt.args.lines, tt.args.start, tt.args.end); got != tt.want { t.Errorf("safeConcat() = [%v], want [%v]", got, tt.want) } }) } } func Test_safeSubstr(t *testing.T) { type args struct { s string start int end int } tests := []struct { name string args args want string }{ {name: "good", want: "is line", args: args{start: 33, end: 40, s: join(s0, s1, s2)}}, {name: "out of range", want: allConcat, args: args{start: -10, end: 1000, s: allConcat}}, {name: "mismatch", want: "", args: args{start: 33, end: 26, s: allConcat}}, {name: "empty", want: "", args: args{start: 2, end: 4, s: ""}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := safeSubstr(tt.args.s, tt.args.start, tt.args.end); got != tt.want { t.Errorf("safeSubstr()\ngot:\n[%v]\nwant:\n[%v]\n", got, tt.want) } }) } } ================================================ FILE: server/services/notify/notifymentions/mentions.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifymentions import ( "regexp" "strings" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" ) var atMentionRegexp = regexp.MustCompile(`\B@[[:alnum:]][[:alnum:]\.\-_:]*`) // extractMentions extracts any mentions in the specified block and returns // a slice of usernames. func extractMentions(block *model.Block) map[string]struct{} { mentions := make(map[string]struct{}) if block == nil || !strings.Contains(block.Title, "@") { return mentions } str := block.Title for _, match := range atMentionRegexp.FindAllString(str, -1) { name := mm_model.NormalizeUsername(match[1:]) if mm_model.IsValidUsernameAllowRemote(name) { mentions[name] = struct{}{} } } return mentions } ================================================ FILE: server/services/notify/notifymentions/mentions_backend.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifymentions import ( "errors" "fmt" "sync" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/focalboard/server/services/permissions" "github.com/wiggin77/merror" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( backendName = "notifyMentions" ) var ( ErrMentionPermission = errors.New("mention not permitted") ) type MentionListener interface { OnMention(userID string, evt notify.BlockChangeEvent) } type BackendParams struct { AppAPI AppAPI Permissions permissions.PermissionsService Delivery MentionDelivery Logger mlog.LoggerIFace } // Backend provides the notification backend for @mentions. type Backend struct { appAPI AppAPI permissions permissions.PermissionsService delivery MentionDelivery logger mlog.LoggerIFace mux sync.RWMutex listeners []MentionListener } func New(params BackendParams) *Backend { return &Backend{ appAPI: params.AppAPI, permissions: params.Permissions, delivery: params.Delivery, logger: params.Logger, } } func (b *Backend) Start() error { return nil } func (b *Backend) ShutDown() error { _ = b.logger.Flush() return nil } func (b *Backend) Name() string { return backendName } func (b *Backend) AddListener(l MentionListener) { b.mux.Lock() defer b.mux.Unlock() b.listeners = append(b.listeners, l) b.logger.Debug("Mention listener added.", mlog.Int("listener_count", len(b.listeners))) } func (b *Backend) RemoveListener(l MentionListener) { b.mux.Lock() defer b.mux.Unlock() list := make([]MentionListener, 0, len(b.listeners)) for _, listener := range b.listeners { if listener != l { list = append(list, listener) } } b.listeners = list b.logger.Debug("Mention listener removed.", mlog.Int("listener_count", len(b.listeners))) } func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { if evt.Board == nil || evt.Card == nil { return nil } if evt.Action == notify.Delete { return nil } switch evt.BlockChanged.Type { case model.TypeText, model.TypeComment, model.TypeImage: default: return nil } mentions := extractMentions(evt.BlockChanged) if len(mentions) == 0 { return nil } oldMentions := extractMentions(evt.BlockOld) merr := merror.New() b.mux.RLock() listeners := make([]MentionListener, len(b.listeners)) copy(listeners, b.listeners) b.mux.RUnlock() for username := range mentions { if _, exists := oldMentions[username]; exists { // the mention already existed; no need to notify again continue } extract := extractText(evt.BlockChanged.Title, username, newLimits()) userID, err := b.deliverMentionNotification(username, extract, evt) if err != nil { if errors.Is(err, ErrMentionPermission) { b.logger.Debug("Cannot deliver notification", mlog.String("user", username), mlog.Err(err)) } else { merr.Append(fmt.Errorf("cannot deliver notification for @%s: %w", username, err)) } } if userID == "" { // was a `@` followed by something other than a username. continue } b.logger.Debug("Mention notification delivered", mlog.String("user", username), mlog.Int("listener_count", len(listeners)), ) for _, listener := range listeners { safeCallListener(listener, userID, evt, b.logger) } } return merr.ErrorOrNil() } func safeCallListener(listener MentionListener, userID string, evt notify.BlockChangeEvent, logger mlog.LoggerIFace) { // don't let panicky listeners stop notifications defer func() { if r := recover(); r != nil { logger.Error("panic calling @mention notification listener", mlog.Any("err", r)) } }() listener.OnMention(userID, evt) } func (b *Backend) deliverMentionNotification(username string, extract string, evt notify.BlockChangeEvent) (string, error) { mentionedUser, err := b.delivery.UserByUsername(username) if err != nil { if model.IsErrNotFound(err) { // not really an error; could just be someone typed "@sometext" return "", nil } else { return "", fmt.Errorf("cannot lookup mentioned user: %w", err) } } if evt.ModifiedBy == nil { return "", fmt.Errorf("invalid user cannot mention: %w", ErrMentionPermission) } if evt.Board.Type == model.BoardTypeOpen { // public board rules: // - admin, editor, commenter: can mention anyone on team (mentioned users are automatically added to board) // - guest: can mention board members switch { case evt.ModifiedBy.SchemeAdmin, evt.ModifiedBy.SchemeEditor, evt.ModifiedBy.SchemeCommenter: if !b.permissions.HasPermissionToTeam(mentionedUser.Id, evt.TeamID, model.PermissionViewTeam) { return "", fmt.Errorf("%s cannot mention non-team member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission) } // add mentioned user to board (if not already a member) member, err := b.appAPI.GetMemberForBoard(evt.Board.ID, mentionedUser.Id) if member == nil || model.IsErrNotFound(err) { // create memberships based on minimum board role newBoardMember := &model.BoardMember{ UserID: mentionedUser.Id, BoardID: evt.Board.ID, SchemeViewer: evt.Board.MinimumRole == model.BoardRoleViewer || evt.Board.MinimumRole == model.BoardRoleCommenter || evt.Board.MinimumRole == model.BoardRoleEditor, SchemeCommenter: evt.Board.MinimumRole == model.BoardRoleCommenter || evt.Board.MinimumRole == model.BoardRoleEditor, SchemeEditor: evt.Board.MinimumRole == model.BoardRoleEditor, } if _, err = b.appAPI.AddMemberToBoard(newBoardMember); err != nil { return "", fmt.Errorf("cannot add mentioned user %s to board %s: %w", mentionedUser.Id, evt.Board.ID, err) } b.logger.Debug("auto-added mentioned user to board", mlog.String("user_id", mentionedUser.Id), mlog.String("board_id", evt.Board.ID), mlog.String("board_type", string(evt.Board.Type)), ) } else { b.logger.Debug("skipping auto-add mentioned user to board; already a member", mlog.String("user_id", mentionedUser.Id), mlog.String("board_id", evt.Board.ID), mlog.String("board_type", string(evt.Board.Type)), ) } case evt.ModifiedBy.SchemeViewer: // viewer should not have gotten this far since they cannot add text to a card return "", fmt.Errorf("%s (viewer) cannot mention user %s: %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission) default: // this is a guest if !b.permissions.HasPermissionToBoard(mentionedUser.Id, evt.Board.ID, model.PermissionViewBoard) { return "", fmt.Errorf("%s cannot mention non-board member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission) } } } else { // private board rules: // - admin, editor, commenter, guest: can mention board members switch { case evt.ModifiedBy.SchemeViewer: // viewer should not have gotten this far since they cannot add text to a card return "", fmt.Errorf("%s (viewer) cannot mention user %s: %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission) default: // everyone else can mention board members if !b.permissions.HasPermissionToBoard(mentionedUser.Id, evt.Board.ID, model.PermissionViewBoard) { return "", fmt.Errorf("%s cannot mention non-board member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission) } } } return b.delivery.MentionDeliver(mentionedUser, extract, evt) } ================================================ FILE: server/services/notify/notifymentions/mentions_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifymentions import ( "reflect" "testing" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" ) func Test_extractMentions(t *testing.T) { tests := []struct { name string block *model.Block want map[string]struct{} }{ {name: "empty", block: makeBlock(""), want: makeMap()}, {name: "zero mentions", block: makeBlock("This is some text."), want: makeMap()}, {name: "one mention", block: makeBlock("Hello @user1"), want: makeMap("user1")}, {name: "multiple mentions", block: makeBlock("Hello @user1, @user2 and @user3"), want: makeMap("user1", "user2", "user3")}, {name: "include period", block: makeBlock("Hello @user1."), want: makeMap("user1.")}, {name: "include underscore", block: makeBlock("Hello @user1_"), want: makeMap("user1_")}, {name: "don't include comma", block: makeBlock("Hello @user1,"), want: makeMap("user1")}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := extractMentions(tt.block); !reflect.DeepEqual(got, tt.want) { t.Errorf("extractMentions() = %v, want %v", got, tt.want) } }) } } func makeBlock(text string) *model.Block { return &model.Block{ ID: mm_model.NewId(), Type: model.TypeComment, Title: text, } } func makeMap(mentions ...string) map[string]struct{} { m := make(map[string]struct{}) for _, mention := range mentions { m[mention] = struct{}{} } return m } ================================================ FILE: server/services/notify/notifysubscriptions/app_api.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifysubscriptions import ( "time" "github.com/mattermost/focalboard/server/model" ) type AppAPI interface { GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error) GetBlockHistoryNewestChildren(parentID string, opts model.QueryBlockHistoryChildOptions) ([]*model.Block, bool, error) GetBoardAndCardByID(blockID string) (board *model.Board, card *model.Block, err error) GetUserByID(userID string) (*model.User, error) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) GetSubscribersForBlock(blockID string) ([]*model.Subscriber, error) UpdateSubscribersNotifiedAt(blockID string, notifyAt int64) error UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) GetNextNotificationHint(remove bool) (*model.NotificationHint, error) } ================================================ FILE: server/services/notify/notifysubscriptions/delivery.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifysubscriptions import ( "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" ) // SubscriptionDelivery provides an interface for delivering subscription notifications to other systems, such as // channels server via plugin API. type SubscriptionDelivery interface { SubscriptionDeliverSlackAttachments(teamID string, subscriberID string, subscriberType model.SubscriberType, attachments []*mm_model.SlackAttachment) error } ================================================ FILE: server/services/notify/notifysubscriptions/diff.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifysubscriptions import ( "fmt" "sort" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // Diff represents a difference between two versions of a block. type Diff struct { Board *model.Board Card *model.Block Authors StringMap BlockType model.BlockType OldBlock *model.Block NewBlock *model.Block UpdateAt int64 // the UpdateAt of the latest version of the block schemaDiffs []SchemaDiff PropDiffs []PropDiff Diffs []*Diff // Diffs for child blocks } type PropDiff struct { ID string // property id Index int Name string OldValue string NewValue string } type SchemaDiff struct { Board *model.Board OldPropDef *model.PropDef NewPropDef *model.PropDef } type diffGenerator struct { board *model.Board card *model.Block store AppAPI hint *model.NotificationHint lastNotifyAt int64 logger mlog.LoggerIFace } func (dg *diffGenerator) generateDiffs() ([]*Diff, error) { // use block_history to fetch blocks in case they were deleted and no longer exist in blocks table. opts := model.QueryBlockHistoryOptions{ Limit: 1, Descending: true, } blocks, err := dg.store.GetBlockHistory(dg.hint.BlockID, opts) if err != nil { return nil, fmt.Errorf("could not get block for notification: %w", err) } if len(blocks) == 0 { return nil, fmt.Errorf("block not found for notification: %w", err) } block := blocks[0] if dg.board == nil || dg.card == nil { return nil, fmt.Errorf("cannot generate diff for block %s; must have a valid board and card: %w", dg.hint.BlockID, err) } // parse board's property schema here so it only happens once. schema, err := model.ParsePropertySchema(dg.board) if err != nil { return nil, fmt.Errorf("could not parse property schema for board %s: %w", dg.board.ID, err) } switch block.Type { case model.TypeBoard: dg.logger.Warn("generateDiffs for board skipped", mlog.String("block_id", block.ID)) // TODO: Fix this // return dg.generateDiffsForBoard(block, schema) return nil, nil case model.TypeCard: diff, err := dg.generateDiffsForCard(block, schema) if err != nil || diff == nil { return nil, err } return []*Diff{diff}, nil default: diff, err := dg.generateDiffForBlock(block, schema) if err != nil || diff == nil { return nil, err } return []*Diff{diff}, nil } } // TODO: fix this /* func (dg *diffGenerator) generateDiffsForBoard(board *model.Board, schema model.PropSchema) ([]*Diff, error) { opts := model.QuerySubtreeOptions{ AfterUpdateAt: dg.lastNotifyAt, } find all child blocks of the board that updated since last notify. blocks, err := dg.store.GetSubTree2(board.ID, board.ID, opts) if err != nil { return nil, fmt.Errorf("could not get subtree for board %s: %w", board.ID, err) } var diffs []*Diff generate diff for board title change or description boardDiff, err := dg.generateDiffForBlock(board, schema) if err != nil { return nil, fmt.Errorf("could not generate diff for board %s: %w", board.ID, err) } if boardDiff != nil { TODO: phase 2 feature (generate schema diffs and add to board diff) goes here. diffs = append(diffs, boardDiff) } for _, b := range blocks { block := b if block.Type == model.TypeCard { cardDiffs, err := dg.generateDiffsForCard(&block, schema) if err != nil { return nil, err } diffs = append(diffs, cardDiffs) } } return diffs, nil } */ func (dg *diffGenerator) generateDiffsForCard(card *model.Block, schema model.PropSchema) (*Diff, error) { // generate diff for card title change and properties. cardDiff, err := dg.generateDiffForBlock(card, schema) if err != nil { return nil, fmt.Errorf("could not generate diff for card %s: %w", card.ID, err) } // fetch all card content blocks that were updated after last notify opts := model.QueryBlockHistoryChildOptions{ AfterUpdateAt: dg.lastNotifyAt, } blocks, _, err := dg.store.GetBlockHistoryNewestChildren(card.ID, opts) if err != nil { return nil, fmt.Errorf("could not get subtree for card %s: %w", card.ID, err) } authors := make(StringMap) // walk child blocks var childDiffs []*Diff for i := range blocks { if blocks[i].ID == card.ID { continue } blockDiff, err := dg.generateDiffForBlock(blocks[i], schema) if err != nil { return nil, fmt.Errorf("could not generate diff for block %s: %w", blocks[i].ID, err) } if blockDiff != nil { childDiffs = append(childDiffs, blockDiff) authors.Append(blockDiff.Authors) } } dg.logger.Debug("generateDiffsForCard", mlog.Bool("has_top_changes", cardDiff != nil), mlog.Int("subtree", len(blocks)), mlog.Array("author_names", authors.Values()), mlog.Int("child_diffs", len(childDiffs)), ) if len(childDiffs) != 0 { if cardDiff == nil { // will be nil if the card has no other changes besides child diffs cardDiff = &Diff{ Board: dg.board, Card: card, Authors: make(StringMap), BlockType: card.Type, OldBlock: card, NewBlock: card, UpdateAt: card.UpdateAt, PropDiffs: nil, schemaDiffs: nil, } } cardDiff.Diffs = childDiffs } cardDiff.Authors.Append(authors) return cardDiff, nil } func (dg *diffGenerator) generateDiffForBlock(newBlock *model.Block, schema model.PropSchema) (*Diff, error) { dg.logger.Debug("generateDiffForBlock - new block", mlog.String("block_id", newBlock.ID), mlog.String("block_type", string(newBlock.Type)), mlog.String("modified_by", newBlock.ModifiedBy), mlog.Int("update_at", newBlock.UpdateAt), ) // find the version of the block as it was at the time of last notify. opts := model.QueryBlockHistoryOptions{ BeforeUpdateAt: dg.lastNotifyAt + 1, Limit: 1, Descending: true, } history, err := dg.store.GetBlockHistory(newBlock.ID, opts) if err != nil { return nil, fmt.Errorf("could not get block history for block %s: %w", newBlock.ID, err) } var oldBlock *model.Block if len(history) != 0 { oldBlock = history[0] dg.logger.Debug("generateDiffForBlock - old block", mlog.String("block_id", oldBlock.ID), mlog.String("block_type", string(oldBlock.Type)), mlog.Int("before_update_at", dg.lastNotifyAt), mlog.String("modified_by", oldBlock.ModifiedBy), mlog.Int("update_at", oldBlock.UpdateAt), ) } // find all the versions of the blocks that changed so we can gather all the author usernames. opts = model.QueryBlockHistoryOptions{ AfterUpdateAt: dg.lastNotifyAt, Descending: true, } chgBlocks, err := dg.store.GetBlockHistory(newBlock.ID, opts) if err != nil { return nil, fmt.Errorf("error getting block history for block %s: %w", newBlock.ID, err) } authors := make(StringMap) dg.logger.Debug("generateDiffForBlock - authors", mlog.Int("after_update_at", dg.lastNotifyAt), mlog.Int("history_count", len(chgBlocks)), ) // have to loop through history slice because GetBlockHistory does not return pointers. for _, b := range chgBlocks { user, err := dg.store.GetUserByID(b.ModifiedBy) if err != nil || user == nil { dg.logger.Error("could not fetch username for block", mlog.String("modified_by", b.ModifiedBy), mlog.Err(err), ) authors.Add(b.ModifiedBy, "unknown_user") // todo: localize this when server has i18n } else { authors.Add(user.ID, user.Username) } } propDiffs := dg.generatePropDiffs(oldBlock, newBlock, schema) dg.logger.Debug("generateDiffForBlock - results", mlog.String("block_id", newBlock.ID), mlog.String("block_type", string(newBlock.Type)), mlog.Array("author_names", authors.Values()), mlog.Int("history_count", len(history)), mlog.Int("prop_diff_count", len(propDiffs)), ) diff := &Diff{ Board: dg.board, Card: dg.card, Authors: authors, BlockType: newBlock.Type, OldBlock: oldBlock, NewBlock: newBlock, UpdateAt: newBlock.UpdateAt, PropDiffs: propDiffs, schemaDiffs: nil, } return diff, nil } func (dg *diffGenerator) generatePropDiffs(oldBlock, newBlock *model.Block, schema model.PropSchema) []PropDiff { var propDiffs []PropDiff oldProps, err := model.ParseProperties(oldBlock, schema, dg.store) if err != nil { dg.logger.Error("Cannot parse properties for old block", mlog.String("block_id", oldBlock.ID), mlog.Err(err), ) } newProps, err := model.ParseProperties(newBlock, schema, dg.store) if err != nil { dg.logger.Error("Cannot parse properties for new block", mlog.String("block_id", oldBlock.ID), mlog.Err(err), ) } // look for new or changed properties. for k, prop := range newProps { oldP, ok := oldProps[k] if ok { // prop changed if prop.Value != oldP.Value { propDiffs = append(propDiffs, PropDiff{ ID: prop.ID, Index: prop.Index, Name: prop.Name, NewValue: prop.Value, OldValue: oldP.Value, }) } } else { // prop added propDiffs = append(propDiffs, PropDiff{ ID: prop.ID, Index: prop.Index, Name: prop.Name, NewValue: prop.Value, OldValue: "", }) } } // look for deleted properties for k, prop := range oldProps { _, ok := newProps[k] if !ok { // prop deleted propDiffs = append(propDiffs, PropDiff{ ID: prop.ID, Index: prop.Index, Name: prop.Name, NewValue: "", OldValue: prop.Value, }) } } return sortPropDiffs(propDiffs) } func sortPropDiffs(propDiffs []PropDiff) []PropDiff { if len(propDiffs) == 0 { return propDiffs } sort.Slice(propDiffs, func(i, j int) bool { return propDiffs[i].Index < propDiffs[j].Index }) return propDiffs } ================================================ FILE: server/services/notify/notifysubscriptions/diff2markdown.go ================================================ package notifysubscriptions import ( "strings" "github.com/sergi/go-diff/diffmatchpatch" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func generateMarkdownDiff(oldText string, newText string, logger mlog.LoggerIFace) string { oldTxtNorm := normalizeText(oldText) newTxtNorm := normalizeText(newText) dmp := diffmatchpatch.New() diffs := dmp.DiffMain(oldTxtNorm, newTxtNorm, false) diffs = dmp.DiffCleanupSemantic(diffs) diffs = dmp.DiffCleanupEfficiency(diffs) // check there is at least one insert or delete var editFound bool for _, d := range diffs { if (d.Type == diffmatchpatch.DiffInsert || d.Type == diffmatchpatch.DiffDelete) && strings.TrimSpace(d.Text) != "" { editFound = true break } } if !editFound { logger.Debug("skipping notification for superficial diff") return "" } cfg := markDownCfg{ insertOpen: "`", insertClose: "`", deleteOpen: "~~`", deleteClose: "`~~", } markdown := generateMarkdown(diffs, cfg) markdown = strings.ReplaceAll(markdown, "¶", "\n") return markdown } const ( truncLenEquals = 60 truncLenInserts = 120 truncLenDeletes = 80 ) type markDownCfg struct { insertOpen string insertClose string deleteOpen string deleteClose string } func generateMarkdown(diffs []diffmatchpatch.Diff, cfg markDownCfg) string { sb := &strings.Builder{} var first, last bool for i, diff := range diffs { first = i == 0 last = i == len(diffs)-1 switch diff.Type { case diffmatchpatch.DiffInsert: sb.WriteString(cfg.insertOpen) sb.WriteString(truncate(diff.Text, truncLenInserts, first, last)) sb.WriteString(cfg.insertClose) case diffmatchpatch.DiffDelete: sb.WriteString(cfg.deleteOpen) sb.WriteString(truncate(diff.Text, truncLenDeletes, first, last)) sb.WriteString(cfg.deleteClose) case diffmatchpatch.DiffEqual: sb.WriteString(truncate(diff.Text, truncLenEquals, first, last)) } } return sb.String() } func truncate(s string, maxLen int, first bool, last bool) string { if len(s) < maxLen { return s } var result string switch { case first: // truncate left result = " ... " + rightWords(s, maxLen) case last: // truncate right result = leftWords(s, maxLen) + " ... " default: // truncate in the middle half := len(s) / 2 left := leftWords(s[:half], maxLen/2) right := rightWords(s[half:], maxLen/2) result = left + " ... " + right } return strings.ReplaceAll(result, "¶", "↩") } func normalizeText(s string) string { s = strings.ReplaceAll(s, "\t", " ") s = strings.ReplaceAll(s, " ", " ") s = strings.ReplaceAll(s, "\n\n", "\n") s = strings.ReplaceAll(s, "\n", "¶") return s } // leftWords returns approximately maxLen characters from the left part of the source string by truncating on the right, // with best effort to include whole words. func leftWords(s string, maxLen int) string { if len(s) < maxLen { return s } fields := strings.Fields(s) fields = words(fields, maxLen) return strings.Join(fields, " ") } // rightWords returns approximately maxLen from the right part of the source string by truncating from the left, // with best effort to include whole words. func rightWords(s string, maxLen int) string { if len(s) < maxLen { return s } fields := strings.Fields(s) // reverse the fields so that the right-most words end up at the beginning. reverse(fields) fields = words(fields, maxLen) // reverse the fields again so that the original order is restored. reverse(fields) return strings.Join(fields, " ") } func reverse(ss []string) { ssLen := len(ss) for i := 0; i < ssLen/2; i++ { ss[i], ss[ssLen-i-1] = ss[ssLen-i-1], ss[i] } } // words returns a subslice containing approximately maxChars of characters. The last item may be truncated. func words(words []string, maxChars int) []string { var count int result := make([]string, 0, len(words)) for i, w := range words { wordLen := len(w) if wordLen+count > maxChars { switch { case i == 0: result = append(result, w[:maxChars]) case wordLen < 8: result = append(result, w) } return result } count += wordLen result = append(result, w) } return result } ================================================ FILE: server/services/notify/notifysubscriptions/diff2markdown_test.go ================================================ package notifysubscriptions import ( "testing" "github.com/stretchr/testify/assert" ) func Test_reverse(t *testing.T) { tests := []struct { name string ss []string want []string }{ {name: "even", ss: []string{"one", "two", "three", "four"}, want: []string{"four", "three", "two", "one"}}, {name: "odd", ss: []string{"one", "two", "three"}, want: []string{"three", "two", "one"}}, {name: "one", ss: []string{"one"}, want: []string{"one"}}, {name: "empty", ss: []string{}, want: []string{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reverse(tt.ss) assert.Equal(t, tt.want, tt.ss) }) } } ================================================ FILE: server/services/notify/notifysubscriptions/diff2slackattachments.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifysubscriptions import ( "bytes" "fmt" "io" "strings" "sync" "text/template" "github.com/mattermost/focalboard/server/model" "github.com/wiggin77/merror" mm_model "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( // card change notifications. defAddCardNotify = "{{.Authors | printAuthors \"unknown_user\" }} has added the card {{. | makeLink}}\n" defModifyCardNotify = "###### {{.Authors | printAuthors \"unknown_user\" }} has modified the card {{. | makeLink}} on the board {{. | makeBoardLink}}\n" defDeleteCardNotify = "{{.Authors | printAuthors \"unknown_user\" }} has deleted the card {{. | makeLink}}\n" ) var ( // templateCache is a map of text templateCache keyed by languange code. templateCache = make(map[string]*template.Template) templateCacheMux sync.Mutex ) // DiffConvOpts provides options when converting diffs to slack attachments. type DiffConvOpts struct { Language string MakeCardLink func(block *model.Block, board *model.Board, card *model.Block) string MakeBoardLink func(board *model.Board) string Logger mlog.LoggerIFace } // getTemplate returns a new or cached named template based on the language specified. func getTemplate(name string, opts DiffConvOpts, def string) (*template.Template, error) { templateCacheMux.Lock() defer templateCacheMux.Unlock() key := name + "&" + opts.Language t, ok := templateCache[key] if !ok { t = template.New(key) if opts.MakeCardLink == nil { opts.MakeCardLink = func(block *model.Block, _ *model.Board, _ *model.Block) string { return fmt.Sprintf("`%s`", block.Title) } } if opts.MakeBoardLink == nil { opts.MakeBoardLink = func(board *model.Board) string { return fmt.Sprintf("`%s`", board.Title) } } myFuncs := template.FuncMap{ "getBoardDescription": getBoardDescription, "makeLink": func(diff *Diff) string { return opts.MakeCardLink(diff.NewBlock, diff.Board, diff.Card) }, "makeBoardLink": func(diff *Diff) string { return opts.MakeBoardLink(diff.Board) }, "stripNewlines": func(s string) string { return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ ")) }, "printAuthors": func(empty string, authors StringMap) string { return makeAuthorsList(authors, empty) }, } t.Funcs(myFuncs) s := def // TODO: lookup i18n string when supported on server t2, err := t.Parse(s) if err != nil { return nil, fmt.Errorf("cannot parse markdown template '%s' for notifications: %w", key, err) } templateCache[key] = t2 } return t, nil } func makeAuthorsList(authors StringMap, empty string) string { if len(authors) == 0 { return empty } prefix := "" sb := &strings.Builder{} for _, name := range authors.Values() { sb.WriteString(prefix) sb.WriteString("@") sb.WriteString(strings.TrimSpace(name)) prefix = ", " } return sb.String() } // execTemplate executes the named template corresponding to the template name and language specified. func execTemplate(w io.Writer, name string, opts DiffConvOpts, def string, data interface{}) error { t, err := getTemplate(name, opts, def) if err != nil { return err } return t.Execute(w, data) } // Diffs2SlackAttachments converts a slice of `Diff` to slack attachments to be used in a post. func Diffs2SlackAttachments(diffs []*Diff, opts DiffConvOpts) ([]*mm_model.SlackAttachment, error) { var attachments []*mm_model.SlackAttachment merr := merror.New() for _, d := range diffs { // only handle cards for now. if d.BlockType == model.TypeCard { a, err := cardDiff2SlackAttachment(d, opts) if err != nil { merr.Append(err) continue } if a == nil { continue } attachments = append(attachments, a) } } return attachments, merr.ErrorOrNil() } func cardDiff2SlackAttachment(cardDiff *Diff, opts DiffConvOpts) (*mm_model.SlackAttachment, error) { // sanity check if cardDiff.NewBlock == nil && cardDiff.OldBlock == nil { return nil, nil } attachment := &mm_model.SlackAttachment{} buf := &bytes.Buffer{} // card added if cardDiff.NewBlock != nil && cardDiff.OldBlock == nil { if err := execTemplate(buf, "AddCardNotify", opts, defAddCardNotify, cardDiff); err != nil { return nil, err } attachment.Pretext = buf.String() attachment.Fallback = attachment.Pretext return attachment, nil } // card deleted if (cardDiff.NewBlock == nil || cardDiff.NewBlock.DeleteAt != 0) && cardDiff.OldBlock != nil { buf.Reset() if err := execTemplate(buf, "DeleteCardNotify", opts, defDeleteCardNotify, cardDiff); err != nil { return nil, err } attachment.Pretext = buf.String() attachment.Fallback = attachment.Pretext return attachment, nil } // at this point new and old block are non-nil opts.Logger.Debug("cardDiff2SlackAttachment", mlog.String("board_id", cardDiff.Board.ID), mlog.String("card_id", cardDiff.Card.ID), mlog.String("new_block_id", cardDiff.NewBlock.ID), mlog.String("old_block_id", cardDiff.OldBlock.ID), mlog.Int("childDiffs", len(cardDiff.Diffs)), ) buf.Reset() if err := execTemplate(buf, "ModifyCardNotify", opts, defModifyCardNotify, cardDiff); err != nil { return nil, fmt.Errorf("cannot write notification for card %s: %w", cardDiff.NewBlock.ID, err) } attachment.Pretext = buf.String() attachment.Fallback = attachment.Pretext // title changes attachment.Fields = appendTitleChanges(attachment.Fields, cardDiff) // property changes attachment.Fields = appendPropertyChanges(attachment.Fields, cardDiff) // comment add/delete attachment.Fields = appendCommentChanges(attachment.Fields, cardDiff) // File Attachment add/delete attachment.Fields = appendAttachmentChanges(attachment.Fields, cardDiff) // content/description changes attachment.Fields = appendContentChanges(attachment.Fields, cardDiff, opts.Logger) if len(attachment.Fields) == 0 { return nil, nil } return attachment, nil } func appendTitleChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField { if cardDiff.NewBlock.Title != cardDiff.OldBlock.Title { fields = append(fields, &mm_model.SlackAttachmentField{ Short: false, Title: "Title", Value: fmt.Sprintf("%s ~~`%s`~~", stripNewlines(cardDiff.NewBlock.Title), stripNewlines(cardDiff.OldBlock.Title)), }) } return fields } func appendPropertyChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField { if len(cardDiff.PropDiffs) == 0 { return fields } for _, propDiff := range cardDiff.PropDiffs { if propDiff.NewValue == propDiff.OldValue { continue } var val string if propDiff.OldValue != "" { val = fmt.Sprintf("%s ~~`%s`~~", stripNewlines(propDiff.NewValue), stripNewlines(propDiff.OldValue)) } else { val = propDiff.NewValue } fields = append(fields, &mm_model.SlackAttachmentField{ Short: false, Title: propDiff.Name, Value: val, }) } return fields } func appendCommentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField { for _, child := range cardDiff.Diffs { if child.BlockType == model.TypeComment { var format string var msg string if child.NewBlock != nil && child.OldBlock == nil { // added comment format = "%s" msg = child.NewBlock.Title } if (child.NewBlock == nil || child.NewBlock.DeleteAt != 0) && child.OldBlock != nil { // deleted comment format = "~~`%s`~~" msg = stripNewlines(child.OldBlock.Title) } if format != "" { fields = append(fields, &mm_model.SlackAttachmentField{ Short: false, Title: "Comment by " + makeAuthorsList(child.Authors, "unknown_user"), // todo: localize this when server has i18n Value: fmt.Sprintf(format, msg), }) } } } return fields } func appendAttachmentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField { for _, child := range cardDiff.Diffs { if child.BlockType == model.TypeAttachment { var format string var msg string if child.NewBlock != nil && child.OldBlock == nil { format = "Added an attachment: **`%s`**" msg = child.NewBlock.Title } else { format = "Removed ~~`%s`~~ attachment" msg = stripNewlines(child.OldBlock.Title) } if format != "" { fields = append(fields, &mm_model.SlackAttachmentField{ Short: false, Title: "Changed by " + makeAuthorsList(child.Authors, "unknown_user"), // TODO: localize this when server has i18n Value: fmt.Sprintf(format, msg), }) } } } return fields } func appendContentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff, logger mlog.LoggerIFace) []*mm_model.SlackAttachmentField { for _, child := range cardDiff.Diffs { var opAdd, opDelete bool var opString string switch { case child.OldBlock == nil && child.NewBlock != nil: opAdd = true opString = "added" // TODO: localize when i18n added to server case child.NewBlock == nil || child.NewBlock.DeleteAt != 0: opDelete = true opString = "deleted" default: opString = "modified" } var newTitle, oldTitle string if child.OldBlock != nil { oldTitle = child.OldBlock.Title } if child.NewBlock != nil { newTitle = child.NewBlock.Title } switch child.BlockType { case model.TypeDivider, model.TypeComment: // do nothing continue case model.TypeImage: if newTitle == "" { newTitle = "An image was " + opString + "." // TODO: localize when i18n added to server } oldTitle = "" case model.TypeAttachment: if newTitle == "" { newTitle = "A file attachment was " + opString + "." // TODO: localize when i18n added to server } oldTitle = "" default: if !opAdd { if opDelete { newTitle = "" } // only strip newlines when modifying or deleting oldTitle = stripNewlines(oldTitle) newTitle = stripNewlines(newTitle) } if newTitle == oldTitle { continue } } logger.Trace("appendContentChanges", mlog.String("type", string(child.BlockType)), mlog.String("opString", opString), mlog.String("oldTitle", oldTitle), mlog.String("newTitle", newTitle), ) markdown := generateMarkdownDiff(oldTitle, newTitle, logger) if markdown == "" { continue } fields = append(fields, &mm_model.SlackAttachmentField{ Short: false, Title: "Description", Value: markdown, }) } return fields } ================================================ FILE: server/services/notify/notifysubscriptions/notifier.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifysubscriptions import ( "errors" "fmt" "sync" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/permissions" "github.com/mattermost/focalboard/server/utils" "github.com/wiggin77/merror" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( defBlockNotificationFreq = time.Minute * 2 enqueueNotifyHintTimeout = time.Second * 10 hintQueueSize = 20 ) var ( errEnqueueNotifyHintTimeout = errors.New("enqueue notify hint timed out") ) // notifier provides block change notifications for subscribers. Block change events are batched // via notifications hints written to the database so that fewer notifications are sent for active // blocks. type notifier struct { serverRoot string store AppAPI permissions permissions.PermissionsService delivery SubscriptionDelivery logger mlog.LoggerIFace hints chan *model.NotificationHint mux sync.Mutex done chan struct{} } func newNotifier(params BackendParams) *notifier { return ¬ifier{ serverRoot: params.ServerRoot, store: params.AppAPI, permissions: params.Permissions, delivery: params.Delivery, logger: params.Logger, done: nil, hints: make(chan *model.NotificationHint, hintQueueSize), } } func (n *notifier) start() { n.mux.Lock() defer n.mux.Unlock() if n.done == nil { n.done = make(chan struct{}) go n.loop() } } func (n *notifier) stop() { n.mux.Lock() defer n.mux.Unlock() if n.done != nil { close(n.done) n.done = nil } } func (n *notifier) loop() { done := n.done var nextNotify time.Time for { hint, err := n.store.GetNextNotificationHint(false) switch { case model.IsErrNotFound(err): // no hints in table; wait up to an hour or when `onNotifyHint` is called again nextNotify = time.Now().Add(time.Hour * 1) n.logger.Debug("notify loop - no hints in queue", mlog.Time("next_check", nextNotify)) case err != nil: // try again in a minute nextNotify = time.Now().Add(time.Minute * 1) n.logger.Error("notify loop - error fetching next notification", mlog.Err(err)) case hint.NotifyAt > utils.GetMillis(): // next hint is not ready yet; sleep until hint.NotifyAt nextNotify = utils.GetTimeForMillis(hint.NotifyAt) default: // it's time to notify n.notify() continue } n.logger.Debug("subscription notifier loop", mlog.Time("next_notify", nextNotify), ) select { case <-n.hints: // A new hint was added. Wake up and check if next hint is ready to go. case <-time.After(time.Until(nextNotify)): // Next scheduled hint should be ready now. case <-done: return } } } func (n *notifier) onNotifyHint(hint *model.NotificationHint) error { n.logger.Debug("onNotifyHint - enqueing hint", mlog.Any("hint", hint)) select { case n.hints <- hint: case <-time.After(enqueueNotifyHintTimeout): return errEnqueueNotifyHintTimeout } return nil } func (n *notifier) notify() { var hint *model.NotificationHint var err error hint, err = n.store.GetNextNotificationHint(true) if err != nil { if model.IsErrNotFound(err) { // Expected when multiple nodes in a cluster try to process the same hint at the same time. // This simply means the other node won. Returning here will simply try fetching another hint. return } n.logger.Error("notify - error fetching next notification", mlog.Err(err)) return } if err = n.notifySubscribers(hint); err != nil { n.logger.Error("Error notifying subscribers", mlog.Err(err)) } } func (n *notifier) notifySubscribers(hint *model.NotificationHint) error { // get the subscriber list subs, err := n.store.GetSubscribersForBlock(hint.BlockID) if err != nil { return err } if len(subs) == 0 { n.logger.Debug("notifySubscribers - no subscribers", mlog.Any("hint", hint)) return nil } // subs slice is sorted by `NotifiedAt`, therefore subs[0] contains the oldest NotifiedAt needed oldestNotifiedAt := subs[0].NotifiedAt // need the block's board and card. board, card, err := n.store.GetBoardAndCardByID(hint.BlockID) if err != nil || board == nil || card == nil { return fmt.Errorf("could not get board & card for block %s: %w", hint.BlockID, err) } n.logger.Debug("notifySubscribers - subscribers", mlog.Any("hint", hint), mlog.String("board_id", board.ID), mlog.String("card_id", card.ID), mlog.Int("sub_count", len(subs)), ) dg := &diffGenerator{ board: board, card: card, store: n.store, hint: hint, lastNotifyAt: oldestNotifiedAt, logger: n.logger, } diffs, err := dg.generateDiffs() if err != nil { return err } n.logger.Debug("notifySubscribers - diffs", mlog.Any("hint", hint), mlog.Int("diff_count", len(diffs)), ) if len(diffs) == 0 { return nil } diffAuthors := make(StringMap) for _, d := range diffs { diffAuthors.Append(d.Authors) } opts := DiffConvOpts{ Language: "en", // TODO: use correct language when i18n is available on server. MakeCardLink: func(block *model.Block, board *model.Board, card *model.Block) string { return fmt.Sprintf("[%s](%s)", block.Title, utils.MakeCardLink(n.serverRoot, board.TeamID, board.ID, card.ID)) }, MakeBoardLink: func(board *model.Board) string { return fmt.Sprintf("[%s](%s)", board.Title, utils.MakeBoardLink(n.serverRoot, board.TeamID, board.ID)) }, Logger: n.logger, } attachments, err := Diffs2SlackAttachments(diffs, opts) if err != nil { return err } merr := merror.New() if len(attachments) > 0 { for _, sub := range subs { // don't notify the author of their own changes. authorName, isAuthor := diffAuthors[sub.SubscriberID] if isAuthor && len(diffAuthors) == 1 { n.logger.Debug("notifySubscribers - skipping author", mlog.Any("hint", hint), mlog.String("author_id", sub.SubscriberID), mlog.String("author_username", authorName), ) continue } // make sure the subscriber still has permissions for the board. if !n.permissions.HasPermissionToBoard(sub.SubscriberID, board.ID, model.PermissionViewBoard) { n.logger.Debug("notifySubscribers - skipping non-board member", mlog.Any("hint", hint), mlog.String("subscriber_id", sub.SubscriberID), mlog.String("board_id", board.ID), ) continue } n.logger.Debug("notifySubscribers - deliver", mlog.Any("hint", hint), mlog.String("modified_by_id", hint.ModifiedByID), mlog.String("subscriber_id", sub.SubscriberID), mlog.String("subscriber_type", string(sub.SubscriberType)), ) if err = n.delivery.SubscriptionDeliverSlackAttachments(board.TeamID, sub.SubscriberID, sub.SubscriberType, attachments); err != nil { merr.Append(fmt.Errorf("cannot deliver notification to subscriber %s [%s]: %w", sub.SubscriberID, sub.SubscriberType, err)) } } } else { n.logger.Debug("notifySubscribers - skip delivery; no chg", mlog.Any("hint", hint), mlog.String("modified_by_id", hint.ModifiedByID), ) } // find the new NotifiedAt based on the newest diff. var notifiedAt int64 for _, d := range diffs { if d.UpdateAt > notifiedAt { notifiedAt = d.UpdateAt } for _, c := range d.Diffs { if c.UpdateAt > notifiedAt { notifiedAt = c.UpdateAt } } } // update the last notified_at for all subscribers since we at least attempted to notify all of them. err = dg.store.UpdateSubscribersNotifiedAt(dg.hint.BlockID, notifiedAt) if err != nil { merr.Append(fmt.Errorf("could not update subscribers notified_at for block %s: %w", dg.hint.BlockID, err)) } return merr.ErrorOrNil() } ================================================ FILE: server/services/notify/notifysubscriptions/subscriptions_backend.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifysubscriptions import ( "fmt" "os" "strconv" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/focalboard/server/services/permissions" "github.com/wiggin77/merror" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( backendName = "notifySubscriptions" ) type BackendParams struct { ServerRoot string AppAPI AppAPI Permissions permissions.PermissionsService Delivery SubscriptionDelivery Logger mlog.LoggerIFace NotifyFreqCardSeconds int NotifyFreqBoardSeconds int } // Backend provides the notification backend for subscriptions. type Backend struct { appAPI AppAPI permissions permissions.PermissionsService delivery SubscriptionDelivery notifier *notifier logger mlog.LoggerIFace notifyFreqCardSeconds int notifyFreqBoardSeconds int } func New(params BackendParams) *Backend { return &Backend{ appAPI: params.AppAPI, delivery: params.Delivery, permissions: params.Permissions, notifier: newNotifier(params), logger: params.Logger, notifyFreqCardSeconds: params.NotifyFreqCardSeconds, notifyFreqBoardSeconds: params.NotifyFreqBoardSeconds, } } func (b *Backend) Start() error { b.logger.Debug("Starting subscriptions backend", mlog.Int("freq_card", b.notifyFreqCardSeconds), mlog.Int("freq_board", b.notifyFreqBoardSeconds), ) b.notifier.start() return nil } func (b *Backend) ShutDown() error { b.logger.Debug("Stopping subscriptions backend") b.notifier.stop() _ = b.logger.Flush() return nil } func (b *Backend) Name() string { return backendName } func (b *Backend) getBlockUpdateFreq(blockType model.BlockType) time.Duration { // check for env variable override sFreq := os.Getenv("MM_BOARDS_NOTIFY_FREQ_SECONDS") if sFreq != "" && sFreq != "0" { if freq, err := strconv.ParseInt(sFreq, 10, 64); err != nil { b.logger.Error("Environment variable MM_BOARDS_NOTIFY_FREQ_SECONDS invalid (ignoring)", mlog.Err(err)) } else { return time.Second * time.Duration(freq) } } switch blockType { case model.TypeCard: return time.Second * time.Duration(b.notifyFreqCardSeconds) default: return defBlockNotificationFreq } } func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { if evt.Board == nil { b.logger.Warn("No board found for block, skipping notify", mlog.String("block_id", evt.BlockChanged.ID), ) return nil } merr := merror.New() var err error // if new card added, automatically subscribe the author. if evt.Action == notify.Add && evt.BlockChanged.Type == model.TypeCard { sub := &model.Subscription{ BlockType: model.TypeCard, BlockID: evt.BlockChanged.ID, SubscriberType: model.SubTypeUser, SubscriberID: evt.ModifiedBy.UserID, } if _, err = b.appAPI.CreateSubscription(sub); err != nil { b.logger.Warn("Cannot subscribe card author to card", mlog.String("card_id", evt.BlockChanged.ID), mlog.Err(err), ) } } // notify board subscribers subs, err := b.appAPI.GetSubscribersForBlock(evt.Board.ID) if err != nil { merr.Append(fmt.Errorf("cannot fetch subscribers for board %s: %w", evt.Board.ID, err)) } if err = b.notifySubscribers(subs, evt.Board.ID, model.TypeBoard, evt.ModifiedBy.UserID); err != nil { merr.Append(fmt.Errorf("cannot notify board subscribers for board %s: %w", evt.Board.ID, err)) } if evt.Card == nil { return merr.ErrorOrNil() } // notify card subscribers subs, err = b.appAPI.GetSubscribersForBlock(evt.Card.ID) if err != nil { merr.Append(fmt.Errorf("cannot fetch subscribers for card %s: %w", evt.Card.ID, err)) } if err = b.notifySubscribers(subs, evt.Card.ID, model.TypeCard, evt.ModifiedBy.UserID); err != nil { merr.Append(fmt.Errorf("cannot notify card subscribers for card %s: %w", evt.Card.ID, err)) } // notify block subscribers (if/when other types can be subscribed to) if evt.Board.ID != evt.BlockChanged.ID && evt.Card.ID != evt.BlockChanged.ID { subs, err := b.appAPI.GetSubscribersForBlock(evt.BlockChanged.ID) if err != nil { merr.Append(fmt.Errorf("cannot fetch subscribers for block %s: %w", evt.BlockChanged.ID, err)) } if err := b.notifySubscribers(subs, evt.BlockChanged.ID, evt.BlockChanged.Type, evt.ModifiedBy.UserID); err != nil { merr.Append(fmt.Errorf("cannot notify block subscribers for block %s: %w", evt.BlockChanged.ID, err)) } } return merr.ErrorOrNil() } // notifySubscribers triggers a change notification for subscribers by writing a notification hint to the database. func (b *Backend) notifySubscribers(subs []*model.Subscriber, blockID string, idType model.BlockType, modifiedByID string) error { if len(subs) == 0 { return nil } hint := &model.NotificationHint{ BlockType: idType, BlockID: blockID, ModifiedByID: modifiedByID, } hint, err := b.appAPI.UpsertNotificationHint(hint, b.getBlockUpdateFreq(idType)) if err != nil { return fmt.Errorf("cannot upsert notification hint: %w", err) } if err := b.notifier.onNotifyHint(hint); err != nil { return err } return nil } // OnMention satisfies the `MentionListener` interface and is called whenever a @mention notification // is sent. Here we create a subscription for the mentioned user to the card. func (b *Backend) OnMention(userID string, evt notify.BlockChangeEvent) { if evt.Card == nil { b.logger.Debug("Cannot subscribe mentioned user to nil card", mlog.String("user_id", userID), mlog.String("block_id", evt.BlockChanged.ID), ) return } // user mentioned must be a board member to subscribe to card. if !b.permissions.HasPermissionToBoard(userID, evt.Board.ID, model.PermissionViewBoard) { b.logger.Debug("Not subscribing mentioned non-board member to card", mlog.String("user_id", userID), mlog.String("block_id", evt.BlockChanged.ID), ) return } sub := &model.Subscription{ BlockType: model.TypeCard, BlockID: evt.Card.ID, SubscriberType: model.SubTypeUser, SubscriberID: userID, } var err error if _, err = b.appAPI.CreateSubscription(sub); err != nil { b.logger.Warn("Cannot subscribe mentioned user to card", mlog.String("user_id", userID), mlog.String("card_id", evt.Card.ID), mlog.Err(err), ) return } b.logger.Debug("Subscribed mentioned user to card", mlog.String("user_id", userID), mlog.String("card_id", evt.Card.ID), ) } ================================================ FILE: server/services/notify/notifysubscriptions/util.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notifysubscriptions import ( "strings" "github.com/mattermost/focalboard/server/model" ) func getBoardDescription(board *model.Block) string { if board == nil { return "" } descr, ok := board.Fields["description"] if !ok { return "" } description, ok := descr.(string) if !ok { return "" } return description } func stripNewlines(s string) string { return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ ")) } type StringMap map[string]string func (sm StringMap) Add(k string, v string) { sm[k] = v } func (sm StringMap) Append(m StringMap) { for k, v := range m { sm[k] = v } } func (sm StringMap) Keys() []string { keys := make([]string, 0, len(sm)) for k := range sm { keys = append(keys, k) } return keys } func (sm StringMap) Values() []string { values := make([]string, 0, len(sm)) for _, v := range sm { values = append(values, v) } return values } ================================================ FILE: server/services/notify/plugindelivery/mention_deliver.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package plugindelivery import ( "fmt" "github.com/mattermost/focalboard/server/services/notify" "github.com/mattermost/focalboard/server/utils" mm_model "github.com/mattermost/mattermost/server/public/model" ) // MentionDeliver notifies a user they have been mentioned in a blockv ia the plugin API. func (pd *PluginDelivery) MentionDeliver(mentionedUser *mm_model.User, extract string, evt notify.BlockChangeEvent) (string, error) { author, err := pd.api.GetUserByID(evt.ModifiedBy.UserID) if err != nil { return "", fmt.Errorf("cannot find user: %w", err) } channel, err := pd.getDirectChannel(evt.TeamID, mentionedUser.Id, pd.botID) if err != nil { return "", fmt.Errorf("cannot get direct channel: %w", err) } link := utils.MakeCardLink(pd.serverRoot, evt.Board.TeamID, evt.Board.ID, evt.Card.ID) boardLink := utils.MakeBoardLink(pd.serverRoot, evt.Board.TeamID, evt.Board.ID) post := &mm_model.Post{ UserId: pd.botID, ChannelId: channel.Id, Message: formatMessage(author.Username, extract, evt.Card.Title, link, evt.BlockChanged, boardLink, evt.Board.Title), } if _, err := pd.api.CreatePost(post); err != nil { return "", err } return mentionedUser.Id, nil } ================================================ FILE: server/services/notify/plugindelivery/message.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package plugindelivery import ( "fmt" "github.com/mattermost/focalboard/server/model" ) const ( // TODO: localize these when i18n is available. defCommentTemplate = "@%s mentioned you in a comment on the card [%s](%s) in board [%s](%s)\n> %s" defDescriptionTemplate = "@%s mentioned you in the card [%s](%s) in board [%s](%s)\n> %s" ) func formatMessage(author string, extract string, card string, link string, block *model.Block, boardLink string, board string) string { template := defDescriptionTemplate if block.Type == model.TypeComment { template = defCommentTemplate } return fmt.Sprintf(template, author, card, link, board, boardLink, extract) } ================================================ FILE: server/services/notify/plugindelivery/plugin_delivery.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package plugindelivery import ( mm_model "github.com/mattermost/mattermost/server/public/model" ) type servicesAPI interface { // GetDirectChannelOrCreate gets a direct message channel, // or creates one if it does not already exist GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error) // CreatePost creates a post. CreatePost(post *mm_model.Post) (*mm_model.Post, error) // GetUserByID gets a user by their ID. GetUserByID(userID string) (*mm_model.User, error) // GetUserByUsername gets a user by their username. GetUserByUsername(name string) (*mm_model.User, error) // GetTeamMember gets a team member by their user id. GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) // GetChannelByID gets a Channel by its ID. GetChannelByID(channelID string) (*mm_model.Channel, error) // GetChannelMember gets a channel member by userID. GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) // CreateMember adds a user to the specified team. Safe to call if the user is // already a member of the team. CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) } // PluginDelivery provides ability to send notifications to direct message channels via Mattermost plugin API. type PluginDelivery struct { botID string serverRoot string api servicesAPI } // New creates a PluginDelivery instance. func New(botID string, serverRoot string, api servicesAPI) *PluginDelivery { return &PluginDelivery{ botID: botID, serverRoot: serverRoot, api: api, } } ================================================ FILE: server/services/notify/plugindelivery/subscription_deliver.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package plugindelivery import ( "errors" "fmt" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" ) var ( ErrUnsupportedSubscriberType = errors.New("invalid subscriber type") ) // SubscriptionDeliverSlashAttachments notifies a user that changes were made to a block they are subscribed to. func (pd *PluginDelivery) SubscriptionDeliverSlackAttachments(teamID string, subscriberID string, subscriptionType model.SubscriberType, attachments []*mm_model.SlackAttachment) error { // check subscriber is member of channel _, err := pd.api.GetUserByID(subscriberID) if err != nil { if model.IsErrNotFound(err) { // subscriber is not a member of the channel; fail silently. return nil } return fmt.Errorf("cannot fetch channel member for user %s: %w", subscriberID, err) } channelID, err := pd.getDirectChannelID(teamID, subscriberID, subscriptionType, pd.botID) if err != nil { return err } post := &mm_model.Post{ UserId: pd.botID, ChannelId: channelID, } mm_model.ParseSlackAttachment(post, attachments) _, err = pd.api.CreatePost(post) return err } func (pd *PluginDelivery) getDirectChannelID(teamID string, subscriberID string, subscriberType model.SubscriberType, botID string) (string, error) { switch subscriberType { case model.SubTypeUser: user, err := pd.api.GetUserByID(subscriberID) if err != nil { return "", fmt.Errorf("cannot find user: %w", err) } channel, err := pd.getDirectChannel(teamID, user.Id, botID) if err != nil || channel == nil { return "", fmt.Errorf("cannot get direct channel: %w", err) } return channel.Id, nil case model.SubTypeChannel: return subscriberID, nil default: return "", ErrUnsupportedSubscriberType } } func (pd *PluginDelivery) getDirectChannel(teamID string, userID string, botID string) (*mm_model.Channel, error) { // first ensure the bot is a member of the team. _, err := pd.api.CreateMember(teamID, botID) if err != nil { return nil, fmt.Errorf("cannot add bot to team %s: %w", teamID, err) } return pd.api.GetDirectChannelOrCreate(userID, botID) } ================================================ FILE: server/services/notify/plugindelivery/user.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package plugindelivery import ( "strings" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" ) const ( usernameSpecialChars = ".-_ " ) func (pd *PluginDelivery) UserByUsername(username string) (*mm_model.User, error) { // check for usernames that might have trailing punctuation var user *mm_model.User var err error ok := true trimmed := username for ok { user, err = pd.api.GetUserByUsername(trimmed) if err != nil && !model.IsErrNotFound(err) { return nil, err } if err == nil { break } trimmed, ok = trimUsernameSpecialChar(trimmed) } if user == nil { return nil, err } return user, nil } // trimUsernameSpecialChar tries to remove the last character from word if it // is a special character for usernames (dot, dash or underscore). If not, it // returns the same string. func trimUsernameSpecialChar(word string) (string, bool) { len := len(word) if len > 0 && strings.LastIndexAny(word, usernameSpecialChars) == (len-1) { return word[:len-1], true } return word, false } ================================================ FILE: server/services/notify/plugindelivery/user_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package plugindelivery import ( "reflect" "testing" "github.com/mattermost/focalboard/server/model" mm_model "github.com/mattermost/mattermost/server/public/model" ) var ( defTeamID = mm_model.NewId() user1 = &mm_model.User{ Id: mm_model.NewId(), Username: "dlauder", } user2 = &mm_model.User{ Id: mm_model.NewId(), Username: "steve.mqueen", } user3 = &mm_model.User{ Id: mm_model.NewId(), Username: "bart_", } user4 = &mm_model.User{ Id: mm_model.NewId(), Username: "missing_", } user5 = &mm_model.User{ Id: mm_model.NewId(), Username: "wrong_team", } mockUsers = map[string]*mm_model.User{ "dlauder": user1, "steve.mqueen": user2, "bart_": user3, "wrong_team": user5, } ) func Test_userByUsername(t *testing.T) { servicesAPI := newServicesAPIMock(mockUsers) delivery := New("bot_id", "server_root", servicesAPI) tests := []struct { name string uname string teamID string want *mm_model.User wantErr bool }{ {name: "user1", uname: user1.Username, want: user1, wantErr: false}, {name: "user1 with period", uname: user1.Username + ".", want: user1, wantErr: false}, {name: "user1 with period plus more", uname: user1.Username + ". ", want: user1, wantErr: false}, {name: "user2 with periods", uname: user2.Username + "...", want: user2, wantErr: false}, {name: "user2 with underscore", uname: user2.Username + "_", want: user2, wantErr: false}, {name: "user2 with hyphen plus more", uname: user2.Username + "- ", want: user2, wantErr: false}, {name: "user2 with hyphen plus all", uname: user2.Username + ".-_ ", want: user2, wantErr: false}, {name: "user3 with underscore", uname: user3.Username + "_", want: user3, wantErr: false}, {name: "user4 missing", uname: user4.Username, want: nil, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := delivery.UserByUsername(tt.uname) if (err != nil) != tt.wantErr { t.Errorf("userByUsername() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("userByUsername()\ngot:\n%v\nwant:\n%v\n", got, tt.want) } }) } } type servicesAPIMock struct { users map[string]*mm_model.User } func newServicesAPIMock(users map[string]*mm_model.User) servicesAPIMock { return servicesAPIMock{ users: users, } } func (m servicesAPIMock) GetUserByUsername(name string) (*mm_model.User, error) { user, ok := m.users[name] if !ok { return nil, model.NewErrNotFound(name) } return user, nil } func (m servicesAPIMock) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) { return nil, nil } func (m servicesAPIMock) GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error) { return nil, nil } func (m servicesAPIMock) CreatePost(post *mm_model.Post) (*mm_model.Post, error) { return post, nil } func (m servicesAPIMock) GetUserByID(userID string) (*mm_model.User, error) { for _, user := range m.users { if user.Id == userID { return user, nil } } return nil, model.NewErrNotFound(userID) } func (m servicesAPIMock) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) { user, err := m.GetUserByID(userID) if err != nil { return nil, err } if teamID != defTeamID { return nil, model.NewErrNotFound(teamID) } member := &mm_model.TeamMember{ UserId: user.Id, TeamId: teamID, } return member, nil } func (m servicesAPIMock) GetChannelByID(channelID string) (*mm_model.Channel, error) { return nil, model.NewErrNotFound(channelID) } func (m servicesAPIMock) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) { return nil, model.NewErrNotFound(userID) } func (m servicesAPIMock) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) { member := &mm_model.TeamMember{ UserId: userID, TeamId: teamID, } return member, nil } ================================================ FILE: server/services/notify/service.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package notify import ( "sync" "github.com/mattermost/focalboard/server/model" "github.com/wiggin77/merror" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type Action string const ( Add Action = "add" Update Action = "update" Delete Action = "delete" ) type BlockChangeEvent struct { Action Action TeamID string Board *model.Board Card *model.Block BlockChanged *model.Block BlockOld *model.Block ModifiedBy *model.BoardMember } // Backend provides an interface for sending notifications. type Backend interface { Start() error ShutDown() error BlockChanged(evt BlockChangeEvent) error Name() string } // Service is a service that sends notifications based on block activity using one or more backends. type Service struct { mux sync.RWMutex backends []Backend logger mlog.LoggerIFace } // New creates a notification service with one or more Backends capable of sending notifications. func New(logger mlog.LoggerIFace, backends ...Backend) (*Service, error) { notify := &Service{ backends: make([]Backend, 0, len(backends)), logger: logger, } merr := merror.New() for _, backend := range backends { if err := notify.AddBackend(backend); err != nil { merr.Append(err) } else { logger.Info("Initialized notification backend", mlog.String("name", backend.Name())) } } return notify, merr.ErrorOrNil() } // AddBackend adds a backend to the list that will be informed of any block changes. func (s *Service) AddBackend(backend Backend) error { if err := backend.Start(); err != nil { return err } s.mux.Lock() defer s.mux.Unlock() s.backends = append(s.backends, backend) return nil } // Shutdown calls shutdown for all backends. func (s *Service) Shutdown() error { s.mux.Lock() defer s.mux.Unlock() merr := merror.New() for _, backend := range s.backends { if err := backend.ShutDown(); err != nil { merr.Append(err) } } s.backends = nil return merr.ErrorOrNil() } // BlockChanged should be called whenever a block is added/updated/deleted. // All backends are informed of the event. func (s *Service) BlockChanged(evt BlockChangeEvent) { s.mux.RLock() defer s.mux.RUnlock() for _, backend := range s.backends { if err := backend.BlockChanged(evt); err != nil { s.logger.Error("Error delivering notification", mlog.String("backend", backend.Name()), mlog.String("action", string(evt.Action)), mlog.String("block_id", evt.BlockChanged.ID), mlog.Err(err), ) } } } ================================================ FILE: server/services/permissions/localpermissions/helpers_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package localpermissions import ( "testing" "github.com/mattermost/focalboard/server/model" permissionsMocks "github.com/mattermost/focalboard/server/services/permissions/mocks" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" ) type TestHelper struct { t *testing.T ctrl *gomock.Controller store *permissionsMocks.MockStore permissions *Service } func SetupTestHelper(t *testing.T) *TestHelper { ctrl := gomock.NewController(t) mockStore := permissionsMocks.NewMockStore(ctrl) return &TestHelper{ t: t, ctrl: ctrl, store: mockStore, permissions: New(mockStore, mlog.CreateConsoleTestLogger(t)), } } func (th *TestHelper) checkBoardPermissions(roleName string, member *model.BoardMember, hasPermissionTo, hasNotPermissionTo []*mmModel.Permission) { for _, p := range hasPermissionTo { th.t.Run(roleName+" "+p.Id, func(t *testing.T) { th.store.EXPECT(). GetMemberForBoard(member.BoardID, member.UserID). Return(member, nil). Times(1) hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p) assert.True(t, hasPermission) }) } for _, p := range hasNotPermissionTo { th.t.Run(roleName+" "+p.Id, func(t *testing.T) { th.store.EXPECT(). GetMemberForBoard(member.BoardID, member.UserID). Return(member, nil). Times(1) hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p) assert.False(t, hasPermission) }) } } ================================================ FILE: server/services/permissions/localpermissions/localpermissions.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package localpermissions import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/permissions" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type Service struct { store permissions.Store logger mlog.LoggerIFace } func New(store permissions.Store, logger mlog.LoggerIFace) *Service { return &Service{ store: store, logger: logger, } } func (s *Service) HasPermissionTo(userID string, permission *mmModel.Permission) bool { return false } func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mmModel.Permission) bool { if userID == "" || teamID == "" || permission == nil { return false } if permission.Id == model.PermissionManageTeam.Id { return false } return true } func (s *Service) HasPermissionToChannel(userID, channelID string, permission *mmModel.Permission) bool { if userID == "" || channelID == "" || permission == nil { return false } return true } func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmModel.Permission) bool { if userID == "" || boardID == "" || permission == nil { return false } member, err := s.store.GetMemberForBoard(boardID, userID) if model.IsErrNotFound(err) { return false } if err != nil { s.logger.Error("error getting member for board", mlog.String("boardID", boardID), mlog.String("userID", userID), mlog.Err(err), ) return false } switch member.MinimumRole { case "admin": member.SchemeAdmin = true case "editor": member.SchemeEditor = true case "commenter": member.SchemeCommenter = true case "viewer": member.SchemeViewer = true } switch permission { case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionDeleteOthersComments: return member.SchemeAdmin case model.PermissionManageBoardCards, model.PermissionManageBoardProperties: return member.SchemeAdmin || member.SchemeEditor case model.PermissionCommentBoardCards: return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter case model.PermissionViewBoard: return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter || member.SchemeViewer default: return false } } ================================================ FILE: server/services/permissions/localpermissions/localpermissions_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package localpermissions import ( "database/sql" "testing" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/assert" ) func TestHasPermissionToTeam(t *testing.T) { th := SetupTestHelper(t) t.Run("empty input should always unauthorize", func(t *testing.T) { assert.False(t, th.permissions.HasPermissionToTeam("", "team-id", model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToTeam("user-id", "", model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToTeam("user-id", "team-id", nil)) }) t.Run("all users have all permissions on teams", func(t *testing.T) { hasPermission := th.permissions.HasPermissionToTeam("user-id", "team-id", model.PermissionManageBoardCards) assert.True(t, hasPermission) }) t.Run("no users have PermissionManageTeam on teams", func(t *testing.T) { hasPermission := th.permissions.HasPermissionToTeam("user-id", "team-id", model.PermissionManageTeam) assert.False(t, hasPermission) }) } func TestHasPermissionToBoard(t *testing.T) { th := SetupTestHelper(t) t.Run("empty input should always unauthorize", func(t *testing.T) { assert.False(t, th.permissions.HasPermissionToBoard("", "board-id", model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToBoard("user-id", "", model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToBoard("user-id", "board-id", nil)) }) t.Run("nonexistent user", func(t *testing.T) { userID := "user-id" boardID := "board-id" th.store.EXPECT(). GetMemberForBoard(boardID, userID). Return(nil, sql.ErrNoRows). Times(1) hasPermission := th.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) assert.False(t, hasPermission) }) t.Run("board admin", func(t *testing.T) { member := &model.BoardMember{ UserID: "user-id", BoardID: "board-id", SchemeAdmin: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionViewBoard, model.PermissionManageBoardProperties, } hasNotPermissionTo := []*mmModel.Permission{} th.checkBoardPermissions("admin", member, hasPermissionTo, hasNotPermissionTo) }) t.Run("board editor", func(t *testing.T) { member := &model.BoardMember{ UserID: "user-id", BoardID: "board-id", SchemeEditor: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardCards, model.PermissionViewBoard, model.PermissionManageBoardProperties, } hasNotPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, } th.checkBoardPermissions("editor", member, hasPermissionTo, hasNotPermissionTo) }) t.Run("board commenter", func(t *testing.T) { member := &model.BoardMember{ UserID: "user-id", BoardID: "board-id", SchemeCommenter: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionViewBoard, } hasNotPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionManageBoardProperties, } th.checkBoardPermissions("commenter", member, hasPermissionTo, hasNotPermissionTo) }) t.Run("board viewer", func(t *testing.T) { member := &model.BoardMember{ UserID: "user-id", BoardID: "board-id", SchemeViewer: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionViewBoard, } hasNotPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionManageBoardProperties, } th.checkBoardPermissions("viewer", member, hasPermissionTo, hasNotPermissionTo) }) t.Run("Manage Team Permission ", func(t *testing.T) { member := &model.BoardMember{ UserID: "user-id", BoardID: "board-id", SchemeViewer: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionViewBoard, } hasNotPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionManageBoardProperties, } th.checkBoardPermissions("viewer", member, hasPermissionTo, hasNotPermissionTo) }) } ================================================ FILE: server/services/permissions/mmpermissions/helpers_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package mmpermissions import ( "testing" "github.com/mattermost/focalboard/server/model" mmpermissionsMocks "github.com/mattermost/focalboard/server/services/permissions/mmpermissions/mocks" permissionsMocks "github.com/mattermost/focalboard/server/services/permissions/mocks" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" ) type TestHelper struct { t *testing.T ctrl *gomock.Controller store *permissionsMocks.MockStore api *mmpermissionsMocks.MockAPI permissions *Service } func SetupTestHelper(t *testing.T) *TestHelper { ctrl := gomock.NewController(t) mockStore := permissionsMocks.NewMockStore(ctrl) mockAPI := mmpermissionsMocks.NewMockAPI(ctrl) return &TestHelper{ t: t, ctrl: ctrl, store: mockStore, api: mockAPI, permissions: New(mockStore, mockAPI, mlog.CreateConsoleTestLogger(t)), } } func (th *TestHelper) checkBoardPermissions(roleName string, member *model.BoardMember, teamID string, hasPermissionTo, hasNotPermissionTo []*mmModel.Permission) { for _, p := range hasPermissionTo { th.t.Run(roleName+" "+p.Id, func(t *testing.T) { th.store.EXPECT(). GetBoard(member.BoardID). Return(&model.Board{ID: member.BoardID, TeamID: teamID}, nil). Times(1) th.api.EXPECT(). HasPermissionToTeam(member.UserID, teamID, model.PermissionViewTeam). Return(true). Times(1) th.store.EXPECT(). GetMemberForBoard(member.BoardID, member.UserID). Return(member, nil). Times(1) if !member.SchemeAdmin { th.api.EXPECT(). HasPermissionToTeam(member.UserID, teamID, model.PermissionManageTeam). Return(roleName == "elevated-admin"). Times(1) } hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p) assert.True(t, hasPermission) }) } for _, p := range hasNotPermissionTo { th.t.Run(roleName+" "+p.Id, func(t *testing.T) { th.store.EXPECT(). GetBoard(member.BoardID). Return(&model.Board{ID: member.BoardID, TeamID: teamID}, nil). Times(1) th.api.EXPECT(). HasPermissionToTeam(member.UserID, teamID, model.PermissionViewTeam). Return(true). Times(1) th.store.EXPECT(). GetMemberForBoard(member.BoardID, member.UserID). Return(member, nil). Times(1) if !member.SchemeAdmin { th.api.EXPECT(). HasPermissionToTeam(member.UserID, teamID, model.PermissionManageTeam). Return(roleName == "elevated-admin"). Times(1) } hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p) assert.False(t, hasPermission) }) } } ================================================ FILE: server/services/permissions/mmpermissions/mmpermissions.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package mmpermissions import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/permissions" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type APIInterface interface { HasPermissionTo(userID string, permission *mmModel.Permission) bool HasPermissionToTeam(userID string, teamID string, permission *mmModel.Permission) bool HasPermissionToChannel(userID string, channelID string, permission *mmModel.Permission) bool } type Service struct { store permissions.Store api APIInterface logger mlog.LoggerIFace } func New(store permissions.Store, api APIInterface, logger mlog.LoggerIFace) *Service { return &Service{ store: store, api: api, logger: logger, } } func (s *Service) HasPermissionTo(userID string, permission *mmModel.Permission) bool { if userID == "" || permission == nil { return false } return s.api.HasPermissionTo(userID, permission) } func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mmModel.Permission) bool { if userID == "" || teamID == "" || permission == nil { return false } return s.api.HasPermissionToTeam(userID, teamID, permission) } func (s *Service) HasPermissionToChannel(userID, channelID string, permission *mmModel.Permission) bool { if userID == "" || channelID == "" || permission == nil { return false } return s.api.HasPermissionToChannel(userID, channelID, permission) } func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmModel.Permission) bool { if userID == "" || boardID == "" || permission == nil { return false } board, err := s.store.GetBoard(boardID) if model.IsErrNotFound(err) { var boards []*model.Board boards, err = s.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}) if err != nil { return false } if len(boards) == 0 { return false } board = boards[0] } else if err != nil { s.logger.Error("error getting board", mlog.String("boardID", boardID), mlog.String("userID", userID), mlog.Err(err), ) return false } // we need to check that the user has permission to see the team // regardless of its local permissions to the board if !s.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { return false } member, err := s.store.GetMemberForBoard(boardID, userID) if model.IsErrNotFound(err) { return false } if err != nil { s.logger.Error("error getting member for board", mlog.String("boardID", boardID), mlog.String("userID", userID), mlog.Err(err), ) return false } switch member.MinimumRole { case "admin": member.SchemeAdmin = true case "editor": member.SchemeEditor = true case "commenter": member.SchemeCommenter = true case "viewer": member.SchemeViewer = true } // Admins become member of boards, but get minimal role // if they are a System/Team Admin (model.PermissionManageTeam) // elevate their permissions if !member.SchemeAdmin && s.HasPermissionToTeam(userID, board.TeamID, model.PermissionManageTeam) { return true } switch permission { case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionDeleteOthersComments: return member.SchemeAdmin case model.PermissionManageBoardCards, model.PermissionManageBoardProperties: return member.SchemeAdmin || member.SchemeEditor case model.PermissionCommentBoardCards: return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter case model.PermissionViewBoard: return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter || member.SchemeViewer default: return false } } ================================================ FILE: server/services/permissions/mmpermissions/mmpermissions_test.go ================================================ //go:generate mockgen -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API package mmpermissions import ( "database/sql" "testing" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/assert" ) const ( testTeamID = "team-id" testBoardID = "board-id" testUserID = "user-id" ) func TestHasPermissionsToTeam(t *testing.T) { th := SetupTestHelper(t) t.Run("empty input should always unauthorize", func(t *testing.T) { assert.False(t, th.permissions.HasPermissionToTeam("", testTeamID, model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToTeam(testUserID, "", model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToTeam(testUserID, testTeamID, nil)) }) t.Run("should authorize if the plugin API does", func(t *testing.T) { userID := testUserID teamID := testTeamID th.api.EXPECT(). HasPermissionToTeam(userID, teamID, model.PermissionViewTeam). Return(true). Times(1) hasPermission := th.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) assert.True(t, hasPermission) }) t.Run("should not authorize if the plugin API doesn't", func(t *testing.T) { userID := testUserID teamID := testTeamID th.api.EXPECT(). HasPermissionToTeam(userID, teamID, model.PermissionViewTeam). Return(false). Times(1) hasPermission := th.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) assert.False(t, hasPermission) }) } // test case for user removed. func TestHasPermissionToBoard(t *testing.T) { th := SetupTestHelper(t) t.Run("empty input should always unauthorize", func(t *testing.T) { assert.False(t, th.permissions.HasPermissionToBoard("", testBoardID, model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToBoard(testUserID, "", model.PermissionManageBoardCards)) assert.False(t, th.permissions.HasPermissionToBoard(testUserID, testBoardID, nil)) }) userID := testUserID boardID := testBoardID teamID := testTeamID t.Run("nonexistent member", func(t *testing.T) { th.store.EXPECT(). GetBoard(boardID). Return(&model.Board{ID: boardID, TeamID: teamID}, nil). Times(1) th.api.EXPECT(). HasPermissionToTeam(userID, teamID, model.PermissionViewTeam). Return(true). Times(1) th.store.EXPECT(). GetMemberForBoard(boardID, userID). Return(nil, sql.ErrNoRows). Times(1) hasPermission := th.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) assert.False(t, hasPermission) }) t.Run("nonexistent board", func(t *testing.T) { th.store.EXPECT(). GetBoard(boardID). Return(nil, sql.ErrNoRows). Times(1) th.store.EXPECT(). GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}). Return(nil, sql.ErrNoRows). Times(1) hasPermission := th.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) assert.False(t, hasPermission) }) t.Run("user that has been removed from the team", func(t *testing.T) { member := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeAdmin: true, } th.store.EXPECT(). GetBoard(boardID). Return(&model.Board{ID: boardID, TeamID: teamID}, nil). Times(1) th.api.EXPECT(). HasPermissionToTeam(userID, teamID, model.PermissionViewTeam). Return(true). Times(1) th.store.EXPECT(). GetMemberForBoard(member.BoardID, member.UserID). Return(member, nil). Times(1) hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, model.PermissionViewBoard) assert.True(t, hasPermission) }) t.Run("board admin", func(t *testing.T) { member := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeAdmin: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionViewBoard, model.PermissionManageBoardProperties, } hasNotPermissionTo := []*mmModel.Permission{} th.checkBoardPermissions("admin", member, teamID, hasPermissionTo, hasNotPermissionTo) }) t.Run("board editor", func(t *testing.T) { member := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeEditor: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardCards, model.PermissionViewBoard, model.PermissionManageBoardProperties, } hasNotPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, } th.checkBoardPermissions("editor", member, teamID, hasPermissionTo, hasNotPermissionTo) }) t.Run("board commenter", func(t *testing.T) { member := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeCommenter: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionViewBoard, } hasNotPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionManageBoardProperties, } th.checkBoardPermissions("commenter", member, teamID, hasPermissionTo, hasNotPermissionTo) }) t.Run("board viewer", func(t *testing.T) { member := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeViewer: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionViewBoard, } hasNotPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionManageBoardProperties, } th.checkBoardPermissions("viewer", member, teamID, hasPermissionTo, hasNotPermissionTo) }) t.Run("elevate board viewer permissions", func(t *testing.T) { member := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeViewer: true, } hasPermissionTo := []*mmModel.Permission{ model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionManageBoardCards, model.PermissionViewBoard, model.PermissionManageBoardProperties, } hasNotPermissionTo := []*mmModel.Permission{} th.checkBoardPermissions("elevated-admin", member, teamID, hasPermissionTo, hasNotPermissionTo) }) } ================================================ FILE: server/services/permissions/mmpermissions/mocks/mockpluginapi.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/mattermost-server/v6/plugin (interfaces: API) // Package mocks is a generated GoMock package. package mocks import ( io "io" http "net/http" reflect "reflect" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/mattermost/server/public/model" ) // MockAPI is a mock of API interface. type MockAPI struct { ctrl *gomock.Controller recorder *MockAPIMockRecorder } // MockAPIMockRecorder is the mock recorder for MockAPI. type MockAPIMockRecorder struct { mock *MockAPI } // NewMockAPI creates a new mock instance. func NewMockAPI(ctrl *gomock.Controller) *MockAPI { mock := &MockAPI{ctrl: ctrl} mock.recorder = &MockAPIMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockAPI) EXPECT() *MockAPIMockRecorder { return m.recorder } // AddChannelMember mocks base method. func (m *MockAPI) AddChannelMember(arg0, arg1 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddChannelMember", arg0, arg1) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // AddChannelMember indicates an expected call of AddChannelMember. func (mr *MockAPIMockRecorder) AddChannelMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddChannelMember", reflect.TypeOf((*MockAPI)(nil).AddChannelMember), arg0, arg1) } // AddReaction mocks base method. func (m *MockAPI) AddReaction(arg0 *model.Reaction) (*model.Reaction, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddReaction", arg0) ret0, _ := ret[0].(*model.Reaction) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // AddReaction indicates an expected call of AddReaction. func (mr *MockAPIMockRecorder) AddReaction(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddReaction", reflect.TypeOf((*MockAPI)(nil).AddReaction), arg0) } // AddUserToChannel mocks base method. func (m *MockAPI) AddUserToChannel(arg0, arg1, arg2 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddUserToChannel", arg0, arg1, arg2) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // AddUserToChannel indicates an expected call of AddUserToChannel. func (mr *MockAPIMockRecorder) AddUserToChannel(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUserToChannel", reflect.TypeOf((*MockAPI)(nil).AddUserToChannel), arg0, arg1, arg2) } // CopyFileInfos mocks base method. func (m *MockAPI) CopyFileInfos(arg0 string, arg1 []string) ([]string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CopyFileInfos", arg0, arg1) ret0, _ := ret[0].([]string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CopyFileInfos indicates an expected call of CopyFileInfos. func (mr *MockAPIMockRecorder) CopyFileInfos(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CopyFileInfos", reflect.TypeOf((*MockAPI)(nil).CopyFileInfos), arg0, arg1) } // CreateBot mocks base method. func (m *MockAPI) CreateBot(arg0 *model.Bot) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateBot", arg0) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateBot indicates an expected call of CreateBot. func (mr *MockAPIMockRecorder) CreateBot(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBot", reflect.TypeOf((*MockAPI)(nil).CreateBot), arg0) } // CreateChannel mocks base method. func (m *MockAPI) CreateChannel(arg0 *model.Channel) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateChannel indicates an expected call of CreateChannel. func (mr *MockAPIMockRecorder) CreateChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateChannel", reflect.TypeOf((*MockAPI)(nil).CreateChannel), arg0) } // CreateChannelSidebarCategory mocks base method. func (m *MockAPI) CreateChannelSidebarCategory(arg0, arg1 string, arg2 *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateChannelSidebarCategory", arg0, arg1, arg2) ret0, _ := ret[0].(*model.SidebarCategoryWithChannels) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateChannelSidebarCategory indicates an expected call of CreateChannelSidebarCategory. func (mr *MockAPIMockRecorder) CreateChannelSidebarCategory(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateChannelSidebarCategory", reflect.TypeOf((*MockAPI)(nil).CreateChannelSidebarCategory), arg0, arg1, arg2) } // CreateCommand mocks base method. func (m *MockAPI) CreateCommand(arg0 *model.Command) (*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateCommand", arg0) ret0, _ := ret[0].(*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateCommand indicates an expected call of CreateCommand. func (mr *MockAPIMockRecorder) CreateCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCommand", reflect.TypeOf((*MockAPI)(nil).CreateCommand), arg0) } // CreateOAuthApp mocks base method. func (m *MockAPI) CreateOAuthApp(arg0 *model.OAuthApp) (*model.OAuthApp, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateOAuthApp", arg0) ret0, _ := ret[0].(*model.OAuthApp) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateOAuthApp indicates an expected call of CreateOAuthApp. func (mr *MockAPIMockRecorder) CreateOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOAuthApp", reflect.TypeOf((*MockAPI)(nil).CreateOAuthApp), arg0) } // CreatePost mocks base method. func (m *MockAPI) CreatePost(arg0 *model.Post) (*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePost", arg0) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreatePost indicates an expected call of CreatePost. func (mr *MockAPIMockRecorder) CreatePost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePost", reflect.TypeOf((*MockAPI)(nil).CreatePost), arg0) } // CreateSession mocks base method. func (m *MockAPI) CreateSession(arg0 *model.Session) (*model.Session, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateSession", arg0) ret0, _ := ret[0].(*model.Session) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateSession indicates an expected call of CreateSession. func (mr *MockAPIMockRecorder) CreateSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockAPI)(nil).CreateSession), arg0) } // CreateTeam mocks base method. func (m *MockAPI) CreateTeam(arg0 *model.Team) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeam", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeam indicates an expected call of CreateTeam. func (mr *MockAPIMockRecorder) CreateTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeam", reflect.TypeOf((*MockAPI)(nil).CreateTeam), arg0) } // CreateTeamMember mocks base method. func (m *MockAPI) CreateTeamMember(arg0, arg1 string) (*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeamMember", arg0, arg1) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeamMember indicates an expected call of CreateTeamMember. func (mr *MockAPIMockRecorder) CreateTeamMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMember", reflect.TypeOf((*MockAPI)(nil).CreateTeamMember), arg0, arg1) } // CreateTeamMembers mocks base method. func (m *MockAPI) CreateTeamMembers(arg0 string, arg1 []string, arg2 string) ([]*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeamMembers", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeamMembers indicates an expected call of CreateTeamMembers. func (mr *MockAPIMockRecorder) CreateTeamMembers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMembers", reflect.TypeOf((*MockAPI)(nil).CreateTeamMembers), arg0, arg1, arg2) } // CreateTeamMembersGracefully mocks base method. func (m *MockAPI) CreateTeamMembersGracefully(arg0 string, arg1 []string, arg2 string) ([]*model.TeamMemberWithError, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeamMembersGracefully", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMemberWithError) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeamMembersGracefully indicates an expected call of CreateTeamMembersGracefully. func (mr *MockAPIMockRecorder) CreateTeamMembersGracefully(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMembersGracefully", reflect.TypeOf((*MockAPI)(nil).CreateTeamMembersGracefully), arg0, arg1, arg2) } // CreateUploadSession mocks base method. func (m *MockAPI) CreateUploadSession(arg0 *model.UploadSession) (*model.UploadSession, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateUploadSession", arg0) ret0, _ := ret[0].(*model.UploadSession) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateUploadSession indicates an expected call of CreateUploadSession. func (mr *MockAPIMockRecorder) CreateUploadSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUploadSession", reflect.TypeOf((*MockAPI)(nil).CreateUploadSession), arg0) } // CreateUser mocks base method. func (m *MockAPI) CreateUser(arg0 *model.User) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateUser indicates an expected call of CreateUser. func (mr *MockAPIMockRecorder) CreateUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockAPI)(nil).CreateUser), arg0) } // CreateUserAccessToken mocks base method. func (m *MockAPI) CreateUserAccessToken(arg0 *model.UserAccessToken) (*model.UserAccessToken, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateUserAccessToken", arg0) ret0, _ := ret[0].(*model.UserAccessToken) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateUserAccessToken indicates an expected call of CreateUserAccessToken. func (mr *MockAPIMockRecorder) CreateUserAccessToken(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserAccessToken", reflect.TypeOf((*MockAPI)(nil).CreateUserAccessToken), arg0) } // DeleteChannel mocks base method. func (m *MockAPI) DeleteChannel(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteChannel", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteChannel indicates an expected call of DeleteChannel. func (mr *MockAPIMockRecorder) DeleteChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChannel", reflect.TypeOf((*MockAPI)(nil).DeleteChannel), arg0) } // DeleteChannelMember mocks base method. func (m *MockAPI) DeleteChannelMember(arg0, arg1 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteChannelMember", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteChannelMember indicates an expected call of DeleteChannelMember. func (mr *MockAPIMockRecorder) DeleteChannelMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChannelMember", reflect.TypeOf((*MockAPI)(nil).DeleteChannelMember), arg0, arg1) } // DeleteCommand mocks base method. func (m *MockAPI) DeleteCommand(arg0 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteCommand", arg0) ret0, _ := ret[0].(error) return ret0 } // DeleteCommand indicates an expected call of DeleteCommand. func (mr *MockAPIMockRecorder) DeleteCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCommand", reflect.TypeOf((*MockAPI)(nil).DeleteCommand), arg0) } // DeleteEphemeralPost mocks base method. func (m *MockAPI) DeleteEphemeralPost(arg0, arg1 string) { m.ctrl.T.Helper() m.ctrl.Call(m, "DeleteEphemeralPost", arg0, arg1) } // DeleteEphemeralPost indicates an expected call of DeleteEphemeralPost. func (mr *MockAPIMockRecorder) DeleteEphemeralPost(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteEphemeralPost", reflect.TypeOf((*MockAPI)(nil).DeleteEphemeralPost), arg0, arg1) } // DeleteOAuthApp mocks base method. func (m *MockAPI) DeleteOAuthApp(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteOAuthApp", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteOAuthApp indicates an expected call of DeleteOAuthApp. func (mr *MockAPIMockRecorder) DeleteOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOAuthApp", reflect.TypeOf((*MockAPI)(nil).DeleteOAuthApp), arg0) } // DeletePost mocks base method. func (m *MockAPI) DeletePost(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeletePost", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeletePost indicates an expected call of DeletePost. func (mr *MockAPIMockRecorder) DeletePost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePost", reflect.TypeOf((*MockAPI)(nil).DeletePost), arg0) } // DeletePreferencesForUser mocks base method. func (m *MockAPI) DeletePreferencesForUser(arg0 string, arg1 []model.Preference) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeletePreferencesForUser", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeletePreferencesForUser indicates an expected call of DeletePreferencesForUser. func (mr *MockAPIMockRecorder) DeletePreferencesForUser(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePreferencesForUser", reflect.TypeOf((*MockAPI)(nil).DeletePreferencesForUser), arg0, arg1) } // DeleteTeam mocks base method. func (m *MockAPI) DeleteTeam(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteTeam", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteTeam indicates an expected call of DeleteTeam. func (mr *MockAPIMockRecorder) DeleteTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTeam", reflect.TypeOf((*MockAPI)(nil).DeleteTeam), arg0) } // DeleteTeamMember mocks base method. func (m *MockAPI) DeleteTeamMember(arg0, arg1, arg2 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteTeamMember", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteTeamMember indicates an expected call of DeleteTeamMember. func (mr *MockAPIMockRecorder) DeleteTeamMember(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTeamMember", reflect.TypeOf((*MockAPI)(nil).DeleteTeamMember), arg0, arg1, arg2) } // DeleteUser mocks base method. func (m *MockAPI) DeleteUser(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteUser", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteUser indicates an expected call of DeleteUser. func (mr *MockAPIMockRecorder) DeleteUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockAPI)(nil).DeleteUser), arg0) } // DisablePlugin mocks base method. func (m *MockAPI) DisablePlugin(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DisablePlugin", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DisablePlugin indicates an expected call of DisablePlugin. func (mr *MockAPIMockRecorder) DisablePlugin(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisablePlugin", reflect.TypeOf((*MockAPI)(nil).DisablePlugin), arg0) } // EnablePlugin mocks base method. func (m *MockAPI) EnablePlugin(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "EnablePlugin", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // EnablePlugin indicates an expected call of EnablePlugin. func (mr *MockAPIMockRecorder) EnablePlugin(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnablePlugin", reflect.TypeOf((*MockAPI)(nil).EnablePlugin), arg0) } // EnsureBotUser mocks base method. func (m *MockAPI) EnsureBotUser(arg0 *model.Bot) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "EnsureBotUser", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // EnsureBotUser indicates an expected call of EnsureBotUser. func (mr *MockAPIMockRecorder) EnsureBotUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureBotUser", reflect.TypeOf((*MockAPI)(nil).EnsureBotUser), arg0) } // ExecuteSlashCommand mocks base method. func (m *MockAPI) ExecuteSlashCommand(arg0 *model.CommandArgs) (*model.CommandResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ExecuteSlashCommand", arg0) ret0, _ := ret[0].(*model.CommandResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // ExecuteSlashCommand indicates an expected call of ExecuteSlashCommand. func (mr *MockAPIMockRecorder) ExecuteSlashCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteSlashCommand", reflect.TypeOf((*MockAPI)(nil).ExecuteSlashCommand), arg0) } // ExtendSessionExpiry mocks base method. func (m *MockAPI) ExtendSessionExpiry(arg0 string, arg1 int64) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ExtendSessionExpiry", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // ExtendSessionExpiry indicates an expected call of ExtendSessionExpiry. func (mr *MockAPIMockRecorder) ExtendSessionExpiry(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendSessionExpiry", reflect.TypeOf((*MockAPI)(nil).ExtendSessionExpiry), arg0, arg1) } // GetBot mocks base method. func (m *MockAPI) GetBot(arg0 string, arg1 bool) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBot", arg0, arg1) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetBot indicates an expected call of GetBot. func (mr *MockAPIMockRecorder) GetBot(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBot", reflect.TypeOf((*MockAPI)(nil).GetBot), arg0, arg1) } // GetBots mocks base method. func (m *MockAPI) GetBots(arg0 *model.BotGetOptions) ([]*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBots", arg0) ret0, _ := ret[0].([]*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetBots indicates an expected call of GetBots. func (mr *MockAPIMockRecorder) GetBots(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBots", reflect.TypeOf((*MockAPI)(nil).GetBots), arg0) } // GetBundlePath mocks base method. func (m *MockAPI) GetBundlePath() (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBundlePath") ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBundlePath indicates an expected call of GetBundlePath. func (mr *MockAPIMockRecorder) GetBundlePath() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBundlePath", reflect.TypeOf((*MockAPI)(nil).GetBundlePath)) } // GetChannel mocks base method. func (m *MockAPI) GetChannel(arg0 string) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannel indicates an expected call of GetChannel. func (mr *MockAPIMockRecorder) GetChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannel", reflect.TypeOf((*MockAPI)(nil).GetChannel), arg0) } // GetChannelByName mocks base method. func (m *MockAPI) GetChannelByName(arg0, arg1 string, arg2 bool) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelByName", arg0, arg1, arg2) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelByName indicates an expected call of GetChannelByName. func (mr *MockAPIMockRecorder) GetChannelByName(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByName", reflect.TypeOf((*MockAPI)(nil).GetChannelByName), arg0, arg1, arg2) } // GetChannelByNameForTeamName mocks base method. func (m *MockAPI) GetChannelByNameForTeamName(arg0, arg1 string, arg2 bool) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelByNameForTeamName", arg0, arg1, arg2) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelByNameForTeamName indicates an expected call of GetChannelByNameForTeamName. func (mr *MockAPIMockRecorder) GetChannelByNameForTeamName(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByNameForTeamName", reflect.TypeOf((*MockAPI)(nil).GetChannelByNameForTeamName), arg0, arg1, arg2) } // GetChannelMember mocks base method. func (m *MockAPI) GetChannelMember(arg0, arg1 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMember", arg0, arg1) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMember indicates an expected call of GetChannelMember. func (mr *MockAPIMockRecorder) GetChannelMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMember", reflect.TypeOf((*MockAPI)(nil).GetChannelMember), arg0, arg1) } // GetChannelMembers mocks base method. func (m *MockAPI) GetChannelMembers(arg0 string, arg1, arg2 int) (model.ChannelMembers, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMembers", arg0, arg1, arg2) ret0, _ := ret[0].(model.ChannelMembers) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMembers indicates an expected call of GetChannelMembers. func (mr *MockAPIMockRecorder) GetChannelMembers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMembers", reflect.TypeOf((*MockAPI)(nil).GetChannelMembers), arg0, arg1, arg2) } // GetChannelMembersByIds mocks base method. func (m *MockAPI) GetChannelMembersByIds(arg0 string, arg1 []string) (model.ChannelMembers, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMembersByIds", arg0, arg1) ret0, _ := ret[0].(model.ChannelMembers) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMembersByIds indicates an expected call of GetChannelMembersByIds. func (mr *MockAPIMockRecorder) GetChannelMembersByIds(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMembersByIds", reflect.TypeOf((*MockAPI)(nil).GetChannelMembersByIds), arg0, arg1) } // GetChannelMembersForUser mocks base method. func (m *MockAPI) GetChannelMembersForUser(arg0, arg1 string, arg2, arg3 int) ([]*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMembersForUser", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMembersForUser indicates an expected call of GetChannelMembersForUser. func (mr *MockAPIMockRecorder) GetChannelMembersForUser(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMembersForUser", reflect.TypeOf((*MockAPI)(nil).GetChannelMembersForUser), arg0, arg1, arg2, arg3) } // GetChannelSidebarCategories mocks base method. func (m *MockAPI) GetChannelSidebarCategories(arg0, arg1 string) (*model.OrderedSidebarCategories, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelSidebarCategories", arg0, arg1) ret0, _ := ret[0].(*model.OrderedSidebarCategories) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelSidebarCategories indicates an expected call of GetChannelSidebarCategories. func (mr *MockAPIMockRecorder) GetChannelSidebarCategories(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelSidebarCategories", reflect.TypeOf((*MockAPI)(nil).GetChannelSidebarCategories), arg0, arg1) } // GetChannelStats mocks base method. func (m *MockAPI) GetChannelStats(arg0 string) (*model.ChannelStats, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelStats", arg0) ret0, _ := ret[0].(*model.ChannelStats) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelStats indicates an expected call of GetChannelStats. func (mr *MockAPIMockRecorder) GetChannelStats(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelStats", reflect.TypeOf((*MockAPI)(nil).GetChannelStats), arg0) } // GetChannelsForTeamForUser mocks base method. func (m *MockAPI) GetChannelsForTeamForUser(arg0, arg1 string, arg2 bool) ([]*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelsForTeamForUser", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelsForTeamForUser indicates an expected call of GetChannelsForTeamForUser. func (mr *MockAPIMockRecorder) GetChannelsForTeamForUser(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelsForTeamForUser", reflect.TypeOf((*MockAPI)(nil).GetChannelsForTeamForUser), arg0, arg1, arg2) } // GetCommand mocks base method. func (m *MockAPI) GetCommand(arg0 string) (*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetCommand", arg0) ret0, _ := ret[0].(*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // GetCommand indicates an expected call of GetCommand. func (mr *MockAPIMockRecorder) GetCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCommand", reflect.TypeOf((*MockAPI)(nil).GetCommand), arg0) } // GetConfig mocks base method. func (m *MockAPI) GetConfig() *model.Config { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetConfig") ret0, _ := ret[0].(*model.Config) return ret0 } // GetConfig indicates an expected call of GetConfig. func (mr *MockAPIMockRecorder) GetConfig() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockAPI)(nil).GetConfig)) } // GetDiagnosticId mocks base method. func (m *MockAPI) GetDiagnosticId() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDiagnosticId") ret0, _ := ret[0].(string) return ret0 } // GetDiagnosticId indicates an expected call of GetDiagnosticId. func (mr *MockAPIMockRecorder) GetDiagnosticId() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDiagnosticId", reflect.TypeOf((*MockAPI)(nil).GetDiagnosticId)) } // GetDirectChannel mocks base method. func (m *MockAPI) GetDirectChannel(arg0, arg1 string) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDirectChannel", arg0, arg1) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetDirectChannel indicates an expected call of GetDirectChannel. func (mr *MockAPIMockRecorder) GetDirectChannel(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDirectChannel", reflect.TypeOf((*MockAPI)(nil).GetDirectChannel), arg0, arg1) } // GetEmoji mocks base method. func (m *MockAPI) GetEmoji(arg0 string) (*model.Emoji, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmoji", arg0) ret0, _ := ret[0].(*model.Emoji) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetEmoji indicates an expected call of GetEmoji. func (mr *MockAPIMockRecorder) GetEmoji(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmoji", reflect.TypeOf((*MockAPI)(nil).GetEmoji), arg0) } // GetEmojiByName mocks base method. func (m *MockAPI) GetEmojiByName(arg0 string) (*model.Emoji, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmojiByName", arg0) ret0, _ := ret[0].(*model.Emoji) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetEmojiByName indicates an expected call of GetEmojiByName. func (mr *MockAPIMockRecorder) GetEmojiByName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmojiByName", reflect.TypeOf((*MockAPI)(nil).GetEmojiByName), arg0) } // GetEmojiImage mocks base method. func (m *MockAPI) GetEmojiImage(arg0 string) ([]byte, string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmojiImage", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(string) ret2, _ := ret[2].(*model.AppError) return ret0, ret1, ret2 } // GetEmojiImage indicates an expected call of GetEmojiImage. func (mr *MockAPIMockRecorder) GetEmojiImage(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmojiImage", reflect.TypeOf((*MockAPI)(nil).GetEmojiImage), arg0) } // GetEmojiList mocks base method. func (m *MockAPI) GetEmojiList(arg0 string, arg1, arg2 int) ([]*model.Emoji, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmojiList", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Emoji) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetEmojiList indicates an expected call of GetEmojiList. func (mr *MockAPIMockRecorder) GetEmojiList(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmojiList", reflect.TypeOf((*MockAPI)(nil).GetEmojiList), arg0, arg1, arg2) } // GetFile mocks base method. func (m *MockAPI) GetFile(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFile", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFile indicates an expected call of GetFile. func (mr *MockAPIMockRecorder) GetFile(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFile", reflect.TypeOf((*MockAPI)(nil).GetFile), arg0) } // GetFileInfo mocks base method. func (m *MockAPI) GetFileInfo(arg0 string) (*model.FileInfo, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileInfo", arg0) ret0, _ := ret[0].(*model.FileInfo) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFileInfo indicates an expected call of GetFileInfo. func (mr *MockAPIMockRecorder) GetFileInfo(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileInfo", reflect.TypeOf((*MockAPI)(nil).GetFileInfo), arg0) } // GetFileInfos mocks base method. func (m *MockAPI) GetFileInfos(arg0, arg1 int, arg2 *model.GetFileInfosOptions) ([]*model.FileInfo, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileInfos", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.FileInfo) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFileInfos indicates an expected call of GetFileInfos. func (mr *MockAPIMockRecorder) GetFileInfos(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileInfos", reflect.TypeOf((*MockAPI)(nil).GetFileInfos), arg0, arg1, arg2) } // GetFileLink mocks base method. func (m *MockAPI) GetFileLink(arg0 string) (string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileLink", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFileLink indicates an expected call of GetFileLink. func (mr *MockAPIMockRecorder) GetFileLink(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileLink", reflect.TypeOf((*MockAPI)(nil).GetFileLink), arg0) } // GetGroup mocks base method. func (m *MockAPI) GetGroup(arg0 string) (*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroup", arg0) ret0, _ := ret[0].(*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroup indicates an expected call of GetGroup. func (mr *MockAPIMockRecorder) GetGroup(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockAPI)(nil).GetGroup), arg0) } // GetGroupByName mocks base method. func (m *MockAPI) GetGroupByName(arg0 string) (*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupByName", arg0) ret0, _ := ret[0].(*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupByName indicates an expected call of GetGroupByName. func (mr *MockAPIMockRecorder) GetGroupByName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockAPI)(nil).GetGroupByName), arg0) } // GetGroupChannel mocks base method. func (m *MockAPI) GetGroupChannel(arg0 []string) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupChannel indicates an expected call of GetGroupChannel. func (mr *MockAPIMockRecorder) GetGroupChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupChannel", reflect.TypeOf((*MockAPI)(nil).GetGroupChannel), arg0) } // GetGroupMemberUsers mocks base method. func (m *MockAPI) GetGroupMemberUsers(arg0 string, arg1, arg2 int) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupMemberUsers", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupMemberUsers indicates an expected call of GetGroupMemberUsers. func (mr *MockAPIMockRecorder) GetGroupMemberUsers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMemberUsers", reflect.TypeOf((*MockAPI)(nil).GetGroupMemberUsers), arg0, arg1, arg2) } // GetGroupsBySource mocks base method. func (m *MockAPI) GetGroupsBySource(arg0 model.GroupSource) ([]*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupsBySource", arg0) ret0, _ := ret[0].([]*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupsBySource indicates an expected call of GetGroupsBySource. func (mr *MockAPIMockRecorder) GetGroupsBySource(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsBySource", reflect.TypeOf((*MockAPI)(nil).GetGroupsBySource), arg0) } // GetGroupsForUser mocks base method. func (m *MockAPI) GetGroupsForUser(arg0 string) ([]*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupsForUser", arg0) ret0, _ := ret[0].([]*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupsForUser indicates an expected call of GetGroupsForUser. func (mr *MockAPIMockRecorder) GetGroupsForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsForUser", reflect.TypeOf((*MockAPI)(nil).GetGroupsForUser), arg0) } // GetLDAPUserAttributes mocks base method. func (m *MockAPI) GetLDAPUserAttributes(arg0 string, arg1 []string) (map[string]string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLDAPUserAttributes", arg0, arg1) ret0, _ := ret[0].(map[string]string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetLDAPUserAttributes indicates an expected call of GetLDAPUserAttributes. func (mr *MockAPIMockRecorder) GetLDAPUserAttributes(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLDAPUserAttributes", reflect.TypeOf((*MockAPI)(nil).GetLDAPUserAttributes), arg0, arg1) } // GetLicense mocks base method. func (m *MockAPI) GetLicense() *model.License { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLicense") ret0, _ := ret[0].(*model.License) return ret0 } // GetLicense indicates an expected call of GetLicense. func (mr *MockAPIMockRecorder) GetLicense() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLicense", reflect.TypeOf((*MockAPI)(nil).GetLicense)) } // GetOAuthApp mocks base method. func (m *MockAPI) GetOAuthApp(arg0 string) (*model.OAuthApp, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOAuthApp", arg0) ret0, _ := ret[0].(*model.OAuthApp) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetOAuthApp indicates an expected call of GetOAuthApp. func (mr *MockAPIMockRecorder) GetOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthApp", reflect.TypeOf((*MockAPI)(nil).GetOAuthApp), arg0) } // GetPluginConfig mocks base method. func (m *MockAPI) GetPluginConfig() map[string]interface{} { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPluginConfig") ret0, _ := ret[0].(map[string]interface{}) return ret0 } // GetPluginConfig indicates an expected call of GetPluginConfig. func (mr *MockAPIMockRecorder) GetPluginConfig() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPluginConfig", reflect.TypeOf((*MockAPI)(nil).GetPluginConfig)) } // GetPluginStatus mocks base method. func (m *MockAPI) GetPluginStatus(arg0 string) (*model.PluginStatus, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPluginStatus", arg0) ret0, _ := ret[0].(*model.PluginStatus) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPluginStatus indicates an expected call of GetPluginStatus. func (mr *MockAPIMockRecorder) GetPluginStatus(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPluginStatus", reflect.TypeOf((*MockAPI)(nil).GetPluginStatus), arg0) } // GetPlugins mocks base method. func (m *MockAPI) GetPlugins() ([]*model.Manifest, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPlugins") ret0, _ := ret[0].([]*model.Manifest) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPlugins indicates an expected call of GetPlugins. func (mr *MockAPIMockRecorder) GetPlugins() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlugins", reflect.TypeOf((*MockAPI)(nil).GetPlugins)) } // GetPost mocks base method. func (m *MockAPI) GetPost(arg0 string) (*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPost", arg0) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPost indicates an expected call of GetPost. func (mr *MockAPIMockRecorder) GetPost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPost", reflect.TypeOf((*MockAPI)(nil).GetPost), arg0) } // GetPostThread mocks base method. func (m *MockAPI) GetPostThread(arg0 string) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostThread", arg0) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostThread indicates an expected call of GetPostThread. func (mr *MockAPIMockRecorder) GetPostThread(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostThread", reflect.TypeOf((*MockAPI)(nil).GetPostThread), arg0) } // GetPostsAfter mocks base method. func (m *MockAPI) GetPostsAfter(arg0, arg1 string, arg2, arg3 int) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsAfter", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsAfter indicates an expected call of GetPostsAfter. func (mr *MockAPIMockRecorder) GetPostsAfter(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsAfter", reflect.TypeOf((*MockAPI)(nil).GetPostsAfter), arg0, arg1, arg2, arg3) } // GetPostsBefore mocks base method. func (m *MockAPI) GetPostsBefore(arg0, arg1 string, arg2, arg3 int) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsBefore", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsBefore indicates an expected call of GetPostsBefore. func (mr *MockAPIMockRecorder) GetPostsBefore(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsBefore", reflect.TypeOf((*MockAPI)(nil).GetPostsBefore), arg0, arg1, arg2, arg3) } // GetPostsForChannel mocks base method. func (m *MockAPI) GetPostsForChannel(arg0 string, arg1, arg2 int) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsForChannel", arg0, arg1, arg2) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsForChannel indicates an expected call of GetPostsForChannel. func (mr *MockAPIMockRecorder) GetPostsForChannel(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsForChannel", reflect.TypeOf((*MockAPI)(nil).GetPostsForChannel), arg0, arg1, arg2) } // GetPostsSince mocks base method. func (m *MockAPI) GetPostsSince(arg0 string, arg1 int64) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsSince", arg0, arg1) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsSince indicates an expected call of GetPostsSince. func (mr *MockAPIMockRecorder) GetPostsSince(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsSince", reflect.TypeOf((*MockAPI)(nil).GetPostsSince), arg0, arg1) } // GetPreferencesForUser mocks base method. func (m *MockAPI) GetPreferencesForUser(arg0 string) ([]model.Preference, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPreferencesForUser", arg0) ret0, _ := ret[0].([]model.Preference) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPreferencesForUser indicates an expected call of GetPreferencesForUser. func (mr *MockAPIMockRecorder) GetPreferencesForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPreferencesForUser", reflect.TypeOf((*MockAPI)(nil).GetPreferencesForUser), arg0) } // GetProfileImage mocks base method. func (m *MockAPI) GetProfileImage(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProfileImage", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetProfileImage indicates an expected call of GetProfileImage. func (mr *MockAPIMockRecorder) GetProfileImage(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfileImage", reflect.TypeOf((*MockAPI)(nil).GetProfileImage), arg0) } // GetPublicChannelsForTeam mocks base method. func (m *MockAPI) GetPublicChannelsForTeam(arg0 string, arg1, arg2 int) ([]*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPublicChannelsForTeam", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPublicChannelsForTeam indicates an expected call of GetPublicChannelsForTeam. func (mr *MockAPIMockRecorder) GetPublicChannelsForTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicChannelsForTeam", reflect.TypeOf((*MockAPI)(nil).GetPublicChannelsForTeam), arg0, arg1, arg2) } // GetReactions mocks base method. func (m *MockAPI) GetReactions(arg0 string) ([]*model.Reaction, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetReactions", arg0) ret0, _ := ret[0].([]*model.Reaction) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetReactions indicates an expected call of GetReactions. func (mr *MockAPIMockRecorder) GetReactions(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetReactions", reflect.TypeOf((*MockAPI)(nil).GetReactions), arg0) } // GetServerVersion mocks base method. func (m *MockAPI) GetServerVersion() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetServerVersion") ret0, _ := ret[0].(string) return ret0 } // GetServerVersion indicates an expected call of GetServerVersion. func (mr *MockAPIMockRecorder) GetServerVersion() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServerVersion", reflect.TypeOf((*MockAPI)(nil).GetServerVersion)) } // GetSession mocks base method. func (m *MockAPI) GetSession(arg0 string) (*model.Session, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSession", arg0) ret0, _ := ret[0].(*model.Session) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetSession indicates an expected call of GetSession. func (mr *MockAPIMockRecorder) GetSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSession", reflect.TypeOf((*MockAPI)(nil).GetSession), arg0) } // GetSystemInstallDate mocks base method. func (m *MockAPI) GetSystemInstallDate() (int64, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSystemInstallDate") ret0, _ := ret[0].(int64) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetSystemInstallDate indicates an expected call of GetSystemInstallDate. func (mr *MockAPIMockRecorder) GetSystemInstallDate() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemInstallDate", reflect.TypeOf((*MockAPI)(nil).GetSystemInstallDate)) } // GetTeam mocks base method. func (m *MockAPI) GetTeam(arg0 string) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeam", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeam indicates an expected call of GetTeam. func (mr *MockAPIMockRecorder) GetTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeam", reflect.TypeOf((*MockAPI)(nil).GetTeam), arg0) } // GetTeamByName mocks base method. func (m *MockAPI) GetTeamByName(arg0 string) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamByName", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamByName indicates an expected call of GetTeamByName. func (mr *MockAPIMockRecorder) GetTeamByName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamByName", reflect.TypeOf((*MockAPI)(nil).GetTeamByName), arg0) } // GetTeamIcon mocks base method. func (m *MockAPI) GetTeamIcon(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamIcon", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamIcon indicates an expected call of GetTeamIcon. func (mr *MockAPIMockRecorder) GetTeamIcon(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamIcon", reflect.TypeOf((*MockAPI)(nil).GetTeamIcon), arg0) } // GetTeamMember mocks base method. func (m *MockAPI) GetTeamMember(arg0, arg1 string) (*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMember", arg0, arg1) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamMember indicates an expected call of GetTeamMember. func (mr *MockAPIMockRecorder) GetTeamMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMember", reflect.TypeOf((*MockAPI)(nil).GetTeamMember), arg0, arg1) } // GetTeamMembers mocks base method. func (m *MockAPI) GetTeamMembers(arg0 string, arg1, arg2 int) ([]*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMembers", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamMembers indicates an expected call of GetTeamMembers. func (mr *MockAPIMockRecorder) GetTeamMembers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMembers", reflect.TypeOf((*MockAPI)(nil).GetTeamMembers), arg0, arg1, arg2) } // GetTeamMembersForUser mocks base method. func (m *MockAPI) GetTeamMembersForUser(arg0 string, arg1, arg2 int) ([]*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMembersForUser", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamMembersForUser indicates an expected call of GetTeamMembersForUser. func (mr *MockAPIMockRecorder) GetTeamMembersForUser(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMembersForUser", reflect.TypeOf((*MockAPI)(nil).GetTeamMembersForUser), arg0, arg1, arg2) } // GetTeamStats mocks base method. func (m *MockAPI) GetTeamStats(arg0 string) (*model.TeamStats, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamStats", arg0) ret0, _ := ret[0].(*model.TeamStats) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamStats indicates an expected call of GetTeamStats. func (mr *MockAPIMockRecorder) GetTeamStats(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamStats", reflect.TypeOf((*MockAPI)(nil).GetTeamStats), arg0) } // GetTeams mocks base method. func (m *MockAPI) GetTeams() ([]*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeams") ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeams indicates an expected call of GetTeams. func (mr *MockAPIMockRecorder) GetTeams() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeams", reflect.TypeOf((*MockAPI)(nil).GetTeams)) } // GetTeamsForUser mocks base method. func (m *MockAPI) GetTeamsForUser(arg0 string) ([]*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamsForUser", arg0) ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamsForUser indicates an expected call of GetTeamsForUser. func (mr *MockAPIMockRecorder) GetTeamsForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamsForUser", reflect.TypeOf((*MockAPI)(nil).GetTeamsForUser), arg0) } // GetTeamsUnreadForUser mocks base method. func (m *MockAPI) GetTeamsUnreadForUser(arg0 string) ([]*model.TeamUnread, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamsUnreadForUser", arg0) ret0, _ := ret[0].([]*model.TeamUnread) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamsUnreadForUser indicates an expected call of GetTeamsUnreadForUser. func (mr *MockAPIMockRecorder) GetTeamsUnreadForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamsUnreadForUser", reflect.TypeOf((*MockAPI)(nil).GetTeamsUnreadForUser), arg0) } // GetTelemetryId mocks base method. func (m *MockAPI) GetTelemetryId() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTelemetryId") ret0, _ := ret[0].(string) return ret0 } // GetTelemetryId indicates an expected call of GetTelemetryId. func (mr *MockAPIMockRecorder) GetTelemetryId() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTelemetryId", reflect.TypeOf((*MockAPI)(nil).GetTelemetryId)) } // GetUnsanitizedConfig mocks base method. func (m *MockAPI) GetUnsanitizedConfig() *model.Config { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUnsanitizedConfig") ret0, _ := ret[0].(*model.Config) return ret0 } // GetUnsanitizedConfig indicates an expected call of GetUnsanitizedConfig. func (mr *MockAPIMockRecorder) GetUnsanitizedConfig() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnsanitizedConfig", reflect.TypeOf((*MockAPI)(nil).GetUnsanitizedConfig)) } // GetUploadSession mocks base method. func (m *MockAPI) GetUploadSession(arg0 string) (*model.UploadSession, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUploadSession", arg0) ret0, _ := ret[0].(*model.UploadSession) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUploadSession indicates an expected call of GetUploadSession. func (mr *MockAPIMockRecorder) GetUploadSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUploadSession", reflect.TypeOf((*MockAPI)(nil).GetUploadSession), arg0) } // GetUser mocks base method. func (m *MockAPI) GetUser(arg0 string) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUser indicates an expected call of GetUser. func (mr *MockAPIMockRecorder) GetUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockAPI)(nil).GetUser), arg0) } // GetUserByEmail mocks base method. func (m *MockAPI) GetUserByEmail(arg0 string) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByEmail", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserByEmail indicates an expected call of GetUserByEmail. func (mr *MockAPIMockRecorder) GetUserByEmail(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockAPI)(nil).GetUserByEmail), arg0) } // GetUserByUsername mocks base method. func (m *MockAPI) GetUserByUsername(arg0 string) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByUsername", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserByUsername indicates an expected call of GetUserByUsername. func (mr *MockAPIMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockAPI)(nil).GetUserByUsername), arg0) } // GetUserStatus mocks base method. func (m *MockAPI) GetUserStatus(arg0 string) (*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserStatus", arg0) ret0, _ := ret[0].(*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserStatus indicates an expected call of GetUserStatus. func (mr *MockAPIMockRecorder) GetUserStatus(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatus", reflect.TypeOf((*MockAPI)(nil).GetUserStatus), arg0) } // GetUserStatusesByIds mocks base method. func (m *MockAPI) GetUserStatusesByIds(arg0 []string) ([]*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserStatusesByIds", arg0) ret0, _ := ret[0].([]*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserStatusesByIds indicates an expected call of GetUserStatusesByIds. func (mr *MockAPIMockRecorder) GetUserStatusesByIds(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusesByIds", reflect.TypeOf((*MockAPI)(nil).GetUserStatusesByIds), arg0) } // GetUsers mocks base method. func (m *MockAPI) GetUsers(arg0 *model.UserGetOptions) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsers", arg0) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsers indicates an expected call of GetUsers. func (mr *MockAPIMockRecorder) GetUsers(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockAPI)(nil).GetUsers), arg0) } // GetUsersByUsernames mocks base method. func (m *MockAPI) GetUsersByUsernames(arg0 []string) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersByUsernames", arg0) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsersByUsernames indicates an expected call of GetUsersByUsernames. func (mr *MockAPIMockRecorder) GetUsersByUsernames(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByUsernames", reflect.TypeOf((*MockAPI)(nil).GetUsersByUsernames), arg0) } // GetUsersInChannel mocks base method. func (m *MockAPI) GetUsersInChannel(arg0, arg1 string, arg2, arg3 int) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersInChannel", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsersInChannel indicates an expected call of GetUsersInChannel. func (mr *MockAPIMockRecorder) GetUsersInChannel(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersInChannel", reflect.TypeOf((*MockAPI)(nil).GetUsersInChannel), arg0, arg1, arg2, arg3) } // GetUsersInTeam mocks base method. func (m *MockAPI) GetUsersInTeam(arg0 string, arg1, arg2 int) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersInTeam", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsersInTeam indicates an expected call of GetUsersInTeam. func (mr *MockAPIMockRecorder) GetUsersInTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersInTeam", reflect.TypeOf((*MockAPI)(nil).GetUsersInTeam), arg0, arg1, arg2) } // HasPermissionTo mocks base method. func (m *MockAPI) HasPermissionTo(arg0 string, arg1 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionTo", arg0, arg1) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionTo indicates an expected call of HasPermissionTo. func (mr *MockAPIMockRecorder) HasPermissionTo(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionTo", reflect.TypeOf((*MockAPI)(nil).HasPermissionTo), arg0, arg1) } // HasPermissionToChannel mocks base method. func (m *MockAPI) HasPermissionToChannel(arg0, arg1 string, arg2 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionToChannel", arg0, arg1, arg2) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionToChannel indicates an expected call of HasPermissionToChannel. func (mr *MockAPIMockRecorder) HasPermissionToChannel(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToChannel", reflect.TypeOf((*MockAPI)(nil).HasPermissionToChannel), arg0, arg1, arg2) } // HasPermissionToTeam mocks base method. func (m *MockAPI) HasPermissionToTeam(arg0, arg1 string, arg2 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionToTeam", arg0, arg1, arg2) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionToTeam indicates an expected call of HasPermissionToTeam. func (mr *MockAPIMockRecorder) HasPermissionToTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToTeam", reflect.TypeOf((*MockAPI)(nil).HasPermissionToTeam), arg0, arg1, arg2) } // InstallPlugin mocks base method. func (m *MockAPI) InstallPlugin(arg0 io.Reader, arg1 bool) (*model.Manifest, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InstallPlugin", arg0, arg1) ret0, _ := ret[0].(*model.Manifest) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // InstallPlugin indicates an expected call of InstallPlugin. func (mr *MockAPIMockRecorder) InstallPlugin(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallPlugin", reflect.TypeOf((*MockAPI)(nil).InstallPlugin), arg0, arg1) } // IsEnterpriseReady mocks base method. func (m *MockAPI) IsEnterpriseReady() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IsEnterpriseReady") ret0, _ := ret[0].(bool) return ret0 } // IsEnterpriseReady indicates an expected call of IsEnterpriseReady. func (mr *MockAPIMockRecorder) IsEnterpriseReady() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsEnterpriseReady", reflect.TypeOf((*MockAPI)(nil).IsEnterpriseReady)) } // KVCompareAndDelete mocks base method. func (m *MockAPI) KVCompareAndDelete(arg0 string, arg1 []byte) (bool, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVCompareAndDelete", arg0, arg1) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVCompareAndDelete indicates an expected call of KVCompareAndDelete. func (mr *MockAPIMockRecorder) KVCompareAndDelete(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVCompareAndDelete", reflect.TypeOf((*MockAPI)(nil).KVCompareAndDelete), arg0, arg1) } // KVCompareAndSet mocks base method. func (m *MockAPI) KVCompareAndSet(arg0 string, arg1, arg2 []byte) (bool, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVCompareAndSet", arg0, arg1, arg2) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVCompareAndSet indicates an expected call of KVCompareAndSet. func (mr *MockAPIMockRecorder) KVCompareAndSet(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVCompareAndSet", reflect.TypeOf((*MockAPI)(nil).KVCompareAndSet), arg0, arg1, arg2) } // KVDelete mocks base method. func (m *MockAPI) KVDelete(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVDelete", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // KVDelete indicates an expected call of KVDelete. func (mr *MockAPIMockRecorder) KVDelete(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVDelete", reflect.TypeOf((*MockAPI)(nil).KVDelete), arg0) } // KVDeleteAll mocks base method. func (m *MockAPI) KVDeleteAll() *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVDeleteAll") ret0, _ := ret[0].(*model.AppError) return ret0 } // KVDeleteAll indicates an expected call of KVDeleteAll. func (mr *MockAPIMockRecorder) KVDeleteAll() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVDeleteAll", reflect.TypeOf((*MockAPI)(nil).KVDeleteAll)) } // KVGet mocks base method. func (m *MockAPI) KVGet(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVGet", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVGet indicates an expected call of KVGet. func (mr *MockAPIMockRecorder) KVGet(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVGet", reflect.TypeOf((*MockAPI)(nil).KVGet), arg0) } // KVList mocks base method. func (m *MockAPI) KVList(arg0, arg1 int) ([]string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVList", arg0, arg1) ret0, _ := ret[0].([]string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVList indicates an expected call of KVList. func (mr *MockAPIMockRecorder) KVList(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVList", reflect.TypeOf((*MockAPI)(nil).KVList), arg0, arg1) } // KVSet mocks base method. func (m *MockAPI) KVSet(arg0 string, arg1 []byte) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVSet", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // KVSet indicates an expected call of KVSet. func (mr *MockAPIMockRecorder) KVSet(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVSet", reflect.TypeOf((*MockAPI)(nil).KVSet), arg0, arg1) } // KVSetWithExpiry mocks base method. func (m *MockAPI) KVSetWithExpiry(arg0 string, arg1 []byte, arg2 int64) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVSetWithExpiry", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // KVSetWithExpiry indicates an expected call of KVSetWithExpiry. func (mr *MockAPIMockRecorder) KVSetWithExpiry(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVSetWithExpiry", reflect.TypeOf((*MockAPI)(nil).KVSetWithExpiry), arg0, arg1, arg2) } // KVSetWithOptions mocks base method. func (m *MockAPI) KVSetWithOptions(arg0 string, arg1 []byte, arg2 model.PluginKVSetOptions) (bool, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVSetWithOptions", arg0, arg1, arg2) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVSetWithOptions indicates an expected call of KVSetWithOptions. func (mr *MockAPIMockRecorder) KVSetWithOptions(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVSetWithOptions", reflect.TypeOf((*MockAPI)(nil).KVSetWithOptions), arg0, arg1, arg2) } // ListBuiltInCommands mocks base method. func (m *MockAPI) ListBuiltInCommands() ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListBuiltInCommands") ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListBuiltInCommands indicates an expected call of ListBuiltInCommands. func (mr *MockAPIMockRecorder) ListBuiltInCommands() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBuiltInCommands", reflect.TypeOf((*MockAPI)(nil).ListBuiltInCommands)) } // ListCommands mocks base method. func (m *MockAPI) ListCommands(arg0 string) ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListCommands", arg0) ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListCommands indicates an expected call of ListCommands. func (mr *MockAPIMockRecorder) ListCommands(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCommands", reflect.TypeOf((*MockAPI)(nil).ListCommands), arg0) } // ListCustomCommands mocks base method. func (m *MockAPI) ListCustomCommands(arg0 string) ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListCustomCommands", arg0) ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListCustomCommands indicates an expected call of ListCustomCommands. func (mr *MockAPIMockRecorder) ListCustomCommands(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCustomCommands", reflect.TypeOf((*MockAPI)(nil).ListCustomCommands), arg0) } // ListPluginCommands mocks base method. func (m *MockAPI) ListPluginCommands(arg0 string) ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListPluginCommands", arg0) ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListPluginCommands indicates an expected call of ListPluginCommands. func (mr *MockAPIMockRecorder) ListPluginCommands(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPluginCommands", reflect.TypeOf((*MockAPI)(nil).ListPluginCommands), arg0) } // LoadPluginConfiguration mocks base method. func (m *MockAPI) LoadPluginConfiguration(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "LoadPluginConfiguration", arg0) ret0, _ := ret[0].(error) return ret0 } // LoadPluginConfiguration indicates an expected call of LoadPluginConfiguration. func (mr *MockAPIMockRecorder) LoadPluginConfiguration(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPluginConfiguration", reflect.TypeOf((*MockAPI)(nil).LoadPluginConfiguration), arg0) } // LogDebug mocks base method. func (m *MockAPI) LogDebug(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogDebug", varargs...) } // LogDebug indicates an expected call of LogDebug. func (mr *MockAPIMockRecorder) LogDebug(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogDebug", reflect.TypeOf((*MockAPI)(nil).LogDebug), varargs...) } // LogError mocks base method. func (m *MockAPI) LogError(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogError", varargs...) } // LogError indicates an expected call of LogError. func (mr *MockAPIMockRecorder) LogError(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogError", reflect.TypeOf((*MockAPI)(nil).LogError), varargs...) } // LogInfo mocks base method. func (m *MockAPI) LogInfo(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogInfo", varargs...) } // LogInfo indicates an expected call of LogInfo. func (mr *MockAPIMockRecorder) LogInfo(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogInfo", reflect.TypeOf((*MockAPI)(nil).LogInfo), varargs...) } // LogWarn mocks base method. func (m *MockAPI) LogWarn(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogWarn", varargs...) } // LogWarn indicates an expected call of LogWarn. func (mr *MockAPIMockRecorder) LogWarn(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogWarn", reflect.TypeOf((*MockAPI)(nil).LogWarn), varargs...) } // OpenInteractiveDialog mocks base method. func (m *MockAPI) OpenInteractiveDialog(arg0 model.OpenDialogRequest) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OpenInteractiveDialog", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // OpenInteractiveDialog indicates an expected call of OpenInteractiveDialog. func (mr *MockAPIMockRecorder) OpenInteractiveDialog(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenInteractiveDialog", reflect.TypeOf((*MockAPI)(nil).OpenInteractiveDialog), arg0) } // PatchBot mocks base method. func (m *MockAPI) PatchBot(arg0 string, arg1 *model.BotPatch) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PatchBot", arg0, arg1) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // PatchBot indicates an expected call of PatchBot. func (mr *MockAPIMockRecorder) PatchBot(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBot", reflect.TypeOf((*MockAPI)(nil).PatchBot), arg0, arg1) } // PermanentDeleteBot mocks base method. func (m *MockAPI) PermanentDeleteBot(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PermanentDeleteBot", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // PermanentDeleteBot indicates an expected call of PermanentDeleteBot. func (mr *MockAPIMockRecorder) PermanentDeleteBot(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeleteBot", reflect.TypeOf((*MockAPI)(nil).PermanentDeleteBot), arg0) } // PluginHTTP mocks base method. func (m *MockAPI) PluginHTTP(arg0 *http.Request) *http.Response { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PluginHTTP", arg0) ret0, _ := ret[0].(*http.Response) return ret0 } // PluginHTTP indicates an expected call of PluginHTTP. func (mr *MockAPIMockRecorder) PluginHTTP(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PluginHTTP", reflect.TypeOf((*MockAPI)(nil).PluginHTTP), arg0) } // PublishPluginClusterEvent mocks base method. func (m *MockAPI) PublishPluginClusterEvent(arg0 model.PluginClusterEvent, arg1 model.PluginClusterEventSendOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PublishPluginClusterEvent", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // PublishPluginClusterEvent indicates an expected call of PublishPluginClusterEvent. func (mr *MockAPIMockRecorder) PublishPluginClusterEvent(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishPluginClusterEvent", reflect.TypeOf((*MockAPI)(nil).PublishPluginClusterEvent), arg0, arg1) } // PublishUserTyping mocks base method. func (m *MockAPI) PublishUserTyping(arg0, arg1, arg2 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PublishUserTyping", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // PublishUserTyping indicates an expected call of PublishUserTyping. func (mr *MockAPIMockRecorder) PublishUserTyping(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishUserTyping", reflect.TypeOf((*MockAPI)(nil).PublishUserTyping), arg0, arg1, arg2) } // PublishWebSocketEvent mocks base method. func (m *MockAPI) PublishWebSocketEvent(arg0 string, arg1 map[string]interface{}, arg2 *model.WebsocketBroadcast) { m.ctrl.T.Helper() m.ctrl.Call(m, "PublishWebSocketEvent", arg0, arg1, arg2) } // PublishWebSocketEvent indicates an expected call of PublishWebSocketEvent. func (mr *MockAPIMockRecorder) PublishWebSocketEvent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishWebSocketEvent", reflect.TypeOf((*MockAPI)(nil).PublishWebSocketEvent), arg0, arg1, arg2) } // ReadFile mocks base method. func (m *MockAPI) ReadFile(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ReadFile", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // ReadFile indicates an expected call of ReadFile. func (mr *MockAPIMockRecorder) ReadFile(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockAPI)(nil).ReadFile), arg0) } // RegisterCollectionAndTopic mocks base method. func (m *MockAPI) RegisterCollectionAndTopic(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RegisterCollectionAndTopic", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // RegisterCollectionAndTopic indicates an expected call of RegisterCollectionAndTopic. func (mr *MockAPIMockRecorder) RegisterCollectionAndTopic(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterCollectionAndTopic", reflect.TypeOf((*MockAPI)(nil).RegisterCollectionAndTopic), arg0, arg1) } // RegisterCommand mocks base method. func (m *MockAPI) RegisterCommand(arg0 *model.Command) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RegisterCommand", arg0) ret0, _ := ret[0].(error) return ret0 } // RegisterCommand indicates an expected call of RegisterCommand. func (mr *MockAPIMockRecorder) RegisterCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterCommand", reflect.TypeOf((*MockAPI)(nil).RegisterCommand), arg0) } // RemovePlugin mocks base method. func (m *MockAPI) RemovePlugin(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemovePlugin", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemovePlugin indicates an expected call of RemovePlugin. func (mr *MockAPIMockRecorder) RemovePlugin(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePlugin", reflect.TypeOf((*MockAPI)(nil).RemovePlugin), arg0) } // RemoveReaction mocks base method. func (m *MockAPI) RemoveReaction(arg0 *model.Reaction) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveReaction", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemoveReaction indicates an expected call of RemoveReaction. func (mr *MockAPIMockRecorder) RemoveReaction(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveReaction", reflect.TypeOf((*MockAPI)(nil).RemoveReaction), arg0) } // RemoveTeamIcon mocks base method. func (m *MockAPI) RemoveTeamIcon(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveTeamIcon", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemoveTeamIcon indicates an expected call of RemoveTeamIcon. func (mr *MockAPIMockRecorder) RemoveTeamIcon(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveTeamIcon", reflect.TypeOf((*MockAPI)(nil).RemoveTeamIcon), arg0) } // RemoveUserCustomStatus mocks base method. func (m *MockAPI) RemoveUserCustomStatus(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveUserCustomStatus", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemoveUserCustomStatus indicates an expected call of RemoveUserCustomStatus. func (mr *MockAPIMockRecorder) RemoveUserCustomStatus(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).RemoveUserCustomStatus), arg0) } // RequestTrialLicense mocks base method. func (m *MockAPI) RequestTrialLicense(arg0 string, arg1 int, arg2, arg3 bool) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RequestTrialLicense", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*model.AppError) return ret0 } // RequestTrialLicense indicates an expected call of RequestTrialLicense. func (mr *MockAPIMockRecorder) RequestTrialLicense(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestTrialLicense", reflect.TypeOf((*MockAPI)(nil).RequestTrialLicense), arg0, arg1, arg2, arg3) } // RevokeSession mocks base method. func (m *MockAPI) RevokeSession(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RevokeSession", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RevokeSession indicates an expected call of RevokeSession. func (mr *MockAPIMockRecorder) RevokeSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeSession", reflect.TypeOf((*MockAPI)(nil).RevokeSession), arg0) } // RevokeUserAccessToken mocks base method. func (m *MockAPI) RevokeUserAccessToken(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RevokeUserAccessToken", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RevokeUserAccessToken indicates an expected call of RevokeUserAccessToken. func (mr *MockAPIMockRecorder) RevokeUserAccessToken(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeUserAccessToken", reflect.TypeOf((*MockAPI)(nil).RevokeUserAccessToken), arg0) } // RolesGrantPermission mocks base method. func (m *MockAPI) RolesGrantPermission(arg0 []string, arg1 string) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RolesGrantPermission", arg0, arg1) ret0, _ := ret[0].(bool) return ret0 } // RolesGrantPermission indicates an expected call of RolesGrantPermission. func (mr *MockAPIMockRecorder) RolesGrantPermission(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RolesGrantPermission", reflect.TypeOf((*MockAPI)(nil).RolesGrantPermission), arg0, arg1) } // SaveConfig mocks base method. func (m *MockAPI) SaveConfig(arg0 *model.Config) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveConfig", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // SaveConfig indicates an expected call of SaveConfig. func (mr *MockAPIMockRecorder) SaveConfig(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveConfig", reflect.TypeOf((*MockAPI)(nil).SaveConfig), arg0) } // SavePluginConfig mocks base method. func (m *MockAPI) SavePluginConfig(arg0 map[string]interface{}) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SavePluginConfig", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // SavePluginConfig indicates an expected call of SavePluginConfig. func (mr *MockAPIMockRecorder) SavePluginConfig(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePluginConfig", reflect.TypeOf((*MockAPI)(nil).SavePluginConfig), arg0) } // SearchChannels mocks base method. func (m *MockAPI) SearchChannels(arg0, arg1 string) ([]*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchChannels", arg0, arg1) ret0, _ := ret[0].([]*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchChannels indicates an expected call of SearchChannels. func (mr *MockAPIMockRecorder) SearchChannels(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchChannels", reflect.TypeOf((*MockAPI)(nil).SearchChannels), arg0, arg1) } // SearchPostsInTeam mocks base method. func (m *MockAPI) SearchPostsInTeam(arg0 string, arg1 []*model.SearchParams) ([]*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchPostsInTeam", arg0, arg1) ret0, _ := ret[0].([]*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchPostsInTeam indicates an expected call of SearchPostsInTeam. func (mr *MockAPIMockRecorder) SearchPostsInTeam(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchPostsInTeam", reflect.TypeOf((*MockAPI)(nil).SearchPostsInTeam), arg0, arg1) } // SearchPostsInTeamForUser mocks base method. func (m *MockAPI) SearchPostsInTeamForUser(arg0, arg1 string, arg2 model.SearchParameter) (*model.PostSearchResults, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchPostsInTeamForUser", arg0, arg1, arg2) ret0, _ := ret[0].(*model.PostSearchResults) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchPostsInTeamForUser indicates an expected call of SearchPostsInTeamForUser. func (mr *MockAPIMockRecorder) SearchPostsInTeamForUser(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchPostsInTeamForUser", reflect.TypeOf((*MockAPI)(nil).SearchPostsInTeamForUser), arg0, arg1, arg2) } // SearchTeams mocks base method. func (m *MockAPI) SearchTeams(arg0 string) ([]*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchTeams", arg0) ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchTeams indicates an expected call of SearchTeams. func (mr *MockAPIMockRecorder) SearchTeams(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchTeams", reflect.TypeOf((*MockAPI)(nil).SearchTeams), arg0) } // SearchUsers mocks base method. func (m *MockAPI) SearchUsers(arg0 *model.UserSearch) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchUsers", arg0) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchUsers indicates an expected call of SearchUsers. func (mr *MockAPIMockRecorder) SearchUsers(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUsers", reflect.TypeOf((*MockAPI)(nil).SearchUsers), arg0) } // SendEphemeralPost mocks base method. func (m *MockAPI) SendEphemeralPost(arg0 string, arg1 *model.Post) *model.Post { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendEphemeralPost", arg0, arg1) ret0, _ := ret[0].(*model.Post) return ret0 } // SendEphemeralPost indicates an expected call of SendEphemeralPost. func (mr *MockAPIMockRecorder) SendEphemeralPost(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendEphemeralPost", reflect.TypeOf((*MockAPI)(nil).SendEphemeralPost), arg0, arg1) } // SendMail mocks base method. func (m *MockAPI) SendMail(arg0, arg1, arg2 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendMail", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // SendMail indicates an expected call of SendMail. func (mr *MockAPIMockRecorder) SendMail(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMail", reflect.TypeOf((*MockAPI)(nil).SendMail), arg0, arg1, arg2) } // SetProfileImage mocks base method. func (m *MockAPI) SetProfileImage(arg0 string, arg1 []byte) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetProfileImage", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // SetProfileImage indicates an expected call of SetProfileImage. func (mr *MockAPIMockRecorder) SetProfileImage(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetProfileImage", reflect.TypeOf((*MockAPI)(nil).SetProfileImage), arg0, arg1) } // SetTeamIcon mocks base method. func (m *MockAPI) SetTeamIcon(arg0 string, arg1 []byte) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetTeamIcon", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // SetTeamIcon indicates an expected call of SetTeamIcon. func (mr *MockAPIMockRecorder) SetTeamIcon(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTeamIcon", reflect.TypeOf((*MockAPI)(nil).SetTeamIcon), arg0, arg1) } // SetUserStatusTimedDND mocks base method. func (m *MockAPI) SetUserStatusTimedDND(arg0 string, arg1 int64) (*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetUserStatusTimedDND", arg0, arg1) ret0, _ := ret[0].(*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SetUserStatusTimedDND indicates an expected call of SetUserStatusTimedDND. func (mr *MockAPIMockRecorder) SetUserStatusTimedDND(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUserStatusTimedDND", reflect.TypeOf((*MockAPI)(nil).SetUserStatusTimedDND), arg0, arg1) } // UnregisterCommand mocks base method. func (m *MockAPI) UnregisterCommand(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnregisterCommand", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UnregisterCommand indicates an expected call of UnregisterCommand. func (mr *MockAPIMockRecorder) UnregisterCommand(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterCommand", reflect.TypeOf((*MockAPI)(nil).UnregisterCommand), arg0, arg1) } // UpdateBotActive mocks base method. func (m *MockAPI) UpdateBotActive(arg0 string, arg1 bool) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateBotActive", arg0, arg1) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateBotActive indicates an expected call of UpdateBotActive. func (mr *MockAPIMockRecorder) UpdateBotActive(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateBotActive", reflect.TypeOf((*MockAPI)(nil).UpdateBotActive), arg0, arg1) } // UpdateChannel mocks base method. func (m *MockAPI) UpdateChannel(arg0 *model.Channel) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannel indicates an expected call of UpdateChannel. func (mr *MockAPIMockRecorder) UpdateChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannel", reflect.TypeOf((*MockAPI)(nil).UpdateChannel), arg0) } // UpdateChannelMemberNotifications mocks base method. func (m *MockAPI) UpdateChannelMemberNotifications(arg0, arg1 string, arg2 map[string]string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannelMemberNotifications", arg0, arg1, arg2) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannelMemberNotifications indicates an expected call of UpdateChannelMemberNotifications. func (mr *MockAPIMockRecorder) UpdateChannelMemberNotifications(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannelMemberNotifications", reflect.TypeOf((*MockAPI)(nil).UpdateChannelMemberNotifications), arg0, arg1, arg2) } // UpdateChannelMemberRoles mocks base method. func (m *MockAPI) UpdateChannelMemberRoles(arg0, arg1, arg2 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannelMemberRoles", arg0, arg1, arg2) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannelMemberRoles indicates an expected call of UpdateChannelMemberRoles. func (mr *MockAPIMockRecorder) UpdateChannelMemberRoles(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannelMemberRoles", reflect.TypeOf((*MockAPI)(nil).UpdateChannelMemberRoles), arg0, arg1, arg2) } // UpdateChannelSidebarCategories mocks base method. func (m *MockAPI) UpdateChannelSidebarCategories(arg0, arg1 string, arg2 []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannelSidebarCategories", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.SidebarCategoryWithChannels) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannelSidebarCategories indicates an expected call of UpdateChannelSidebarCategories. func (mr *MockAPIMockRecorder) UpdateChannelSidebarCategories(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannelSidebarCategories", reflect.TypeOf((*MockAPI)(nil).UpdateChannelSidebarCategories), arg0, arg1, arg2) } // UpdateCommand mocks base method. func (m *MockAPI) UpdateCommand(arg0 string, arg1 *model.Command) (*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateCommand", arg0, arg1) ret0, _ := ret[0].(*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateCommand indicates an expected call of UpdateCommand. func (mr *MockAPIMockRecorder) UpdateCommand(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCommand", reflect.TypeOf((*MockAPI)(nil).UpdateCommand), arg0, arg1) } // UpdateEphemeralPost mocks base method. func (m *MockAPI) UpdateEphemeralPost(arg0 string, arg1 *model.Post) *model.Post { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateEphemeralPost", arg0, arg1) ret0, _ := ret[0].(*model.Post) return ret0 } // UpdateEphemeralPost indicates an expected call of UpdateEphemeralPost. func (mr *MockAPIMockRecorder) UpdateEphemeralPost(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEphemeralPost", reflect.TypeOf((*MockAPI)(nil).UpdateEphemeralPost), arg0, arg1) } // UpdateOAuthApp mocks base method. func (m *MockAPI) UpdateOAuthApp(arg0 *model.OAuthApp) (*model.OAuthApp, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateOAuthApp", arg0) ret0, _ := ret[0].(*model.OAuthApp) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateOAuthApp indicates an expected call of UpdateOAuthApp. func (mr *MockAPIMockRecorder) UpdateOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOAuthApp", reflect.TypeOf((*MockAPI)(nil).UpdateOAuthApp), arg0) } // UpdatePost mocks base method. func (m *MockAPI) UpdatePost(arg0 *model.Post) (*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdatePost", arg0) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdatePost indicates an expected call of UpdatePost. func (mr *MockAPIMockRecorder) UpdatePost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePost", reflect.TypeOf((*MockAPI)(nil).UpdatePost), arg0) } // UpdatePreferencesForUser mocks base method. func (m *MockAPI) UpdatePreferencesForUser(arg0 string, arg1 []model.Preference) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdatePreferencesForUser", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // UpdatePreferencesForUser indicates an expected call of UpdatePreferencesForUser. func (mr *MockAPIMockRecorder) UpdatePreferencesForUser(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePreferencesForUser", reflect.TypeOf((*MockAPI)(nil).UpdatePreferencesForUser), arg0, arg1) } // UpdateTeam mocks base method. func (m *MockAPI) UpdateTeam(arg0 *model.Team) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateTeam", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateTeam indicates an expected call of UpdateTeam. func (mr *MockAPIMockRecorder) UpdateTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeam", reflect.TypeOf((*MockAPI)(nil).UpdateTeam), arg0) } // UpdateTeamMemberRoles mocks base method. func (m *MockAPI) UpdateTeamMemberRoles(arg0, arg1, arg2 string) (*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateTeamMemberRoles", arg0, arg1, arg2) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateTeamMemberRoles indicates an expected call of UpdateTeamMemberRoles. func (mr *MockAPIMockRecorder) UpdateTeamMemberRoles(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeamMemberRoles", reflect.TypeOf((*MockAPI)(nil).UpdateTeamMemberRoles), arg0, arg1, arg2) } // UpdateUser mocks base method. func (m *MockAPI) UpdateUser(arg0 *model.User) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateUser indicates an expected call of UpdateUser. func (mr *MockAPIMockRecorder) UpdateUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockAPI)(nil).UpdateUser), arg0) } // UpdateUserActive mocks base method. func (m *MockAPI) UpdateUserActive(arg0 string, arg1 bool) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserActive", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // UpdateUserActive indicates an expected call of UpdateUserActive. func (mr *MockAPIMockRecorder) UpdateUserActive(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserActive", reflect.TypeOf((*MockAPI)(nil).UpdateUserActive), arg0, arg1) } // UpdateUserCustomStatus mocks base method. func (m *MockAPI) UpdateUserCustomStatus(arg0 string, arg1 *model.CustomStatus) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserCustomStatus", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // UpdateUserCustomStatus indicates an expected call of UpdateUserCustomStatus. func (mr *MockAPIMockRecorder) UpdateUserCustomStatus(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserCustomStatus), arg0, arg1) } // UpdateUserStatus mocks base method. func (m *MockAPI) UpdateUserStatus(arg0, arg1 string) (*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserStatus", arg0, arg1) ret0, _ := ret[0].(*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateUserStatus indicates an expected call of UpdateUserStatus. func (mr *MockAPIMockRecorder) UpdateUserStatus(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserStatus), arg0, arg1) } // UploadData mocks base method. func (m *MockAPI) UploadData(arg0 *model.UploadSession, arg1 io.Reader) (*model.FileInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UploadData", arg0, arg1) ret0, _ := ret[0].(*model.FileInfo) ret1, _ := ret[1].(error) return ret0, ret1 } // UploadData indicates an expected call of UploadData. func (mr *MockAPIMockRecorder) UploadData(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadData", reflect.TypeOf((*MockAPI)(nil).UploadData), arg0, arg1) } // UploadFile mocks base method. func (m *MockAPI) UploadFile(arg0 []byte, arg1, arg2 string) (*model.FileInfo, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UploadFile", arg0, arg1, arg2) ret0, _ := ret[0].(*model.FileInfo) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UploadFile indicates an expected call of UploadFile. func (mr *MockAPIMockRecorder) UploadFile(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadFile", reflect.TypeOf((*MockAPI)(nil).UploadFile), arg0, arg1, arg2) } ================================================ FILE: server/services/permissions/mocks/mockstore.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/focalboard/server/services/permissions (interfaces: Store) // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/focalboard/server/model" ) // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller recorder *MockStoreMockRecorder } // MockStoreMockRecorder is the mock recorder for MockStore. type MockStoreMockRecorder struct { mock *MockStore } // NewMockStore creates a new mock instance. func NewMockStore(ctrl *gomock.Controller) *MockStore { mock := &MockStore{ctrl: ctrl} mock.recorder = &MockStoreMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStore) EXPECT() *MockStoreMockRecorder { return m.recorder } // GetBoard mocks base method. func (m *MockStore) GetBoard(arg0 string) (*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoard", arg0) ret0, _ := ret[0].(*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoard indicates an expected call of GetBoard. func (mr *MockStoreMockRecorder) GetBoard(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoard", reflect.TypeOf((*MockStore)(nil).GetBoard), arg0) } // GetBoardHistory mocks base method. func (m *MockStore) GetBoardHistory(arg0 string, arg1 model.QueryBoardHistoryOptions) ([]*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardHistory", arg0, arg1) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoardHistory indicates an expected call of GetBoardHistory. func (mr *MockStoreMockRecorder) GetBoardHistory(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardHistory", reflect.TypeOf((*MockStore)(nil).GetBoardHistory), arg0, arg1) } // GetMemberForBoard mocks base method. func (m *MockStore) GetMemberForBoard(arg0, arg1 string) (*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetMemberForBoard", arg0, arg1) ret0, _ := ret[0].(*model.BoardMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetMemberForBoard indicates an expected call of GetMemberForBoard. func (mr *MockStoreMockRecorder) GetMemberForBoard(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMemberForBoard", reflect.TypeOf((*MockStore)(nil).GetMemberForBoard), arg0, arg1) } ================================================ FILE: server/services/permissions/permissions.go ================================================ //go:generate mockgen -destination=mocks/mockstore.go -package mocks . Store // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package permissions import ( "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" ) type PermissionsService interface { HasPermissionTo(userID string, permission *mmModel.Permission) bool HasPermissionToTeam(userID, teamID string, permission *mmModel.Permission) bool HasPermissionToChannel(userID, channelID string, permission *mmModel.Permission) bool HasPermissionToBoard(userID, boardID string, permission *mmModel.Permission) bool } type Store interface { GetBoard(boardID string) (*model.Board, error) GetMemberForBoard(boardID, userID string) (*model.BoardMember, error) GetBoardHistory(boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) } ================================================ FILE: server/services/scheduler/scheduler.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package scheduler import ( "fmt" "time" ) type TaskFunc func() type ScheduledTask struct { Name string `json:"name"` Interval time.Duration `json:"interval"` Recurring bool `json:"recurring"` function func() cancel chan struct{} cancelled chan struct{} } func CreateTask(name string, function TaskFunc, timeToExecution time.Duration) *ScheduledTask { return createTask(name, function, timeToExecution, false) } func CreateRecurringTask(name string, function TaskFunc, interval time.Duration) *ScheduledTask { return createTask(name, function, interval, true) } func createTask(name string, function TaskFunc, interval time.Duration, recurring bool) *ScheduledTask { task := &ScheduledTask{ Name: name, Interval: interval, Recurring: recurring, function: function, cancel: make(chan struct{}), cancelled: make(chan struct{}), } go func() { defer close(task.cancelled) ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: function() case <-task.cancel: return } if !task.Recurring { break } } }() return task } func (task *ScheduledTask) Cancel() { close(task.cancel) <-task.cancelled } func (task *ScheduledTask) String() string { return fmt.Sprintf( "%s\nInterval: %s\nRecurring: %t\n", task.Name, task.Interval.String(), task.Recurring, ) } ================================================ FILE: server/services/scheduler/scheduler_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package scheduler import ( "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" ) func TestCreateTask(t *testing.T) { taskName := "Test Task" taskTime := time.Millisecond * 200 taskWait := time.Millisecond * 100 executionCount := new(int32) testFunc := func() { atomic.AddInt32(executionCount, 1) } task := CreateTask(taskName, testFunc, taskTime) assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) time.Sleep(taskTime + taskWait) assert.EqualValues(t, 1, atomic.LoadInt32(executionCount)) assert.Equal(t, taskName, task.Name) assert.Equal(t, taskTime, task.Interval) assert.False(t, task.Recurring) } func TestCreateRecurringTask(t *testing.T) { taskName := "Test Recurring Task" taskTime := time.Millisecond * 500 taskWait := time.Millisecond * 200 executionCount := new(int32) testFunc := func() { atomic.AddInt32(executionCount, 1) } task := CreateRecurringTask(taskName, testFunc, taskTime) assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) time.Sleep(taskTime + taskWait) assert.EqualValues(t, 1, atomic.LoadInt32(executionCount)) time.Sleep(taskTime) assert.EqualValues(t, 2, atomic.LoadInt32(executionCount)) assert.Equal(t, taskName, task.Name) assert.Equal(t, taskTime, task.Interval) assert.True(t, task.Recurring) task.Cancel() } func TestCancelTask(t *testing.T) { taskName := "Test Task" taskTime := time.Millisecond * 100 taskWait := time.Millisecond * 100 executionCount := new(int32) testFunc := func() { atomic.AddInt32(executionCount, 1) } task := CreateTask(taskName, testFunc, taskTime) assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) task.Cancel() time.Sleep(taskTime + taskWait) assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) } ================================================ FILE: server/services/store/generators/main.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package main import ( "bytes" "fmt" "go/ast" "go/format" "go/parser" "go/token" "io" "log" "os" "path" "strings" "text/template" ) const ( WithTransactionComment = "@withTransaction" ErrorType = "error" StringType = "string" IntType = "int" Int32Type = "int32" Int64Type = "int64" BoolType = "bool" ) func isError(typeName string) bool { return strings.Contains(typeName, ErrorType) } func isString(typeName string) bool { return typeName == StringType } func isInt(typeName string) bool { return typeName == IntType || typeName == Int32Type || typeName == Int64Type } func isBool(typeName string) bool { return typeName == BoolType } func main() { if err := buildTransactionalStore(); err != nil { log.Fatal(err) } } func buildTransactionalStore() error { code, err := generateLayer("TransactionalStore", "transactional_store.go.tmpl") if err != nil { return err } formatedCode, err := format.Source(code) if err != nil { return err } return os.WriteFile(path.Join("sqlstore/public_methods.go"), formatedCode, 0644) //nolint:gosec } type methodParam struct { Name string Type string } type methodData struct { Params []methodParam Results []string WithTransaction bool } type storeMetadata struct { Name string Methods map[string]methodData } var blacklistedStoreMethodNames = map[string]bool{ "Shutdown": true, "DBType": true, "DBVersion": true, } func extractMethodMetadata(method *ast.Field, src []byte) methodData { params := []methodParam{} results := []string{} withTransaction := false ast.Inspect(method.Type, func(expr ast.Node) bool { //nolint:gocritic switch e := expr.(type) { case *ast.FuncType: if method.Doc != nil { for _, comment := range method.Doc.List { if strings.Contains(comment.Text, WithTransactionComment) { withTransaction = true break } } } if e.Params != nil { for _, param := range e.Params.List { for _, paramName := range param.Names { params = append(params, methodParam{Name: paramName.Name, Type: string(src[param.Type.Pos()-1 : param.Type.End()-1])}) } } } if e.Results != nil { for _, result := range e.Results.List { results = append(results, string(src[result.Type.Pos()-1:result.Type.End()-1])) } } } return true }) return methodData{Params: params, Results: results, WithTransaction: withTransaction} } func extractStoreMetadata() (*storeMetadata, error) { // Create the AST by parsing src. fset := token.NewFileSet() // positions are relative to fset file, err := os.Open("store.go") if err != nil { return nil, fmt.Errorf("unable to open store/store.go file: %w", err) } src, err := io.ReadAll(file) if err != nil { return nil, err } file.Close() f, err := parser.ParseFile(fset, "", src, parser.AllErrors|parser.ParseComments) if err != nil { return nil, err } metadata := storeMetadata{Methods: map[string]methodData{}} ast.Inspect(f, func(n ast.Node) bool { //nolint:gocritic switch x := n.(type) { case *ast.TypeSpec: if x.Name.Name == "Store" { for _, method := range x.Type.(*ast.InterfaceType).Methods.List { methodName := method.Names[0].Name if _, ok := blacklistedStoreMethodNames[methodName]; ok { continue } metadata.Methods[methodName] = extractMethodMetadata(method, src) } } } return true }) return &metadata, nil } func generateLayer(name, templateFile string) ([]byte, error) { out := bytes.NewBufferString("") metadata, err := extractStoreMetadata() if err != nil { return nil, err } metadata.Name = name myFuncs := template.FuncMap{ "joinResultsForSignature": func(results []string) string { if len(results) == 0 { return "" } if len(results) == 1 { return strings.Join(results, ", ") } return fmt.Sprintf("(%s)", strings.Join(results, ", ")) }, "genResultsVars": func(results []string, withNilError bool) string { vars := []string{} for i, typeName := range results { switch { case isError(typeName): if withNilError { vars = append(vars, "nil") } else { vars = append(vars, "err") } case i == 0: vars = append(vars, "result") default: vars = append(vars, fmt.Sprintf("resultVar%d", i)) } } return strings.Join(vars, ", ") }, "errorPresent": func(results []string) bool { for _, typeName := range results { if isError(typeName) { return true } } return false }, "errorVar": func(results []string) string { for _, typeName := range results { if isError(typeName) { return "err" } } return "" }, "joinParams": func(params []methodParam) string { paramsNames := make([]string, 0, len(params)) for _, param := range params { tParams := "" if strings.HasPrefix(param.Type, "...") { tParams = "..." } paramsNames = append(paramsNames, param.Name+tParams) } return strings.Join(paramsNames, ", ") }, "joinParamsWithType": func(params []methodParam) string { paramsWithType := []string{} for _, param := range params { switch param.Type { case "Container": paramsWithType = append(paramsWithType, fmt.Sprintf("%s store.%s", param.Name, param.Type)) default: paramsWithType = append(paramsWithType, fmt.Sprintf("%s %s", param.Name, param.Type)) } } return strings.Join(paramsWithType, ", ") }, "renameStoreMethod": func(methodName string) string { return strings.ToLower(methodName[0:1]) + methodName[1:] }, "genErrorResultsVars": func(results []string, errName string) string { vars := []string{} for _, typeName := range results { switch { case isError(typeName): vars = append(vars, errName) case isString(typeName): vars = append(vars, "\"\"") case isInt(typeName): vars = append(vars, "0") case isBool(typeName): vars = append(vars, "false") default: vars = append(vars, "nil") } } return strings.Join(vars, ", ") }, } t := template.Must(template.New(templateFile).Funcs(myFuncs).ParseFiles("generators/" + templateFile)) if err = t.Execute(out, metadata); err != nil { return nil, err } return out.Bytes(), nil } ================================================ FILE: server/services/store/generators/transactional_store.go.tmpl ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. // Code generated by "make generate" from the Store interface // DO NOT EDIT // To add a public method, create an entry in the Store interface, // prefix it with a @withTransaction comment if you need it to be // transactional and then add a private method in the store itself // with db sq.BaseRunner as the first parameter before running `make // generate` package sqlstore import ( "context" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" mmModel "github.com/mattermost/mattermost/server/public/model" ) {{range $index, $element := .Methods}} func (s *SQLStore) {{$index}}({{$element.Params | joinParamsWithType}}) {{$element.Results | joinResultsForSignature}} { {{- if $element.WithTransaction}} if s.dbType == model.SqliteDBType { return s.{{$index | renameStoreMethod}}(s.db, {{$element.Params | joinParams}}) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return {{ genErrorResultsVars $element.Results "txErr"}} } {{- if $element.Results | len | eq 0}} s.{{$index | renameStoreMethod}}(tx, {{$element.Params | joinParams}}) if err := tx.Commit(); err != nil { return {{ genErrorResultsVars $element.Results "err"}} } {{else}} {{genResultsVars $element.Results false }} := s.{{$index | renameStoreMethod}}(tx, {{$element.Params | joinParams}}) {{- if $element.Results | errorPresent }} if {{$element.Results | errorVar}} != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "{{$index}}")) } return {{ genErrorResultsVars $element.Results "err"}} } {{end}} if err := tx.Commit(); err != nil { return {{ genErrorResultsVars $element.Results "err"}} } return {{ genResultsVars $element.Results true -}} {{end}} {{else}} return s.{{$index | renameStoreMethod}}(s.db, {{$element.Params | joinParams}}) {{end}} } {{end}} ================================================ FILE: server/services/store/mattermostauthlayer/mattermostauthlayer.go ================================================ package mattermostauthlayer import ( "database/sql" "encoding/json" "errors" "fmt" "net/http" "strings" mmModel "github.com/mattermost/mattermost/server/public/model" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var boardsBotID string // servicesAPI is the interface required my the MattermostAuthLayer to interact with // the mattermost-server. You can use plugin-api or product-api adapter implementations. type servicesAPI interface { GetDirectChannel(userID1, userID2 string) (*mmModel.Channel, error) GetChannelByID(channelID string) (*mmModel.Channel, error) GetChannelMember(channelID string, userID string) (*mmModel.ChannelMember, error) GetChannelsForTeamForUser(teamID string, userID string, includeDeleted bool) (mmModel.ChannelList, error) GetUserByID(userID string) (*mmModel.User, error) UpdateUser(user *mmModel.User) (*mmModel.User, error) GetUserByEmail(email string) (*mmModel.User, error) GetUserByUsername(username string) (*mmModel.User, error) GetLicense() *mmModel.License GetFileInfo(fileID string) (*mmModel.FileInfo, error) EnsureBot(bot *mmModel.Bot) (string, error) CreatePost(post *mmModel.Post) (*mmModel.Post, error) GetTeamMember(teamID string, userID string) (*mmModel.TeamMember, error) GetPreferencesForUser(userID string) (mmModel.Preferences, error) DeletePreferencesForUser(userID string, preferences mmModel.Preferences) error UpdatePreferencesForUser(userID string, preferences mmModel.Preferences) error } // Store represents the abstraction of the data storage. type MattermostAuthLayer struct { store.Store dbType string mmDB *sql.DB logger mlog.LoggerIFace servicesAPI servicesAPI tablePrefix string } // New creates a new SQL implementation of the store. func New(dbType string, db *sql.DB, store store.Store, logger mlog.LoggerIFace, api servicesAPI, tablePrefix string) (*MattermostAuthLayer, error) { layer := &MattermostAuthLayer{ Store: store, dbType: dbType, mmDB: db, logger: logger, servicesAPI: api, tablePrefix: tablePrefix, } return layer, nil } // Shutdown close the connection with the store. func (s *MattermostAuthLayer) Shutdown() error { return s.Store.Shutdown() } func (s *MattermostAuthLayer) GetRegisteredUserCount() (int, error) { query := s.getQueryBuilder(). Select("count(*)"). From("Users"). Where(sq.Eq{"deleteAt": 0}) row := query.QueryRow() var count int err := row.Scan(&count) if err != nil { return 0, err } return count, nil } func (s *MattermostAuthLayer) GetUserByID(userID string) (*model.User, error) { mmuser, err := s.servicesAPI.GetUserByID(userID) if err != nil { return nil, err } user := mmUserToFbUser(mmuser) return &user, nil } func (s *MattermostAuthLayer) GetUserByEmail(email string) (*model.User, error) { mmuser, err := s.servicesAPI.GetUserByEmail(email) if err != nil { return nil, err } user := mmUserToFbUser(mmuser) return &user, nil } func (s *MattermostAuthLayer) GetUserByUsername(username string) (*model.User, error) { mmuser, err := s.servicesAPI.GetUserByUsername(username) if err != nil { return nil, err } user := mmUserToFbUser(mmuser) return &user, nil } func (s *MattermostAuthLayer) CreateUser(user *model.User) (*model.User, error) { return nil, store.NewNotSupportedError("no user creation allowed from focalboard, create it using mattermost") } func (s *MattermostAuthLayer) UpdateUser(user *model.User) (*model.User, error) { return nil, store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) UpdateUserPassword(username, password string) error { return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) UpdateUserPasswordByID(userID, password string) error { return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) PatchUserPreferences(userID string, patch model.UserPreferencesPatch) (mmModel.Preferences, error) { preferences, err := s.GetUserPreferences(userID) if err != nil { return nil, err } if len(patch.UpdatedFields) > 0 { updatedPreferences := mmModel.Preferences{} for key, value := range patch.UpdatedFields { preference := mmModel.Preference{ UserId: userID, Category: model.PreferencesCategoryFocalboard, Name: key, Value: value, } updatedPreferences = append(updatedPreferences, preference) } if err := s.servicesAPI.UpdatePreferencesForUser(userID, updatedPreferences); err != nil { s.logger.Error("failed to update user preferences", mlog.String("user_id", userID), mlog.Err(err)) return nil, err } // we update the preferences list replacing or adding those // that were updated newPreferences := mmModel.Preferences{} for _, existingPreference := range preferences { hasBeenUpdated := false for _, updatedPreference := range updatedPreferences { if updatedPreference.Name == existingPreference.Name { hasBeenUpdated = true break } } if !hasBeenUpdated { newPreferences = append(newPreferences, existingPreference) } } newPreferences = append(newPreferences, updatedPreferences...) preferences = newPreferences } if len(patch.DeletedFields) > 0 { deletedPreferences := mmModel.Preferences{} for _, key := range patch.DeletedFields { preference := mmModel.Preference{ UserId: userID, Category: model.PreferencesCategoryFocalboard, Name: key, } deletedPreferences = append(deletedPreferences, preference) } if err := s.servicesAPI.DeletePreferencesForUser(userID, deletedPreferences); err != nil { s.logger.Error("failed to delete user preferences", mlog.String("user_id", userID), mlog.Err(err)) return nil, err } // we update the preferences removing those that have been // deleted newPreferences := mmModel.Preferences{} for _, existingPreference := range preferences { hasBeenDeleted := false for _, deletedPreference := range deletedPreferences { if deletedPreference.Name == existingPreference.Name { hasBeenDeleted = true break } } if !hasBeenDeleted { newPreferences = append(newPreferences, existingPreference) } } preferences = newPreferences } return preferences, nil } func (s *MattermostAuthLayer) GetUserPreferences(userID string) (mmModel.Preferences, error) { return s.servicesAPI.GetPreferencesForUser(userID) } // GetActiveUserCount returns the number of users with active sessions within N seconds ago. func (s *MattermostAuthLayer) GetActiveUserCount(updatedSecondsAgo int64) (int, error) { query := s.getQueryBuilder(). Select("count(distinct userId)"). From("Sessions"). Where(sq.Gt{"LastActivityAt": utils.GetMillis() - utils.SecondsToMillis(updatedSecondsAgo)}) row := query.QueryRow() var count int err := row.Scan(&count) if err != nil { return 0, err } return count, nil } func (s *MattermostAuthLayer) GetSession(token string, expireTime int64) (*model.Session, error) { return nil, store.NewNotSupportedError("sessions not used when using mattermost") } func (s *MattermostAuthLayer) CreateSession(session *model.Session) error { return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) RefreshSession(session *model.Session) error { return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) UpdateSession(session *model.Session) error { return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) DeleteSession(sessionID string) error { return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) CleanUpSessions(expireTime int64) error { return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost") } func (s *MattermostAuthLayer) GetTeam(id string) (*model.Team, error) { if id == "0" { team := model.Team{ ID: id, Title: "", } return &team, nil } query := s.getQueryBuilder(). Select("DisplayName"). From("Teams"). Where(sq.Eq{"ID": id}) row := query.QueryRow() var displayName string err := row.Scan(&displayName) if err != nil && !model.IsErrNotFound(err) { s.logger.Error("GetTeam scan error", mlog.String("team_id", id), mlog.Err(err), ) return nil, err } return &model.Team{ID: id, Title: displayName}, nil } // GetTeamsForUser retrieves all the teams that the user is a member of. func (s *MattermostAuthLayer) GetTeamsForUser(userID string) ([]*model.Team, error) { query := s.getQueryBuilder(). Select("t.Id", "t.DisplayName"). From("Teams as t"). Join("TeamMembers as tm on t.Id=tm.TeamId"). Where(sq.Eq{"tm.UserId": userID}). Where(sq.Eq{"tm.DeleteAt": 0}) rows, err := query.Query() if err != nil { return nil, err } defer s.CloseRows(rows) teams := []*model.Team{} for rows.Next() { var team model.Team err := rows.Scan( &team.ID, &team.Title, ) if err != nil { return nil, err } teams = append(teams, &team) } return teams, nil } func (s *MattermostAuthLayer) getQueryBuilder() sq.StatementBuilderType { builder := sq.StatementBuilder if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType { builder = builder.PlaceholderFormat(sq.Dollar) } return builder.RunWith(s.mmDB) } func (s *MattermostAuthLayer) GetUsersByTeam(teamID string, asGuestID string, showEmail, showName bool) ([]*model.User, error) { query := s.baseUserQuery(showEmail, showName). Where(sq.Eq{"u.deleteAt": 0}) if asGuestID == "" { query = query. Join("TeamMembers as tm ON tm.UserID = u.id"). Where(sq.Eq{"tm.TeamId": teamID}) } else { boards, err := s.GetBoardsForUserAndTeam(asGuestID, teamID, false) if err != nil { return nil, err } boardsIDs := []string{} for _, board := range boards { boardsIDs = append(boardsIDs, board.ID) } query = query. Join(s.tablePrefix + "board_members as bm ON bm.UserID = u.ID"). Where(sq.Eq{"bm.BoardId": boardsIDs}) } rows, err := query.Query() if err != nil { return nil, err } defer s.CloseRows(rows) users, err := s.usersFromRows(rows) if err != nil { return nil, err } return users, nil } func (s *MattermostAuthLayer) GetUsersList(userIDs []string, showEmail, showName bool) ([]*model.User, error) { query := s.baseUserQuery(showEmail, showName). Where(sq.Eq{"u.id": userIDs}) rows, err := query.Query() if err != nil { return nil, err } defer s.CloseRows(rows) users, err := s.usersFromRows(rows) if err != nil { return nil, err } if len(users) != len(userIDs) { return users, model.NewErrNotAllFound("user", userIDs) } return users, nil } func (s *MattermostAuthLayer) SearchUsersByTeam(teamID string, searchQuery string, asGuestID string, excludeBots, showEmail, showName bool) ([]*model.User, error) { query := s.baseUserQuery(showEmail, showName). Where(sq.Eq{"u.deleteAt": 0}). Where(sq.Or{ sq.Like{"u.username": "%" + searchQuery + "%"}, sq.Like{"u.nickname": "%" + searchQuery + "%"}, sq.Like{"u.firstname": "%" + searchQuery + "%"}, sq.Like{"u.lastname": "%" + searchQuery + "%"}, }). OrderBy("u.username"). Limit(10) if excludeBots { query = query. Where(sq.Eq{"b.UserId IS NOT NULL": false}) } if asGuestID == "" { query = query. Join("TeamMembers as tm ON tm.UserID = u.id"). Where(sq.Eq{"tm.TeamId": teamID}) } else { boards, err := s.GetBoardsForUserAndTeam(asGuestID, teamID, false) if err != nil { return nil, err } boardsIDs := []string{} for _, board := range boards { boardsIDs = append(boardsIDs, board.ID) } query = query. Join(s.tablePrefix + "board_members as bm ON bm.user_id = u.ID"). Where(sq.Eq{"bm.board_id": boardsIDs}) } rows, err := query.Query() if err != nil { return nil, err } defer s.CloseRows(rows) users, err := s.usersFromRows(rows) if err != nil { return nil, err } return users, nil } func (s *MattermostAuthLayer) usersFromRows(rows *sql.Rows) ([]*model.User, error) { users := []*model.User{} for rows.Next() { var user model.User err := rows.Scan( &user.ID, &user.Username, &user.Email, &user.Nickname, &user.FirstName, &user.LastName, &user.CreateAt, &user.UpdateAt, &user.DeleteAt, &user.IsBot, &user.IsGuest, ) if err != nil { return nil, err } users = append(users, &user) } return users, nil } func (s *MattermostAuthLayer) CloseRows(rows *sql.Rows) { if err := rows.Close(); err != nil { s.logger.Error("error closing MattermostAuthLayer row set", mlog.Err(err)) } } func (s *MattermostAuthLayer) CreatePrivateWorkspace(userID string) (string, error) { // we emulate a private workspace by creating // a DM channel from the user to themselves. channel, err := s.servicesAPI.GetDirectChannel(userID, userID) if err != nil { s.logger.Error("error fetching private workspace", mlog.String("userID", userID), mlog.Err(err)) return "", err } return channel.Id, nil } func mmUserToFbUser(mmUser *mmModel.User) model.User { authData := "" if mmUser.AuthData != nil { authData = *mmUser.AuthData } return model.User{ ID: mmUser.Id, Username: mmUser.Username, Email: mmUser.Email, Password: mmUser.Password, Nickname: mmUser.Nickname, FirstName: mmUser.FirstName, LastName: mmUser.LastName, MfaSecret: mmUser.MfaSecret, AuthService: mmUser.AuthService, AuthData: authData, CreateAt: mmUser.CreateAt, UpdateAt: mmUser.UpdateAt, DeleteAt: mmUser.DeleteAt, IsBot: mmUser.IsBot, IsGuest: mmUser.IsGuest(), Roles: mmUser.Roles, } } func (s *MattermostAuthLayer) GetFileInfo(id string) (*mmModel.FileInfo, error) { fileInfo, err := s.servicesAPI.GetFileInfo(id) if err != nil { // Not finding fileinfo is fine because we don't have data for // any existing files already uploaded in Boards before this code // was deployed. var appErr *mmModel.AppError if errors.As(err, &appErr) { if appErr.StatusCode == http.StatusNotFound { return nil, model.NewErrNotFound("file info ID=" + id) } } s.logger.Error("error fetching fileinfo", mlog.String("id", id), mlog.Err(err), ) return nil, err } return fileInfo, nil } func (s *MattermostAuthLayer) SaveFileInfo(fileInfo *mmModel.FileInfo) error { query := s.getQueryBuilder(). Insert("FileInfo"). Columns( "Id", "CreatorId", "PostId", "CreateAt", "UpdateAt", "DeleteAt", "Path", "ThumbnailPath", "PreviewPath", "Name", "Extension", "Size", "MimeType", "Width", "Height", "HasPreviewImage", "MiniPreview", "Content", "RemoteId", "Archived", ). Values( fileInfo.Id, fileInfo.CreatorId, fileInfo.PostId, fileInfo.CreateAt, fileInfo.UpdateAt, fileInfo.DeleteAt, fileInfo.Path, fileInfo.ThumbnailPath, fileInfo.PreviewPath, fileInfo.Name, fileInfo.Extension, fileInfo.Size, fileInfo.MimeType, fileInfo.Width, fileInfo.Height, fileInfo.HasPreviewImage, fileInfo.MiniPreview, fileInfo.Content, fileInfo.RemoteId, false, ) if _, err := query.Exec(); err != nil { s.logger.Error( "failed to save fileinfo", mlog.String("file_name", fileInfo.Name), mlog.Int("size", fileInfo.Size), mlog.Err(err), ) return err } return nil } func (s *MattermostAuthLayer) GetLicense() *mmModel.License { return s.servicesAPI.GetLicense() } func boardFields(prefix string) []string { //nolint:unparam fields := []string{ "id", "team_id", "COALESCE(channel_id, '')", "COALESCE(created_by, '')", "modified_by", "type", "minimum_role", "title", "description", "icon", "show_description", "is_template", "template_version", "COALESCE(properties, '{}')", "COALESCE(card_properties, '[]')", "create_at", "update_at", "delete_at", } if prefix == "" { return fields } prefixedFields := make([]string, len(fields)) for i, field := range fields { if strings.HasPrefix(field, "COALESCE(") { prefixedFields[i] = strings.Replace(field, "COALESCE(", "COALESCE("+prefix, 1) } else { prefixedFields[i] = prefix + field } } return prefixedFields } func (s *MattermostAuthLayer) baseUserQuery(showEmail, showName bool) sq.SelectBuilder { emailField := "''" if showEmail { emailField = "u.email" } firstNameField := "''" lastNameField := "''" if showName { firstNameField = "u.firstname" lastNameField = "u.lastname" } return s.getQueryBuilder(). Select( "u.id", "u.username", emailField, "u.nickname", firstNameField, lastNameField, "u.CreateAt as create_at", "u.UpdateAt as update_at", "u.DeleteAt as delete_at", "b.UserId IS NOT NULL AS is_bot", "u.roles = 'system_guest' as is_guest", ). From("Users as u"). LeftJoin("Bots b ON ( b.UserID = u.id )") } // SearchBoardsForUser returns all boards that match with the // term that are either private and which the user is a member of, or // they're open, regardless of the user membership. // Search is case-insensitive. func (s *MattermostAuthLayer) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) { // as we're joining three queries, we need to avoid numbered // placeholders until the join is done, so we use the default // question mark placeholder here builder := s.getQueryBuilder().PlaceholderFormat(sq.Question) boardMembersQ := builder. Select(boardFields("b.")...). From(s.tablePrefix + "boards as b"). Join(s.tablePrefix + "board_members as bm on b.id=bm.board_id"). Where(sq.Eq{ "b.is_template": false, "bm.user_id": userID, }) teamMembersQ := builder. Select(boardFields("b.")...). From(s.tablePrefix + "boards as b"). Join("TeamMembers as tm on tm.teamid=b.team_id"). Where(sq.Eq{ "b.is_template": false, "tm.userID": userID, "tm.deleteAt": 0, "b.type": model.BoardTypeOpen, }) channelMembersQ := builder. Select(boardFields("b.")...). From(s.tablePrefix + "boards as b"). Join("ChannelMembers as cm on cm.channelId=b.channel_id"). Where(sq.Eq{ "b.is_template": false, "cm.userId": userID, }) if term != "" { if searchField == model.BoardSearchFieldPropertyName { var where, whereTerm string switch s.dbType { case model.PostgresDBType: where = "b.properties->? is not null" whereTerm = term case model.MysqlDBType, model.SqliteDBType: where = "JSON_EXTRACT(b.properties, ?) IS NOT NULL" whereTerm = "$." + term default: where = "b.properties LIKE ?" whereTerm = "%\"" + term + "\"%" } boardMembersQ = boardMembersQ.Where(where, whereTerm) teamMembersQ = teamMembersQ.Where(where, whereTerm) channelMembersQ = channelMembersQ.Where(where, whereTerm) } else { // model.BoardSearchFieldTitle // break search query into space separated words // and search for all words. // This should later be upgraded to industrial-strength // word tokenizer, that uses much more than space // to break words. conditions := sq.And{} for _, word := range strings.Split(strings.TrimSpace(term), " ") { conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) } boardMembersQ = boardMembersQ.Where(conditions) teamMembersQ = teamMembersQ.Where(conditions) channelMembersQ = channelMembersQ.Where(conditions) } } teamMembersSQL, teamMembersArgs, err := teamMembersQ.ToSql() if err != nil { return nil, fmt.Errorf("SearchBoardsForUser error getting teamMembersSQL: %w", err) } channelMembersSQL, channelMembersArgs, err := channelMembersQ.ToSql() if err != nil { return nil, fmt.Errorf("SearchBoardsForUser error getting channelMembersSQL: %w", err) } unionQ := boardMembersQ user, err := s.GetUserByID(userID) if err != nil { return nil, err } // NOTE: theoretically, could do e.g. `isGuest := !includePublicBoards` // but that introduces some tight coupling + fragility if !user.IsGuest { unionQ = unionQ. Prefix("("). Suffix(") UNION ("+channelMembersSQL+")", channelMembersArgs...) if includePublicBoards { unionQ = unionQ.Suffix(" UNION ("+teamMembersSQL+")", teamMembersArgs...) } } else if includePublicBoards { unionQ = unionQ. Prefix("("). Suffix(") UNION ("+teamMembersSQL+")", teamMembersArgs...) } unionSQL, unionArgs, err := unionQ.ToSql() if err != nil { return nil, fmt.Errorf("SearchBoardsForUser error getting unionSQL: %w", err) } // if we're using postgres or sqlite, we need to replace the // question mark placeholder with the numbered dollar one, now // that the full query is built if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType { var rErr error unionSQL, rErr = sq.Dollar.ReplacePlaceholders(unionSQL) if rErr != nil { return nil, fmt.Errorf("SearchBoardsForUser unable to replace unionSQL placeholders: %w", rErr) } } rows, err := s.mmDB.Query(unionSQL, unionArgs...) if err != nil { s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.boardsFromRows(rows, false) } // searchBoardsForUserInTeam returns all boards that match with the // term that are either private and which the user is a member of, or // they're open, regardless of the user membership. // Search is case-insensitive. func (s *MattermostAuthLayer) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) { // as we're joining three queries, we need to avoid numbered // placeholders until the join is done, so we use the default // question mark placeholder here builder := s.getQueryBuilder().PlaceholderFormat(sq.Question) openBoardsQ := builder. Select(boardFields("b.")...). From(s.tablePrefix + "boards as b"). Where(sq.Eq{ "b.is_template": false, "b.team_id": teamID, "b.type": model.BoardTypeOpen, }) memberBoardsQ := builder. Select(boardFields("b.")...). From(s.tablePrefix + "boards AS b"). Join(s.tablePrefix + "board_members AS bm on b.id = bm.board_id"). Where(sq.Eq{ "b.is_template": false, "b.team_id": teamID, "bm.user_id": userID, }) channelMemberBoardsQ := builder. Select(boardFields("b.")...). From(s.tablePrefix + "boards AS b"). Join("ChannelMembers AS cm on cm.channelId = b.channel_id"). Where(sq.Eq{ "b.is_template": false, "b.team_id": teamID, "cm.userId": userID, }) if term != "" { // break search query into space separated words // and search for all words. // This should later be upgraded to industrial-strength // word tokenizer, that uses much more than space // to break words. conditions := sq.And{} for _, word := range strings.Split(strings.TrimSpace(term), " ") { conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) } openBoardsQ = openBoardsQ.Where(conditions) memberBoardsQ = memberBoardsQ.Where(conditions) channelMemberBoardsQ = channelMemberBoardsQ.Where(conditions) } memberBoardsSQL, memberBoardsArgs, err := memberBoardsQ.ToSql() if err != nil { return nil, fmt.Errorf("SearchBoardsForUserInTeam error getting memberBoardsSQL: %w", err) } channelMemberBoardsSQL, channelMemberBoardsArgs, err := channelMemberBoardsQ.ToSql() if err != nil { return nil, fmt.Errorf("SearchBoardsForUserInTeam error getting channelMemberBoardsSQL: %w", err) } unionQ := openBoardsQ. Prefix("("). Suffix(") UNION ("+memberBoardsSQL, memberBoardsArgs...). Suffix(") UNION ("+channelMemberBoardsSQL+")", channelMemberBoardsArgs...) unionSQL, unionArgs, err := unionQ.ToSql() if err != nil { return nil, fmt.Errorf("SearchBoardsForUserInTeam error getting unionSQL: %w", err) } // if we're using postgres or sqlite, we need to replace the // question mark placeholder with the numbered dollar one, now // that the full query is built if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType { var rErr error unionSQL, rErr = sq.Dollar.ReplacePlaceholders(unionSQL) if rErr != nil { return nil, fmt.Errorf("SearchBoardsForUserInTeam unable to replace unionSQL placeholders: %w", rErr) } } rows, err := s.mmDB.Query(unionSQL, unionArgs...) if err != nil { s.logger.Error(`searchBoardsForUserInTeam ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.boardsFromRows(rows, false) } func (s *MattermostAuthLayer) boardsFromRows(rows *sql.Rows, removeDuplicates bool) ([]*model.Board, error) { boards := []*model.Board{} idMap := make(map[string]struct{}) for rows.Next() { var board model.Board var propertiesBytes []byte var cardPropertiesBytes []byte err := rows.Scan( &board.ID, &board.TeamID, &board.ChannelID, &board.CreatedBy, &board.ModifiedBy, &board.Type, &board.MinimumRole, &board.Title, &board.Description, &board.Icon, &board.ShowDescription, &board.IsTemplate, &board.TemplateVersion, &propertiesBytes, &cardPropertiesBytes, &board.CreateAt, &board.UpdateAt, &board.DeleteAt, ) if err != nil { s.logger.Error("boardsFromRows scan error", mlog.Err(err)) return nil, err } if removeDuplicates { if _, ok := idMap[board.ID]; ok { continue } else { idMap[board.ID] = struct{}{} } } err = json.Unmarshal(propertiesBytes, &board.Properties) if err != nil { s.logger.Error("board properties unmarshal error", mlog.Err(err)) return nil, err } err = json.Unmarshal(cardPropertiesBytes, &board.CardProperties) if err != nil { s.logger.Error("board card properties unmarshal error", mlog.Err(err)) return nil, err } boards = append(boards, &board) } return boards, nil } func (s *MattermostAuthLayer) implicitBoardMembershipsFromRows(rows *sql.Rows) ([]*model.BoardMember, error) { boardMembers := []*model.BoardMember{} for rows.Next() { var boardMember model.BoardMember err := rows.Scan( &boardMember.UserID, &boardMember.BoardID, ) if err != nil { return nil, err } boardMember.Roles = "editor" boardMember.SchemeEditor = true boardMember.Synthetic = true boardMembers = append(boardMembers, &boardMember) } return boardMembers, nil } func (s *MattermostAuthLayer) GetMemberForBoard(boardID, userID string) (*model.BoardMember, error) { bm, originalErr := s.Store.GetMemberForBoard(boardID, userID) // Explicit membership not found if model.IsErrNotFound(originalErr) { if userID == model.SystemUserID { return nil, model.NewErrNotFound(userID) } var user *model.User // No synthetic memberships for guests user, err := s.GetUserByID(userID) if err != nil { return nil, err } if user.IsGuest { return nil, model.NewErrNotFound("user is a guest") } b, boardErr := s.Store.GetBoard(boardID) if boardErr != nil { return nil, boardErr } if b.ChannelID != "" { _, memberErr := s.servicesAPI.GetChannelMember(b.ChannelID, userID) if memberErr != nil { var appErr *mmModel.AppError if errors.As(memberErr, &appErr) && appErr.StatusCode == http.StatusNotFound { // Plugin API returns error if channel member doesn't exist. // We're fine if it doesn't exist, so its not an error for us. message := fmt.Sprintf("member BoardID=%s UserID=%s", boardID, userID) return nil, model.NewErrNotFound(message) } return nil, memberErr } return &model.BoardMember{ BoardID: boardID, UserID: userID, Roles: "editor", SchemeAdmin: false, SchemeEditor: true, SchemeCommenter: false, SchemeViewer: false, Synthetic: true, }, nil } if b.Type == model.BoardTypeOpen && b.IsTemplate { _, memberErr := s.servicesAPI.GetTeamMember(b.TeamID, userID) if memberErr != nil { var appErr *mmModel.AppError if errors.As(memberErr, &appErr) && appErr.StatusCode == http.StatusNotFound { return nil, model.NewErrNotFound(userID) } return nil, memberErr } return &model.BoardMember{ BoardID: boardID, UserID: userID, Roles: "viewer", SchemeAdmin: false, SchemeEditor: false, SchemeCommenter: false, SchemeViewer: true, Synthetic: true, }, nil } } if originalErr != nil { return nil, originalErr } return bm, nil } func (s *MattermostAuthLayer) GetMembersForUser(userID string) ([]*model.BoardMember, error) { explicitMembers, err := s.Store.GetMembersForUser(userID) if err != nil { s.logger.Error(`getMembersForUser ERROR`, mlog.Err(err)) return nil, err } query := s.getQueryBuilder(). Select("CM.userID, B.Id"). From(s.tablePrefix + "boards AS B"). Join("ChannelMembers AS CM ON B.channel_id=CM.channelId"). Where(sq.Eq{"CM.userID": userID}) rows, err := query.Query() if err != nil { s.logger.Error(`getMembersForUser ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) members := []*model.BoardMember{} existingMembers := map[string]bool{} for _, m := range explicitMembers { members = append(members, m) existingMembers[m.BoardID] = true } // No synthetic memberships for guests user, err := s.GetUserByID(userID) if err != nil { return nil, err } if user.IsGuest { return members, nil } implicitMembers, err := s.implicitBoardMembershipsFromRows(rows) if err != nil { return nil, err } for _, m := range implicitMembers { if !existingMembers[m.BoardID] { members = append(members, m) } } return members, nil } func (s *MattermostAuthLayer) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) { explicitMembers, err := s.Store.GetMembersForBoard(boardID) if err != nil { s.logger.Error(`getMembersForBoard ERROR`, mlog.Err(err)) return nil, err } query := s.getQueryBuilder(). Select("CM.userID, B.Id"). From(s.tablePrefix + "boards AS B"). Join("ChannelMembers AS CM ON B.channel_id=CM.channelId"). Join("Users as U on CM.userID = U.id"). LeftJoin("Bots as bo on U.id = bo.UserID"). Where(sq.Eq{"B.id": boardID}). Where(sq.NotEq{"B.channel_id": ""}). // Filter out guests as they don't have synthetic membership Where(sq.NotEq{"U.roles": "system_guest"}). Where(sq.Eq{"bo.UserId IS NOT NULL": false}) rows, err := query.Query() if err != nil { s.logger.Error(`getMembersForBoard ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) implicitMembers, err := s.implicitBoardMembershipsFromRows(rows) if err != nil { return nil, err } members := []*model.BoardMember{} existingMembers := map[string]bool{} for _, m := range explicitMembers { members = append(members, m) existingMembers[m.UserID] = true } for _, m := range implicitMembers { if !existingMembers[m.UserID] { members = append(members, m) } } return members, nil } func (s *MattermostAuthLayer) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) { if includePublicBoards { boards, err := s.SearchBoardsForUserInTeam(teamID, "", userID) if err != nil { return nil, err } return boards, nil } // retrieve only direct memberships for user // this is usually done for guests. members, err := s.GetMembersForUser(userID) if err != nil { return nil, err } boardIDs := []string{} for _, m := range members { boardIDs = append(boardIDs, m.BoardID) } boards, err := s.Store.GetBoardsInTeamByIds(boardIDs, teamID) if model.IsErrNotFound(err) { if boards == nil { boards = []*model.Board{} } return boards, nil } if err != nil { return nil, err } return boards, nil } func (s *MattermostAuthLayer) SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error) { channels, err := s.servicesAPI.GetChannelsForTeamForUser(teamID, userID, false) if err != nil { return nil, err } lowerQuery := strings.ToLower(query) result := []*mmModel.Channel{} count := 0 for _, channel := range channels { if channel.Type != mmModel.ChannelTypeDirect && channel.Type != mmModel.ChannelTypeGroup && (strings.Contains(strings.ToLower(channel.Name), lowerQuery) || strings.Contains(strings.ToLower(channel.DisplayName), lowerQuery)) { result = append(result, channel) count++ if count >= 10 { break } } } return result, nil } func (s *MattermostAuthLayer) GetChannel(teamID, channelID string) (*mmModel.Channel, error) { channel, err := s.servicesAPI.GetChannelByID(channelID) if err != nil { return nil, err } return channel, nil } func (s *MattermostAuthLayer) getBoardsBotID() (string, error) { if boardsBotID == "" { var err error boardsBotID, err = s.servicesAPI.EnsureBot(model.FocalboardBot) if err != nil { s.logger.Error("failed to ensure boards bot", mlog.Err(err)) return "", err } } return boardsBotID, nil } func (s *MattermostAuthLayer) SendMessage(message, postType string, receipts []string) error { botID, err := s.getBoardsBotID() if err != nil { return err } for _, receipt := range receipts { channel, err := s.servicesAPI.GetDirectChannel(botID, receipt) if err != nil { s.logger.Error( "failed to get DM channel between system bot and user for receipt", mlog.String("receipt", receipt), mlog.String("user_id", receipt), mlog.Err(err), ) continue } if err := s.PostMessage(message, postType, channel.Id); err != nil { s.logger.Error( "failed to send message to receipt from SendMessage", mlog.String("receipt", receipt), mlog.Err(err), ) continue } } return nil } func (s *MattermostAuthLayer) PostMessage(message, postType, channelID string) error { botID, err := s.getBoardsBotID() if err != nil { return err } post := &mmModel.Post{ Message: message, UserId: botID, ChannelId: channelID, Type: postType, } if _, err := s.servicesAPI.CreatePost(post); err != nil { s.logger.Error( "failed to send message to receipt from PostMessage", mlog.Err(err), ) } return nil } func (s *MattermostAuthLayer) GetUserTimezone(userID string) (string, error) { user, err := s.servicesAPI.GetUserByID(userID) if err != nil { return "", err } timezone := user.Timezone return mmModel.GetPreferredTimezone(timezone), nil } func (s *MattermostAuthLayer) CanSeeUser(seerID string, seenID string) (bool, error) { mmuser, appErr := s.servicesAPI.GetUserByID(seerID) if appErr != nil { return false, appErr } if !mmuser.IsGuest() { return true, nil } query := s.getQueryBuilder(). Select("1"). From(s.tablePrefix + "board_members AS bm1"). Join(s.tablePrefix + "board_members AS bm2 ON bm1.board_id=bm2.board_id"). Where(sq.Or{ sq.And{ sq.Eq{"bm1.user_id": seerID}, sq.Eq{"bm2.user_id": seenID}, }, sq.And{ sq.Eq{"bm1.user_id": seenID}, sq.Eq{"bm2.user_id": seerID}, }, }).Limit(1) rows, err := query.Query() if err != nil { return false, err } defer s.CloseRows(rows) for rows.Next() { return true, err } query = s.getQueryBuilder(). Select("1"). From("channelmembers AS cm1"). Join("channelmembers AS cm2 ON cm1.channelid=cm2.channelid"). Where(sq.Or{ sq.And{ sq.Eq{"cm1.userid": seerID}, sq.Eq{"cm2.userid": seenID}, }, sq.And{ sq.Eq{"cm1.userid": seenID}, sq.Eq{"cm2.userid": seerID}, }, }).Limit(1) rows, err = query.Query() if err != nil { return false, err } defer s.CloseRows(rows) for rows.Next() { return true, err } return false, nil } ================================================ FILE: server/services/store/mattermostauthlayer/mattermostauthlayer_test.go ================================================ package mattermostauthlayer import ( "errors" "testing" "github.com/golang/mock/gomock" "github.com/mattermost/focalboard/server/model" mockservicesapi "github.com/mattermost/focalboard/server/model/mocks" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/stretchr/testify/require" ) var errTest = errors.New("failed to patch bot") func TestGetBoardsBotID(t *testing.T) { ctrl := gomock.NewController(t) servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl) mmAuthLayer, _ := New("test", nil, nil, mlog.CreateConsoleTestLogger(t), servicesAPI, "") servicesAPI.EXPECT().EnsureBot(model.FocalboardBot).Return("", errTest) _, err := mmAuthLayer.getBoardsBotID() require.NotEmpty(t, err) servicesAPI.EXPECT().EnsureBot(model.FocalboardBot).Return("TestBotID", nil).Times(1) botID, err := mmAuthLayer.getBoardsBotID() require.Empty(t, err) require.NotEmpty(t, botID) require.Equal(t, "TestBotID", botID) // Call again, should not call "EnsureBot" botID, err = mmAuthLayer.getBoardsBotID() require.Empty(t, err) require.NotEmpty(t, botID) require.Equal(t, "TestBotID", botID) } ================================================ FILE: server/services/store/mockstore/mockstore.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/focalboard/server/services/store (interfaces: Store) // Package mockstore is a generated GoMock package. package mockstore import ( reflect "reflect" time "time" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/focalboard/server/model" model0 "github.com/mattermost/mattermost/server/public/model" ) // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller recorder *MockStoreMockRecorder } // MockStoreMockRecorder is the mock recorder for MockStore. type MockStoreMockRecorder struct { mock *MockStore } // NewMockStore creates a new mock instance. func NewMockStore(ctrl *gomock.Controller) *MockStore { mock := &MockStore{ctrl: ctrl} mock.recorder = &MockStoreMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStore) EXPECT() *MockStoreMockRecorder { return m.recorder } // AddUpdateCategoryBoard mocks base method. func (m *MockStore) AddUpdateCategoryBoard(arg0, arg1 string, arg2 []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddUpdateCategoryBoard", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // AddUpdateCategoryBoard indicates an expected call of AddUpdateCategoryBoard. func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1, arg2) } // CanSeeUser mocks base method. func (m *MockStore) CanSeeUser(arg0, arg1 string) (bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CanSeeUser", arg0, arg1) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(error) return ret0, ret1 } // CanSeeUser indicates an expected call of CanSeeUser. func (mr *MockStoreMockRecorder) CanSeeUser(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanSeeUser", reflect.TypeOf((*MockStore)(nil).CanSeeUser), arg0, arg1) } // CleanUpSessions mocks base method. func (m *MockStore) CleanUpSessions(arg0 int64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CleanUpSessions", arg0) ret0, _ := ret[0].(error) return ret0 } // CleanUpSessions indicates an expected call of CleanUpSessions. func (mr *MockStoreMockRecorder) CleanUpSessions(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanUpSessions", reflect.TypeOf((*MockStore)(nil).CleanUpSessions), arg0) } // CreateBoardsAndBlocks mocks base method. func (m *MockStore) CreateBoardsAndBlocks(arg0 *model.BoardsAndBlocks, arg1 string) (*model.BoardsAndBlocks, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateBoardsAndBlocks", arg0, arg1) ret0, _ := ret[0].(*model.BoardsAndBlocks) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateBoardsAndBlocks indicates an expected call of CreateBoardsAndBlocks. func (mr *MockStoreMockRecorder) CreateBoardsAndBlocks(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBoardsAndBlocks", reflect.TypeOf((*MockStore)(nil).CreateBoardsAndBlocks), arg0, arg1) } // CreateBoardsAndBlocksWithAdmin mocks base method. func (m *MockStore) CreateBoardsAndBlocksWithAdmin(arg0 *model.BoardsAndBlocks, arg1 string) (*model.BoardsAndBlocks, []*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateBoardsAndBlocksWithAdmin", arg0, arg1) ret0, _ := ret[0].(*model.BoardsAndBlocks) ret1, _ := ret[1].([]*model.BoardMember) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // CreateBoardsAndBlocksWithAdmin indicates an expected call of CreateBoardsAndBlocksWithAdmin. func (mr *MockStoreMockRecorder) CreateBoardsAndBlocksWithAdmin(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBoardsAndBlocksWithAdmin", reflect.TypeOf((*MockStore)(nil).CreateBoardsAndBlocksWithAdmin), arg0, arg1) } // CreateCategory mocks base method. func (m *MockStore) CreateCategory(arg0 model.Category) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateCategory", arg0) ret0, _ := ret[0].(error) return ret0 } // CreateCategory indicates an expected call of CreateCategory. func (mr *MockStoreMockRecorder) CreateCategory(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCategory", reflect.TypeOf((*MockStore)(nil).CreateCategory), arg0) } // CreateSession mocks base method. func (m *MockStore) CreateSession(arg0 *model.Session) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateSession", arg0) ret0, _ := ret[0].(error) return ret0 } // CreateSession indicates an expected call of CreateSession. func (mr *MockStoreMockRecorder) CreateSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockStore)(nil).CreateSession), arg0) } // CreateSubscription mocks base method. func (m *MockStore) CreateSubscription(arg0 *model.Subscription) (*model.Subscription, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateSubscription", arg0) ret0, _ := ret[0].(*model.Subscription) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateSubscription indicates an expected call of CreateSubscription. func (mr *MockStoreMockRecorder) CreateSubscription(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSubscription", reflect.TypeOf((*MockStore)(nil).CreateSubscription), arg0) } // CreateUser mocks base method. func (m *MockStore) CreateUser(arg0 *model.User) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateUser indicates an expected call of CreateUser. func (mr *MockStoreMockRecorder) CreateUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockStore)(nil).CreateUser), arg0) } // DBType mocks base method. func (m *MockStore) DBType() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DBType") ret0, _ := ret[0].(string) return ret0 } // DBType indicates an expected call of DBType. func (mr *MockStoreMockRecorder) DBType() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DBType", reflect.TypeOf((*MockStore)(nil).DBType)) } // DBVersion mocks base method. func (m *MockStore) DBVersion() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DBVersion") ret0, _ := ret[0].(string) return ret0 } // DBVersion indicates an expected call of DBVersion. func (mr *MockStoreMockRecorder) DBVersion() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DBVersion", reflect.TypeOf((*MockStore)(nil).DBVersion)) } // DeleteBlock mocks base method. func (m *MockStore) DeleteBlock(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteBlock", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeleteBlock indicates an expected call of DeleteBlock. func (mr *MockStoreMockRecorder) DeleteBlock(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBlock", reflect.TypeOf((*MockStore)(nil).DeleteBlock), arg0, arg1) } // DeleteBlockRecord mocks base method. func (m *MockStore) DeleteBlockRecord(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteBlockRecord", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeleteBlockRecord indicates an expected call of DeleteBlockRecord. func (mr *MockStoreMockRecorder) DeleteBlockRecord(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBlockRecord", reflect.TypeOf((*MockStore)(nil).DeleteBlockRecord), arg0, arg1) } // DeleteBoard mocks base method. func (m *MockStore) DeleteBoard(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteBoard", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeleteBoard indicates an expected call of DeleteBoard. func (mr *MockStoreMockRecorder) DeleteBoard(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBoard", reflect.TypeOf((*MockStore)(nil).DeleteBoard), arg0, arg1) } // DeleteBoardRecord mocks base method. func (m *MockStore) DeleteBoardRecord(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteBoardRecord", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeleteBoardRecord indicates an expected call of DeleteBoardRecord. func (mr *MockStoreMockRecorder) DeleteBoardRecord(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBoardRecord", reflect.TypeOf((*MockStore)(nil).DeleteBoardRecord), arg0, arg1) } // DeleteBoardsAndBlocks mocks base method. func (m *MockStore) DeleteBoardsAndBlocks(arg0 *model.DeleteBoardsAndBlocks, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteBoardsAndBlocks", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeleteBoardsAndBlocks indicates an expected call of DeleteBoardsAndBlocks. func (mr *MockStoreMockRecorder) DeleteBoardsAndBlocks(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBoardsAndBlocks", reflect.TypeOf((*MockStore)(nil).DeleteBoardsAndBlocks), arg0, arg1) } // DeleteCategory mocks base method. func (m *MockStore) DeleteCategory(arg0, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteCategory", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // DeleteCategory indicates an expected call of DeleteCategory. func (mr *MockStoreMockRecorder) DeleteCategory(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCategory", reflect.TypeOf((*MockStore)(nil).DeleteCategory), arg0, arg1, arg2) } // DeleteMember mocks base method. func (m *MockStore) DeleteMember(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteMember", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeleteMember indicates an expected call of DeleteMember. func (mr *MockStoreMockRecorder) DeleteMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMember", reflect.TypeOf((*MockStore)(nil).DeleteMember), arg0, arg1) } // DeleteNotificationHint mocks base method. func (m *MockStore) DeleteNotificationHint(arg0 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteNotificationHint", arg0) ret0, _ := ret[0].(error) return ret0 } // DeleteNotificationHint indicates an expected call of DeleteNotificationHint. func (mr *MockStoreMockRecorder) DeleteNotificationHint(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNotificationHint", reflect.TypeOf((*MockStore)(nil).DeleteNotificationHint), arg0) } // DeleteSession mocks base method. func (m *MockStore) DeleteSession(arg0 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteSession", arg0) ret0, _ := ret[0].(error) return ret0 } // DeleteSession indicates an expected call of DeleteSession. func (mr *MockStoreMockRecorder) DeleteSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSession", reflect.TypeOf((*MockStore)(nil).DeleteSession), arg0) } // DeleteSubscription mocks base method. func (m *MockStore) DeleteSubscription(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteSubscription", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // DeleteSubscription indicates an expected call of DeleteSubscription. func (mr *MockStoreMockRecorder) DeleteSubscription(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSubscription", reflect.TypeOf((*MockStore)(nil).DeleteSubscription), arg0, arg1) } // DuplicateBlock mocks base method. func (m *MockStore) DuplicateBlock(arg0, arg1, arg2 string, arg3 bool) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DuplicateBlock", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // DuplicateBlock indicates an expected call of DuplicateBlock. func (mr *MockStoreMockRecorder) DuplicateBlock(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DuplicateBlock", reflect.TypeOf((*MockStore)(nil).DuplicateBlock), arg0, arg1, arg2, arg3) } // DuplicateBoard mocks base method. func (m *MockStore) DuplicateBoard(arg0, arg1, arg2 string, arg3 bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DuplicateBoard", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*model.BoardsAndBlocks) ret1, _ := ret[1].([]*model.BoardMember) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // DuplicateBoard indicates an expected call of DuplicateBoard. func (mr *MockStoreMockRecorder) DuplicateBoard(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DuplicateBoard", reflect.TypeOf((*MockStore)(nil).DuplicateBoard), arg0, arg1, arg2, arg3) } // GetActiveUserCount mocks base method. func (m *MockStore) GetActiveUserCount(arg0 int64) (int, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetActiveUserCount", arg0) ret0, _ := ret[0].(int) ret1, _ := ret[1].(error) return ret0, ret1 } // GetActiveUserCount indicates an expected call of GetActiveUserCount. func (mr *MockStoreMockRecorder) GetActiveUserCount(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveUserCount", reflect.TypeOf((*MockStore)(nil).GetActiveUserCount), arg0) } // GetAllTeams mocks base method. func (m *MockStore) GetAllTeams() ([]*model.Team, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllTeams") ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAllTeams indicates an expected call of GetAllTeams. func (mr *MockStoreMockRecorder) GetAllTeams() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTeams", reflect.TypeOf((*MockStore)(nil).GetAllTeams)) } // GetBlock mocks base method. func (m *MockStore) GetBlock(arg0 string) (*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlock", arg0) ret0, _ := ret[0].(*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlock indicates an expected call of GetBlock. func (mr *MockStoreMockRecorder) GetBlock(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlock", reflect.TypeOf((*MockStore)(nil).GetBlock), arg0) } // GetBlockCountsByType mocks base method. func (m *MockStore) GetBlockCountsByType() (map[string]int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlockCountsByType") ret0, _ := ret[0].(map[string]int64) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlockCountsByType indicates an expected call of GetBlockCountsByType. func (mr *MockStoreMockRecorder) GetBlockCountsByType() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockCountsByType", reflect.TypeOf((*MockStore)(nil).GetBlockCountsByType)) } // GetBlockHistory mocks base method. func (m *MockStore) GetBlockHistory(arg0 string, arg1 model.QueryBlockHistoryOptions) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlockHistory", arg0, arg1) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlockHistory indicates an expected call of GetBlockHistory. func (mr *MockStoreMockRecorder) GetBlockHistory(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockHistory", reflect.TypeOf((*MockStore)(nil).GetBlockHistory), arg0, arg1) } // GetBlockHistoryDescendants mocks base method. func (m *MockStore) GetBlockHistoryDescendants(arg0 string, arg1 model.QueryBlockHistoryOptions) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlockHistoryDescendants", arg0, arg1) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlockHistoryDescendants indicates an expected call of GetBlockHistoryDescendants. func (mr *MockStoreMockRecorder) GetBlockHistoryDescendants(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockHistoryDescendants", reflect.TypeOf((*MockStore)(nil).GetBlockHistoryDescendants), arg0, arg1) } // GetBlockHistoryNewestChildren mocks base method. func (m *MockStore) GetBlockHistoryNewestChildren(arg0 string, arg1 model.QueryBlockHistoryChildOptions) ([]*model.Block, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlockHistoryNewestChildren", arg0, arg1) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // GetBlockHistoryNewestChildren indicates an expected call of GetBlockHistoryNewestChildren. func (mr *MockStoreMockRecorder) GetBlockHistoryNewestChildren(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockHistoryNewestChildren", reflect.TypeOf((*MockStore)(nil).GetBlockHistoryNewestChildren), arg0, arg1) } // GetBlocks mocks base method. func (m *MockStore) GetBlocks(arg0 model.QueryBlocksOptions) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlocks", arg0) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlocks indicates an expected call of GetBlocks. func (mr *MockStoreMockRecorder) GetBlocks(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocks", reflect.TypeOf((*MockStore)(nil).GetBlocks), arg0) } // GetBlocksByIDs mocks base method. func (m *MockStore) GetBlocksByIDs(arg0 []string) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlocksByIDs", arg0) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlocksByIDs indicates an expected call of GetBlocksByIDs. func (mr *MockStoreMockRecorder) GetBlocksByIDs(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksByIDs", reflect.TypeOf((*MockStore)(nil).GetBlocksByIDs), arg0) } // GetBlocksComplianceHistory mocks base method. func (m *MockStore) GetBlocksComplianceHistory(arg0 model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlocksComplianceHistory", arg0) ret0, _ := ret[0].([]*model.BlockHistory) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // GetBlocksComplianceHistory indicates an expected call of GetBlocksComplianceHistory. func (mr *MockStoreMockRecorder) GetBlocksComplianceHistory(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksComplianceHistory", reflect.TypeOf((*MockStore)(nil).GetBlocksComplianceHistory), arg0) } // GetBlocksForBoard mocks base method. func (m *MockStore) GetBlocksForBoard(arg0 string) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlocksForBoard", arg0) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlocksForBoard indicates an expected call of GetBlocksForBoard. func (mr *MockStoreMockRecorder) GetBlocksForBoard(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksForBoard", reflect.TypeOf((*MockStore)(nil).GetBlocksForBoard), arg0) } // GetBlocksWithParent mocks base method. func (m *MockStore) GetBlocksWithParent(arg0, arg1 string) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlocksWithParent", arg0, arg1) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlocksWithParent indicates an expected call of GetBlocksWithParent. func (mr *MockStoreMockRecorder) GetBlocksWithParent(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksWithParent", reflect.TypeOf((*MockStore)(nil).GetBlocksWithParent), arg0, arg1) } // GetBlocksWithParentAndType mocks base method. func (m *MockStore) GetBlocksWithParentAndType(arg0, arg1, arg2 string) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlocksWithParentAndType", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlocksWithParentAndType indicates an expected call of GetBlocksWithParentAndType. func (mr *MockStoreMockRecorder) GetBlocksWithParentAndType(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksWithParentAndType", reflect.TypeOf((*MockStore)(nil).GetBlocksWithParentAndType), arg0, arg1, arg2) } // GetBlocksWithType mocks base method. func (m *MockStore) GetBlocksWithType(arg0, arg1 string) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlocksWithType", arg0, arg1) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlocksWithType indicates an expected call of GetBlocksWithType. func (mr *MockStoreMockRecorder) GetBlocksWithType(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksWithType", reflect.TypeOf((*MockStore)(nil).GetBlocksWithType), arg0, arg1) } // GetBoard mocks base method. func (m *MockStore) GetBoard(arg0 string) (*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoard", arg0) ret0, _ := ret[0].(*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoard indicates an expected call of GetBoard. func (mr *MockStoreMockRecorder) GetBoard(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoard", reflect.TypeOf((*MockStore)(nil).GetBoard), arg0) } // GetBoardAndCard mocks base method. func (m *MockStore) GetBoardAndCard(arg0 *model.Block) (*model.Board, *model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardAndCard", arg0) ret0, _ := ret[0].(*model.Board) ret1, _ := ret[1].(*model.Block) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // GetBoardAndCard indicates an expected call of GetBoardAndCard. func (mr *MockStoreMockRecorder) GetBoardAndCard(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardAndCard", reflect.TypeOf((*MockStore)(nil).GetBoardAndCard), arg0) } // GetBoardAndCardByID mocks base method. func (m *MockStore) GetBoardAndCardByID(arg0 string) (*model.Board, *model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardAndCardByID", arg0) ret0, _ := ret[0].(*model.Board) ret1, _ := ret[1].(*model.Block) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // GetBoardAndCardByID indicates an expected call of GetBoardAndCardByID. func (mr *MockStoreMockRecorder) GetBoardAndCardByID(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardAndCardByID", reflect.TypeOf((*MockStore)(nil).GetBoardAndCardByID), arg0) } // GetBoardCount mocks base method. func (m *MockStore) GetBoardCount() (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardCount") ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoardCount indicates an expected call of GetBoardCount. func (mr *MockStoreMockRecorder) GetBoardCount() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardCount", reflect.TypeOf((*MockStore)(nil).GetBoardCount)) } // GetBoardHistory mocks base method. func (m *MockStore) GetBoardHistory(arg0 string, arg1 model.QueryBoardHistoryOptions) ([]*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardHistory", arg0, arg1) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoardHistory indicates an expected call of GetBoardHistory. func (mr *MockStoreMockRecorder) GetBoardHistory(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardHistory", reflect.TypeOf((*MockStore)(nil).GetBoardHistory), arg0, arg1) } // GetBoardMemberHistory mocks base method. func (m *MockStore) GetBoardMemberHistory(arg0, arg1 string, arg2 uint64) ([]*model.BoardMemberHistoryEntry, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardMemberHistory", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.BoardMemberHistoryEntry) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoardMemberHistory indicates an expected call of GetBoardMemberHistory. func (mr *MockStoreMockRecorder) GetBoardMemberHistory(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardMemberHistory", reflect.TypeOf((*MockStore)(nil).GetBoardMemberHistory), arg0, arg1, arg2) } // GetBoardsComplianceHistory mocks base method. func (m *MockStore) GetBoardsComplianceHistory(arg0 model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardsComplianceHistory", arg0) ret0, _ := ret[0].([]*model.BoardHistory) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // GetBoardsComplianceHistory indicates an expected call of GetBoardsComplianceHistory. func (mr *MockStoreMockRecorder) GetBoardsComplianceHistory(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsComplianceHistory", reflect.TypeOf((*MockStore)(nil).GetBoardsComplianceHistory), arg0) } // GetBoardsForCompliance mocks base method. func (m *MockStore) GetBoardsForCompliance(arg0 model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardsForCompliance", arg0) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // GetBoardsForCompliance indicates an expected call of GetBoardsForCompliance. func (mr *MockStoreMockRecorder) GetBoardsForCompliance(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsForCompliance", reflect.TypeOf((*MockStore)(nil).GetBoardsForCompliance), arg0) } // GetBoardsForUserAndTeam mocks base method. func (m *MockStore) GetBoardsForUserAndTeam(arg0, arg1 string, arg2 bool) ([]*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardsForUserAndTeam", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoardsForUserAndTeam indicates an expected call of GetBoardsForUserAndTeam. func (mr *MockStoreMockRecorder) GetBoardsForUserAndTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsForUserAndTeam", reflect.TypeOf((*MockStore)(nil).GetBoardsForUserAndTeam), arg0, arg1, arg2) } // GetBoardsInTeamByIds mocks base method. func (m *MockStore) GetBoardsInTeamByIds(arg0 []string, arg1 string) ([]*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoardsInTeamByIds", arg0, arg1) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBoardsInTeamByIds indicates an expected call of GetBoardsInTeamByIds. func (mr *MockStoreMockRecorder) GetBoardsInTeamByIds(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsInTeamByIds", reflect.TypeOf((*MockStore)(nil).GetBoardsInTeamByIds), arg0, arg1) } // GetCardLimitTimestamp mocks base method. func (m *MockStore) GetCardLimitTimestamp() (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetCardLimitTimestamp") ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // GetCardLimitTimestamp indicates an expected call of GetCardLimitTimestamp. func (mr *MockStoreMockRecorder) GetCardLimitTimestamp() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCardLimitTimestamp", reflect.TypeOf((*MockStore)(nil).GetCardLimitTimestamp)) } // GetCategory mocks base method. func (m *MockStore) GetCategory(arg0 string) (*model.Category, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetCategory", arg0) ret0, _ := ret[0].(*model.Category) ret1, _ := ret[1].(error) return ret0, ret1 } // GetCategory indicates an expected call of GetCategory. func (mr *MockStoreMockRecorder) GetCategory(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCategory", reflect.TypeOf((*MockStore)(nil).GetCategory), arg0) } // GetChannel mocks base method. func (m *MockStore) GetChannel(arg0, arg1 string) (*model0.Channel, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannel", arg0, arg1) ret0, _ := ret[0].(*model0.Channel) ret1, _ := ret[1].(error) return ret0, ret1 } // GetChannel indicates an expected call of GetChannel. func (mr *MockStoreMockRecorder) GetChannel(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannel", reflect.TypeOf((*MockStore)(nil).GetChannel), arg0, arg1) } // GetFileInfo mocks base method. func (m *MockStore) GetFileInfo(arg0 string) (*model0.FileInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileInfo", arg0) ret0, _ := ret[0].(*model0.FileInfo) ret1, _ := ret[1].(error) return ret0, ret1 } // GetFileInfo indicates an expected call of GetFileInfo. func (mr *MockStoreMockRecorder) GetFileInfo(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileInfo", reflect.TypeOf((*MockStore)(nil).GetFileInfo), arg0) } // GetLicense mocks base method. func (m *MockStore) GetLicense() *model0.License { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLicense") ret0, _ := ret[0].(*model0.License) return ret0 } // GetLicense indicates an expected call of GetLicense. func (mr *MockStoreMockRecorder) GetLicense() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLicense", reflect.TypeOf((*MockStore)(nil).GetLicense)) } // GetMemberForBoard mocks base method. func (m *MockStore) GetMemberForBoard(arg0, arg1 string) (*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetMemberForBoard", arg0, arg1) ret0, _ := ret[0].(*model.BoardMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetMemberForBoard indicates an expected call of GetMemberForBoard. func (mr *MockStoreMockRecorder) GetMemberForBoard(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMemberForBoard", reflect.TypeOf((*MockStore)(nil).GetMemberForBoard), arg0, arg1) } // GetMembersForBoard mocks base method. func (m *MockStore) GetMembersForBoard(arg0 string) ([]*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetMembersForBoard", arg0) ret0, _ := ret[0].([]*model.BoardMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetMembersForBoard indicates an expected call of GetMembersForBoard. func (mr *MockStoreMockRecorder) GetMembersForBoard(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMembersForBoard", reflect.TypeOf((*MockStore)(nil).GetMembersForBoard), arg0) } // GetMembersForUser mocks base method. func (m *MockStore) GetMembersForUser(arg0 string) ([]*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetMembersForUser", arg0) ret0, _ := ret[0].([]*model.BoardMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetMembersForUser indicates an expected call of GetMembersForUser. func (mr *MockStoreMockRecorder) GetMembersForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMembersForUser", reflect.TypeOf((*MockStore)(nil).GetMembersForUser), arg0) } // GetNextNotificationHint mocks base method. func (m *MockStore) GetNextNotificationHint(arg0 bool) (*model.NotificationHint, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNextNotificationHint", arg0) ret0, _ := ret[0].(*model.NotificationHint) ret1, _ := ret[1].(error) return ret0, ret1 } // GetNextNotificationHint indicates an expected call of GetNextNotificationHint. func (mr *MockStoreMockRecorder) GetNextNotificationHint(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNextNotificationHint", reflect.TypeOf((*MockStore)(nil).GetNextNotificationHint), arg0) } // GetNotificationHint mocks base method. func (m *MockStore) GetNotificationHint(arg0 string) (*model.NotificationHint, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNotificationHint", arg0) ret0, _ := ret[0].(*model.NotificationHint) ret1, _ := ret[1].(error) return ret0, ret1 } // GetNotificationHint indicates an expected call of GetNotificationHint. func (mr *MockStoreMockRecorder) GetNotificationHint(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationHint", reflect.TypeOf((*MockStore)(nil).GetNotificationHint), arg0) } // GetRegisteredUserCount mocks base method. func (m *MockStore) GetRegisteredUserCount() (int, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetRegisteredUserCount") ret0, _ := ret[0].(int) ret1, _ := ret[1].(error) return ret0, ret1 } // GetRegisteredUserCount indicates an expected call of GetRegisteredUserCount. func (mr *MockStoreMockRecorder) GetRegisteredUserCount() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRegisteredUserCount", reflect.TypeOf((*MockStore)(nil).GetRegisteredUserCount)) } // GetSession mocks base method. func (m *MockStore) GetSession(arg0 string, arg1 int64) (*model.Session, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSession", arg0, arg1) ret0, _ := ret[0].(*model.Session) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSession indicates an expected call of GetSession. func (mr *MockStoreMockRecorder) GetSession(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSession", reflect.TypeOf((*MockStore)(nil).GetSession), arg0, arg1) } // GetSharing mocks base method. func (m *MockStore) GetSharing(arg0 string) (*model.Sharing, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSharing", arg0) ret0, _ := ret[0].(*model.Sharing) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSharing indicates an expected call of GetSharing. func (mr *MockStoreMockRecorder) GetSharing(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSharing", reflect.TypeOf((*MockStore)(nil).GetSharing), arg0) } // GetSubTree2 mocks base method. func (m *MockStore) GetSubTree2(arg0, arg1 string, arg2 model.QuerySubtreeOptions) ([]*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSubTree2", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSubTree2 indicates an expected call of GetSubTree2. func (mr *MockStoreMockRecorder) GetSubTree2(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree2", reflect.TypeOf((*MockStore)(nil).GetSubTree2), arg0, arg1, arg2) } // GetSubscribersCountForBlock mocks base method. func (m *MockStore) GetSubscribersCountForBlock(arg0 string) (int, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSubscribersCountForBlock", arg0) ret0, _ := ret[0].(int) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSubscribersCountForBlock indicates an expected call of GetSubscribersCountForBlock. func (mr *MockStoreMockRecorder) GetSubscribersCountForBlock(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscribersCountForBlock", reflect.TypeOf((*MockStore)(nil).GetSubscribersCountForBlock), arg0) } // GetSubscribersForBlock mocks base method. func (m *MockStore) GetSubscribersForBlock(arg0 string) ([]*model.Subscriber, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSubscribersForBlock", arg0) ret0, _ := ret[0].([]*model.Subscriber) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSubscribersForBlock indicates an expected call of GetSubscribersForBlock. func (mr *MockStoreMockRecorder) GetSubscribersForBlock(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscribersForBlock", reflect.TypeOf((*MockStore)(nil).GetSubscribersForBlock), arg0) } // GetSubscription mocks base method. func (m *MockStore) GetSubscription(arg0, arg1 string) (*model.Subscription, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSubscription", arg0, arg1) ret0, _ := ret[0].(*model.Subscription) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSubscription indicates an expected call of GetSubscription. func (mr *MockStoreMockRecorder) GetSubscription(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscription", reflect.TypeOf((*MockStore)(nil).GetSubscription), arg0, arg1) } // GetSubscriptions mocks base method. func (m *MockStore) GetSubscriptions(arg0 string) ([]*model.Subscription, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSubscriptions", arg0) ret0, _ := ret[0].([]*model.Subscription) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSubscriptions indicates an expected call of GetSubscriptions. func (mr *MockStoreMockRecorder) GetSubscriptions(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscriptions", reflect.TypeOf((*MockStore)(nil).GetSubscriptions), arg0) } // GetSystemSetting mocks base method. func (m *MockStore) GetSystemSetting(arg0 string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSystemSetting", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSystemSetting indicates an expected call of GetSystemSetting. func (mr *MockStoreMockRecorder) GetSystemSetting(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemSetting", reflect.TypeOf((*MockStore)(nil).GetSystemSetting), arg0) } // GetSystemSettings mocks base method. func (m *MockStore) GetSystemSettings() (map[string]string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSystemSettings") ret0, _ := ret[0].(map[string]string) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSystemSettings indicates an expected call of GetSystemSettings. func (mr *MockStoreMockRecorder) GetSystemSettings() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemSettings", reflect.TypeOf((*MockStore)(nil).GetSystemSettings)) } // GetTeam mocks base method. func (m *MockStore) GetTeam(arg0 string) (*model.Team, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeam", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTeam indicates an expected call of GetTeam. func (mr *MockStoreMockRecorder) GetTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeam", reflect.TypeOf((*MockStore)(nil).GetTeam), arg0) } // GetTeamCount mocks base method. func (m *MockStore) GetTeamCount() (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamCount") ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTeamCount indicates an expected call of GetTeamCount. func (mr *MockStoreMockRecorder) GetTeamCount() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamCount", reflect.TypeOf((*MockStore)(nil).GetTeamCount)) } // GetTeamsForUser mocks base method. func (m *MockStore) GetTeamsForUser(arg0 string) ([]*model.Team, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamsForUser", arg0) ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTeamsForUser indicates an expected call of GetTeamsForUser. func (mr *MockStoreMockRecorder) GetTeamsForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamsForUser", reflect.TypeOf((*MockStore)(nil).GetTeamsForUser), arg0) } // GetTemplateBoards mocks base method. func (m *MockStore) GetTemplateBoards(arg0, arg1 string) ([]*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTemplateBoards", arg0, arg1) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTemplateBoards indicates an expected call of GetTemplateBoards. func (mr *MockStoreMockRecorder) GetTemplateBoards(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateBoards", reflect.TypeOf((*MockStore)(nil).GetTemplateBoards), arg0, arg1) } // GetUsedCardsCount mocks base method. func (m *MockStore) GetUsedCardsCount() (int, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsedCardsCount") ret0, _ := ret[0].(int) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUsedCardsCount indicates an expected call of GetUsedCardsCount. func (mr *MockStoreMockRecorder) GetUsedCardsCount() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsedCardsCount", reflect.TypeOf((*MockStore)(nil).GetUsedCardsCount)) } // GetUserByEmail mocks base method. func (m *MockStore) GetUserByEmail(arg0 string) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByEmail", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserByEmail indicates an expected call of GetUserByEmail. func (mr *MockStoreMockRecorder) GetUserByEmail(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockStore)(nil).GetUserByEmail), arg0) } // GetUserByID mocks base method. func (m *MockStore) GetUserByID(arg0 string) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByID", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserByID indicates an expected call of GetUserByID. func (mr *MockStoreMockRecorder) GetUserByID(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByID", reflect.TypeOf((*MockStore)(nil).GetUserByID), arg0) } // GetUserByUsername mocks base method. func (m *MockStore) GetUserByUsername(arg0 string) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByUsername", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserByUsername indicates an expected call of GetUserByUsername. func (mr *MockStoreMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockStore)(nil).GetUserByUsername), arg0) } // GetUserCategories mocks base method. func (m *MockStore) GetUserCategories(arg0, arg1 string) ([]model.Category, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserCategories", arg0, arg1) ret0, _ := ret[0].([]model.Category) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserCategories indicates an expected call of GetUserCategories. func (mr *MockStoreMockRecorder) GetUserCategories(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCategories", reflect.TypeOf((*MockStore)(nil).GetUserCategories), arg0, arg1) } // GetUserCategoryBoards mocks base method. func (m *MockStore) GetUserCategoryBoards(arg0, arg1 string) ([]model.CategoryBoards, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserCategoryBoards", arg0, arg1) ret0, _ := ret[0].([]model.CategoryBoards) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserCategoryBoards indicates an expected call of GetUserCategoryBoards. func (mr *MockStoreMockRecorder) GetUserCategoryBoards(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCategoryBoards", reflect.TypeOf((*MockStore)(nil).GetUserCategoryBoards), arg0, arg1) } // GetUserPreferences mocks base method. func (m *MockStore) GetUserPreferences(arg0 string) (model0.Preferences, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserPreferences", arg0) ret0, _ := ret[0].(model0.Preferences) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserPreferences indicates an expected call of GetUserPreferences. func (mr *MockStoreMockRecorder) GetUserPreferences(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPreferences", reflect.TypeOf((*MockStore)(nil).GetUserPreferences), arg0) } // GetUserTimezone mocks base method. func (m *MockStore) GetUserTimezone(arg0 string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserTimezone", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUserTimezone indicates an expected call of GetUserTimezone. func (mr *MockStoreMockRecorder) GetUserTimezone(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTimezone", reflect.TypeOf((*MockStore)(nil).GetUserTimezone), arg0) } // GetUsersByTeam mocks base method. func (m *MockStore) GetUsersByTeam(arg0, arg1 string, arg2, arg3 bool) ([]*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersByTeam", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUsersByTeam indicates an expected call of GetUsersByTeam. func (mr *MockStoreMockRecorder) GetUsersByTeam(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByTeam", reflect.TypeOf((*MockStore)(nil).GetUsersByTeam), arg0, arg1, arg2, arg3) } // GetUsersList mocks base method. func (m *MockStore) GetUsersList(arg0 []string, arg1, arg2 bool) ([]*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersList", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUsersList indicates an expected call of GetUsersList. func (mr *MockStoreMockRecorder) GetUsersList(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersList", reflect.TypeOf((*MockStore)(nil).GetUsersList), arg0, arg1, arg2) } // InsertBlock mocks base method. func (m *MockStore) InsertBlock(arg0 *model.Block, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertBlock", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // InsertBlock indicates an expected call of InsertBlock. func (mr *MockStoreMockRecorder) InsertBlock(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBlock", reflect.TypeOf((*MockStore)(nil).InsertBlock), arg0, arg1) } // InsertBlocks mocks base method. func (m *MockStore) InsertBlocks(arg0 []*model.Block, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertBlocks", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // InsertBlocks indicates an expected call of InsertBlocks. func (mr *MockStoreMockRecorder) InsertBlocks(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBlocks", reflect.TypeOf((*MockStore)(nil).InsertBlocks), arg0, arg1) } // InsertBoard mocks base method. func (m *MockStore) InsertBoard(arg0 *model.Board, arg1 string) (*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertBoard", arg0, arg1) ret0, _ := ret[0].(*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // InsertBoard indicates an expected call of InsertBoard. func (mr *MockStoreMockRecorder) InsertBoard(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoard", reflect.TypeOf((*MockStore)(nil).InsertBoard), arg0, arg1) } // InsertBoardWithAdmin mocks base method. func (m *MockStore) InsertBoardWithAdmin(arg0 *model.Board, arg1 string) (*model.Board, *model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertBoardWithAdmin", arg0, arg1) ret0, _ := ret[0].(*model.Board) ret1, _ := ret[1].(*model.BoardMember) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // InsertBoardWithAdmin indicates an expected call of InsertBoardWithAdmin. func (mr *MockStoreMockRecorder) InsertBoardWithAdmin(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoardWithAdmin", reflect.TypeOf((*MockStore)(nil).InsertBoardWithAdmin), arg0, arg1) } // PatchBlock mocks base method. func (m *MockStore) PatchBlock(arg0 string, arg1 *model.BlockPatch, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PatchBlock", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // PatchBlock indicates an expected call of PatchBlock. func (mr *MockStoreMockRecorder) PatchBlock(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBlock", reflect.TypeOf((*MockStore)(nil).PatchBlock), arg0, arg1, arg2) } // PatchBlocks mocks base method. func (m *MockStore) PatchBlocks(arg0 *model.BlockPatchBatch, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PatchBlocks", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // PatchBlocks indicates an expected call of PatchBlocks. func (mr *MockStoreMockRecorder) PatchBlocks(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBlocks", reflect.TypeOf((*MockStore)(nil).PatchBlocks), arg0, arg1) } // PatchBoard mocks base method. func (m *MockStore) PatchBoard(arg0 string, arg1 *model.BoardPatch, arg2 string) (*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PatchBoard", arg0, arg1, arg2) ret0, _ := ret[0].(*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // PatchBoard indicates an expected call of PatchBoard. func (mr *MockStoreMockRecorder) PatchBoard(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBoard", reflect.TypeOf((*MockStore)(nil).PatchBoard), arg0, arg1, arg2) } // PatchBoardsAndBlocks mocks base method. func (m *MockStore) PatchBoardsAndBlocks(arg0 *model.PatchBoardsAndBlocks, arg1 string) (*model.BoardsAndBlocks, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PatchBoardsAndBlocks", arg0, arg1) ret0, _ := ret[0].(*model.BoardsAndBlocks) ret1, _ := ret[1].(error) return ret0, ret1 } // PatchBoardsAndBlocks indicates an expected call of PatchBoardsAndBlocks. func (mr *MockStoreMockRecorder) PatchBoardsAndBlocks(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBoardsAndBlocks", reflect.TypeOf((*MockStore)(nil).PatchBoardsAndBlocks), arg0, arg1) } // PatchUserPreferences mocks base method. func (m *MockStore) PatchUserPreferences(arg0 string, arg1 model.UserPreferencesPatch) (model0.Preferences, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PatchUserPreferences", arg0, arg1) ret0, _ := ret[0].(model0.Preferences) ret1, _ := ret[1].(error) return ret0, ret1 } // PatchUserPreferences indicates an expected call of PatchUserPreferences. func (mr *MockStoreMockRecorder) PatchUserPreferences(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchUserPreferences", reflect.TypeOf((*MockStore)(nil).PatchUserPreferences), arg0, arg1) } // PostMessage mocks base method. func (m *MockStore) PostMessage(arg0, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PostMessage", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // PostMessage indicates an expected call of PostMessage. func (mr *MockStoreMockRecorder) PostMessage(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostMessage", reflect.TypeOf((*MockStore)(nil).PostMessage), arg0, arg1, arg2) } // RefreshSession mocks base method. func (m *MockStore) RefreshSession(arg0 *model.Session) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RefreshSession", arg0) ret0, _ := ret[0].(error) return ret0 } // RefreshSession indicates an expected call of RefreshSession. func (mr *MockStoreMockRecorder) RefreshSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RefreshSession", reflect.TypeOf((*MockStore)(nil).RefreshSession), arg0) } // RemoveDefaultTemplates mocks base method. func (m *MockStore) RemoveDefaultTemplates(arg0 []*model.Board) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveDefaultTemplates", arg0) ret0, _ := ret[0].(error) return ret0 } // RemoveDefaultTemplates indicates an expected call of RemoveDefaultTemplates. func (mr *MockStoreMockRecorder) RemoveDefaultTemplates(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveDefaultTemplates", reflect.TypeOf((*MockStore)(nil).RemoveDefaultTemplates), arg0) } // ReorderCategories mocks base method. func (m *MockStore) ReorderCategories(arg0, arg1 string, arg2 []string) ([]string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ReorderCategories", arg0, arg1, arg2) ret0, _ := ret[0].([]string) ret1, _ := ret[1].(error) return ret0, ret1 } // ReorderCategories indicates an expected call of ReorderCategories. func (mr *MockStoreMockRecorder) ReorderCategories(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderCategories", reflect.TypeOf((*MockStore)(nil).ReorderCategories), arg0, arg1, arg2) } // ReorderCategoryBoards mocks base method. func (m *MockStore) ReorderCategoryBoards(arg0 string, arg1 []string) ([]string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ReorderCategoryBoards", arg0, arg1) ret0, _ := ret[0].([]string) ret1, _ := ret[1].(error) return ret0, ret1 } // ReorderCategoryBoards indicates an expected call of ReorderCategoryBoards. func (mr *MockStoreMockRecorder) ReorderCategoryBoards(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderCategoryBoards", reflect.TypeOf((*MockStore)(nil).ReorderCategoryBoards), arg0, arg1) } // RunDataRetention mocks base method. func (m *MockStore) RunDataRetention(arg0, arg1 int64) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RunDataRetention", arg0, arg1) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // RunDataRetention indicates an expected call of RunDataRetention. func (mr *MockStoreMockRecorder) RunDataRetention(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunDataRetention", reflect.TypeOf((*MockStore)(nil).RunDataRetention), arg0, arg1) } // SaveFileInfo mocks base method. func (m *MockStore) SaveFileInfo(arg0 *model0.FileInfo) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveFileInfo", arg0) ret0, _ := ret[0].(error) return ret0 } // SaveFileInfo indicates an expected call of SaveFileInfo. func (mr *MockStoreMockRecorder) SaveFileInfo(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveFileInfo", reflect.TypeOf((*MockStore)(nil).SaveFileInfo), arg0) } // SaveMember mocks base method. func (m *MockStore) SaveMember(arg0 *model.BoardMember) (*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveMember", arg0) ret0, _ := ret[0].(*model.BoardMember) ret1, _ := ret[1].(error) return ret0, ret1 } // SaveMember indicates an expected call of SaveMember. func (mr *MockStoreMockRecorder) SaveMember(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveMember", reflect.TypeOf((*MockStore)(nil).SaveMember), arg0) } // SearchBoardsForUser mocks base method. func (m *MockStore) SearchBoardsForUser(arg0 string, arg1 model.BoardSearchField, arg2 string, arg3 bool) ([]*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchBoardsForUser", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchBoardsForUser indicates an expected call of SearchBoardsForUser. func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1, arg2, arg3) } // SearchBoardsForUserInTeam mocks base method. func (m *MockStore) SearchBoardsForUserInTeam(arg0, arg1, arg2 string) ([]*model.Board, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchBoardsForUserInTeam", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchBoardsForUserInTeam indicates an expected call of SearchBoardsForUserInTeam. func (mr *MockStoreMockRecorder) SearchBoardsForUserInTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUserInTeam", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUserInTeam), arg0, arg1, arg2) } // SearchUserChannels mocks base method. func (m *MockStore) SearchUserChannels(arg0, arg1, arg2 string) ([]*model0.Channel, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchUserChannels", arg0, arg1, arg2) ret0, _ := ret[0].([]*model0.Channel) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchUserChannels indicates an expected call of SearchUserChannels. func (mr *MockStoreMockRecorder) SearchUserChannels(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUserChannels", reflect.TypeOf((*MockStore)(nil).SearchUserChannels), arg0, arg1, arg2) } // SearchUsersByTeam mocks base method. func (m *MockStore) SearchUsersByTeam(arg0, arg1, arg2 string, arg3, arg4, arg5 bool) ([]*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchUsersByTeam", arg0, arg1, arg2, arg3, arg4, arg5) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchUsersByTeam indicates an expected call of SearchUsersByTeam. func (mr *MockStoreMockRecorder) SearchUsersByTeam(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUsersByTeam", reflect.TypeOf((*MockStore)(nil).SearchUsersByTeam), arg0, arg1, arg2, arg3, arg4, arg5) } // SendMessage mocks base method. func (m *MockStore) SendMessage(arg0, arg1 string, arg2 []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendMessage", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // SendMessage indicates an expected call of SendMessage. func (mr *MockStoreMockRecorder) SendMessage(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessage", reflect.TypeOf((*MockStore)(nil).SendMessage), arg0, arg1, arg2) } // SetBoardVisibility mocks base method. func (m *MockStore) SetBoardVisibility(arg0, arg1, arg2 string, arg3 bool) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetBoardVisibility", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } // SetBoardVisibility indicates an expected call of SetBoardVisibility. func (mr *MockStoreMockRecorder) SetBoardVisibility(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBoardVisibility", reflect.TypeOf((*MockStore)(nil).SetBoardVisibility), arg0, arg1, arg2, arg3) } // SetSystemSetting mocks base method. func (m *MockStore) SetSystemSetting(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetSystemSetting", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // SetSystemSetting indicates an expected call of SetSystemSetting. func (mr *MockStoreMockRecorder) SetSystemSetting(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSystemSetting", reflect.TypeOf((*MockStore)(nil).SetSystemSetting), arg0, arg1) } // Shutdown mocks base method. func (m *MockStore) Shutdown() error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Shutdown") ret0, _ := ret[0].(error) return ret0 } // Shutdown indicates an expected call of Shutdown. func (mr *MockStoreMockRecorder) Shutdown() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockStore)(nil).Shutdown)) } // UndeleteBlock mocks base method. func (m *MockStore) UndeleteBlock(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UndeleteBlock", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UndeleteBlock indicates an expected call of UndeleteBlock. func (mr *MockStoreMockRecorder) UndeleteBlock(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UndeleteBlock", reflect.TypeOf((*MockStore)(nil).UndeleteBlock), arg0, arg1) } // UndeleteBoard mocks base method. func (m *MockStore) UndeleteBoard(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UndeleteBoard", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UndeleteBoard indicates an expected call of UndeleteBoard. func (mr *MockStoreMockRecorder) UndeleteBoard(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UndeleteBoard", reflect.TypeOf((*MockStore)(nil).UndeleteBoard), arg0, arg1) } // UpdateCardLimitTimestamp mocks base method. func (m *MockStore) UpdateCardLimitTimestamp(arg0 int) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateCardLimitTimestamp", arg0) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateCardLimitTimestamp indicates an expected call of UpdateCardLimitTimestamp. func (mr *MockStoreMockRecorder) UpdateCardLimitTimestamp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCardLimitTimestamp", reflect.TypeOf((*MockStore)(nil).UpdateCardLimitTimestamp), arg0) } // UpdateCategory mocks base method. func (m *MockStore) UpdateCategory(arg0 model.Category) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateCategory", arg0) ret0, _ := ret[0].(error) return ret0 } // UpdateCategory indicates an expected call of UpdateCategory. func (mr *MockStoreMockRecorder) UpdateCategory(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCategory", reflect.TypeOf((*MockStore)(nil).UpdateCategory), arg0) } // UpdateSession mocks base method. func (m *MockStore) UpdateSession(arg0 *model.Session) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateSession", arg0) ret0, _ := ret[0].(error) return ret0 } // UpdateSession indicates an expected call of UpdateSession. func (mr *MockStoreMockRecorder) UpdateSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSession", reflect.TypeOf((*MockStore)(nil).UpdateSession), arg0) } // UpdateSubscribersNotifiedAt mocks base method. func (m *MockStore) UpdateSubscribersNotifiedAt(arg0 string, arg1 int64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateSubscribersNotifiedAt", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UpdateSubscribersNotifiedAt indicates an expected call of UpdateSubscribersNotifiedAt. func (mr *MockStoreMockRecorder) UpdateSubscribersNotifiedAt(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSubscribersNotifiedAt", reflect.TypeOf((*MockStore)(nil).UpdateSubscribersNotifiedAt), arg0, arg1) } // UpdateUser mocks base method. func (m *MockStore) UpdateUser(arg0 *model.User) (*model.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateUser indicates an expected call of UpdateUser. func (mr *MockStoreMockRecorder) UpdateUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockStore)(nil).UpdateUser), arg0) } // UpdateUserPassword mocks base method. func (m *MockStore) UpdateUserPassword(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserPassword", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UpdateUserPassword indicates an expected call of UpdateUserPassword. func (mr *MockStoreMockRecorder) UpdateUserPassword(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPassword", reflect.TypeOf((*MockStore)(nil).UpdateUserPassword), arg0, arg1) } // UpdateUserPasswordByID mocks base method. func (m *MockStore) UpdateUserPasswordByID(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserPasswordByID", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UpdateUserPasswordByID indicates an expected call of UpdateUserPasswordByID. func (mr *MockStoreMockRecorder) UpdateUserPasswordByID(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPasswordByID", reflect.TypeOf((*MockStore)(nil).UpdateUserPasswordByID), arg0, arg1) } // UpsertNotificationHint mocks base method. func (m *MockStore) UpsertNotificationHint(arg0 *model.NotificationHint, arg1 time.Duration) (*model.NotificationHint, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpsertNotificationHint", arg0, arg1) ret0, _ := ret[0].(*model.NotificationHint) ret1, _ := ret[1].(error) return ret0, ret1 } // UpsertNotificationHint indicates an expected call of UpsertNotificationHint. func (mr *MockStoreMockRecorder) UpsertNotificationHint(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationHint", reflect.TypeOf((*MockStore)(nil).UpsertNotificationHint), arg0, arg1) } // UpsertSharing mocks base method. func (m *MockStore) UpsertSharing(arg0 model.Sharing) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpsertSharing", arg0) ret0, _ := ret[0].(error) return ret0 } // UpsertSharing indicates an expected call of UpsertSharing. func (mr *MockStoreMockRecorder) UpsertSharing(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertSharing", reflect.TypeOf((*MockStore)(nil).UpsertSharing), arg0) } // UpsertTeamSettings mocks base method. func (m *MockStore) UpsertTeamSettings(arg0 model.Team) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpsertTeamSettings", arg0) ret0, _ := ret[0].(error) return ret0 } // UpsertTeamSettings indicates an expected call of UpsertTeamSettings. func (mr *MockStoreMockRecorder) UpsertTeamSettings(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTeamSettings", reflect.TypeOf((*MockStore)(nil).UpsertTeamSettings), arg0) } // UpsertTeamSignupToken mocks base method. func (m *MockStore) UpsertTeamSignupToken(arg0 model.Team) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpsertTeamSignupToken", arg0) ret0, _ := ret[0].(error) return ret0 } // UpsertTeamSignupToken indicates an expected call of UpsertTeamSignupToken. func (mr *MockStoreMockRecorder) UpsertTeamSignupToken(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTeamSignupToken", reflect.TypeOf((*MockStore)(nil).UpsertTeamSignupToken), arg0) } ================================================ FILE: server/services/store/sqlstore/blocks.go ================================================ package sqlstore import ( "database/sql" "encoding/json" "fmt" "strings" "github.com/mattermost/focalboard/server/utils" sq "github.com/Masterminds/squirrel" _ "github.com/lib/pq" // postgres driver "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( maxSearchDepth = 50 descClause = " DESC " ) func (s *SQLStore) timestampToCharField(name string, as string) string { switch s.dbType { case model.MysqlDBType: return fmt.Sprintf("date_format(%s, '%%Y-%%m-%%d %%H:%%i:%%S') AS %s", name, as) case model.PostgresDBType: return fmt.Sprintf("to_char(%s, 'YYYY-MM-DD HH:MI:SS.MS') AS %s", name, as) default: return fmt.Sprintf("%s AS %s", name, as) } } func (s *SQLStore) blockFields(tableAlias string) []string { if tableAlias != "" && !strings.HasSuffix(tableAlias, ".") { tableAlias += "." } return []string{ tableAlias + "id", tableAlias + "parent_id", tableAlias + "created_by", tableAlias + "modified_by", tableAlias + s.escapeField("schema"), tableAlias + "type", tableAlias + "title", "COALESCE(" + tableAlias + "fields, '{}')", s.timestampToCharField(tableAlias+"insert_at", "insertAt"), tableAlias + "create_at", tableAlias + "update_at", tableAlias + "delete_at", "COALESCE(" + tableAlias + "board_id, '0')", } } func (s *SQLStore) getBlocks(db sq.BaseRunner, opts model.QueryBlocksOptions) ([]*model.Block, error) { query := s.getQueryBuilder(db). Select(s.blockFields("")...). From(s.tablePrefix + "blocks") if opts.BoardID != "" { query = query.Where(sq.Eq{"board_id": opts.BoardID}) } if opts.ParentID != "" { query = query.Where(sq.Eq{"parent_id": opts.ParentID}) } if opts.BlockType != "" && opts.BlockType != model.TypeUnknown { query = query.Where(sq.Eq{"type": opts.BlockType}) } if opts.Page != 0 { query = query.Offset(uint64(opts.Page * opts.PerPage)) } if opts.PerPage > 0 { query = query.Limit(uint64(opts.PerPage)) } rows, err := query.Query() if err != nil { s.logger.Error(`getBlocks ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.blocksFromRows(rows) } func (s *SQLStore) getBlocksWithParentAndType(db sq.BaseRunner, boardID, parentID string, blockType string) ([]*model.Block, error) { opts := model.QueryBlocksOptions{ BoardID: boardID, ParentID: parentID, BlockType: model.BlockType(blockType), } return s.getBlocks(db, opts) } func (s *SQLStore) getBlocksWithParent(db sq.BaseRunner, boardID, parentID string) ([]*model.Block, error) { opts := model.QueryBlocksOptions{ BoardID: boardID, ParentID: parentID, } return s.getBlocks(db, opts) } func (s *SQLStore) getBlocksByIDs(db sq.BaseRunner, ids []string) ([]*model.Block, error) { query := s.getQueryBuilder(db). Select(s.blockFields("")...). From(s.tablePrefix + "blocks"). Where(sq.Eq{"id": ids}) rows, err := query.Query() if err != nil { s.logger.Error(`GetBlocksByIDs ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) blocks, err := s.blocksFromRows(rows) if err != nil { return nil, err } if len(blocks) != len(ids) { return blocks, model.NewErrNotAllFound("block", ids) } return blocks, nil } func (s *SQLStore) getBlocksWithType(db sq.BaseRunner, boardID, blockType string) ([]*model.Block, error) { opts := model.QueryBlocksOptions{ BoardID: boardID, BlockType: model.BlockType(blockType), } return s.getBlocks(db, opts) } // getSubTree2 returns blocks within 2 levels of the given blockID. func (s *SQLStore) getSubTree2(db sq.BaseRunner, boardID string, blockID string, opts model.QuerySubtreeOptions) ([]*model.Block, error) { query := s.getQueryBuilder(db). Select(s.blockFields("")...). From(s.tablePrefix + "blocks"). Where(sq.Or{sq.Eq{"id": blockID}, sq.Eq{"parent_id": blockID}}). Where(sq.Eq{"board_id": boardID}). OrderBy("insert_at, update_at") if opts.BeforeUpdateAt != 0 { query = query.Where(sq.LtOrEq{"update_at": opts.BeforeUpdateAt}) } if opts.AfterUpdateAt != 0 { query = query.Where(sq.GtOrEq{"update_at": opts.AfterUpdateAt}) } if opts.Limit != 0 { query = query.Limit(opts.Limit) } rows, err := query.Query() if err != nil { s.logger.Error(`getSubTree ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.blocksFromRows(rows) } func (s *SQLStore) getBlocksForBoard(db sq.BaseRunner, boardID string) ([]*model.Block, error) { opts := model.QueryBlocksOptions{ BoardID: boardID, } return s.getBlocks(db, opts) } func (s *SQLStore) blocksFromRows(rows *sql.Rows) ([]*model.Block, error) { results := []*model.Block{} for rows.Next() { var block model.Block var fieldsJSON string var modifiedBy sql.NullString var insertAt sql.NullString err := rows.Scan( &block.ID, &block.ParentID, &block.CreatedBy, &modifiedBy, &block.Schema, &block.Type, &block.Title, &fieldsJSON, &insertAt, &block.CreateAt, &block.UpdateAt, &block.DeleteAt, &block.BoardID) if err != nil { // handle this error s.logger.Error(`ERROR blocksFromRows`, mlog.Err(err)) return nil, err } if modifiedBy.Valid { block.ModifiedBy = modifiedBy.String } err = json.Unmarshal([]byte(fieldsJSON), &block.Fields) if err != nil { // handle this error s.logger.Error(`ERROR blocksFromRows fields`, mlog.Err(err)) return nil, err } results = append(results, &block) } return results, nil } func (s *SQLStore) insertBlock(db sq.BaseRunner, block *model.Block, userID string) error { if err := block.IsValid(); err != nil { return fmt.Errorf("error validating block %s: %w", block.ID, err) } fieldsJSON, err := json.Marshal(block.Fields) if err != nil { return err } existingBlock, err := s.getBlock(db, block.ID) if err != nil && !model.IsErrNotFound(err) { return err } block.UpdateAt = utils.GetMillis() block.ModifiedBy = userID insertQuery := s.getQueryBuilder(db).Insert(""). Columns( "channel_id", "id", "parent_id", "created_by", "modified_by", s.escapeField("schema"), "type", "title", "fields", "create_at", "update_at", "delete_at", "board_id", ) insertQueryValues := map[string]interface{}{ "channel_id": "", "id": block.ID, "parent_id": block.ParentID, s.escapeField("schema"): block.Schema, "type": block.Type, "title": block.Title, "fields": fieldsJSON, "delete_at": block.DeleteAt, "created_by": userID, "modified_by": block.ModifiedBy, "create_at": utils.GetMillis(), "update_at": block.UpdateAt, "board_id": block.BoardID, } if existingBlock != nil { // block with ID exists, so this is an update operation query := s.getQueryBuilder(db).Update(s.tablePrefix+"blocks"). Where(sq.Eq{"id": block.ID}). Where(sq.Eq{"board_id": block.BoardID}). Set("parent_id", block.ParentID). Set("modified_by", block.ModifiedBy). Set(s.escapeField("schema"), block.Schema). Set("type", block.Type). Set("title", block.Title). Set("fields", fieldsJSON). Set("update_at", block.UpdateAt). Set("delete_at", block.DeleteAt) if _, err := query.Exec(); err != nil { s.logger.Error(`InsertBlock error occurred while updating existing block`, mlog.String("blockID", block.ID), mlog.Err(err)) return err } } else { block.CreatedBy = userID query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "blocks") if _, err := query.Exec(); err != nil { return err } } // writing block history query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "blocks_history") if _, err := query.Exec(); err != nil { return err } return nil } func (s *SQLStore) patchBlock(db sq.BaseRunner, blockID string, blockPatch *model.BlockPatch, userID string) error { existingBlock, err := s.getBlock(db, blockID) if err != nil { return err } block := blockPatch.Patch(existingBlock) return s.insertBlock(db, block, userID) } func (s *SQLStore) patchBlocks(db sq.BaseRunner, blockPatches *model.BlockPatchBatch, userID string) error { for i, blockID := range blockPatches.BlockIDs { err := s.patchBlock(db, blockID, &blockPatches.BlockPatches[i], userID) if err != nil { return err } } return nil } func (s *SQLStore) insertBlocks(db sq.BaseRunner, blocks []*model.Block, userID string) error { for _, block := range blocks { if err := block.IsValid(); err != nil { return fmt.Errorf("error validating block %s: %w", block.ID, err) } } for i := range blocks { err := s.insertBlock(db, blocks[i], userID) if err != nil { return err } } return nil } func (s *SQLStore) deleteBlock(db sq.BaseRunner, blockID string, modifiedBy string) error { return s.deleteBlockAndChildren(db, blockID, modifiedBy, false) } func retrieveFileIDFromBlockFieldStorage(id string) string { parts := strings.Split(id, ".") if len(parts) < 1 { return "" } return parts[0][1:] } func (s *SQLStore) deleteBlockAndChildren(db sq.BaseRunner, blockID string, modifiedBy string, keepChildren bool) error { block, err := s.getBlock(db, blockID) if model.IsErrNotFound(err) { s.logger.Warn("deleteBlock block not found", mlog.String("block_id", blockID)) return nil // deleting non-exiting block is not considered an error (for now) } if err != nil { return err } fieldsJSON, err := json.Marshal(block.Fields) if err != nil { return err } now := utils.GetMillis() insertQuery := s.getQueryBuilder(db).Insert(s.tablePrefix+"blocks_history"). Columns( "board_id", "id", "parent_id", s.escapeField("schema"), "type", "title", "fields", "modified_by", "create_at", "update_at", "delete_at", "created_by", ). Values( block.BoardID, block.ID, block.ParentID, block.Schema, block.Type, block.Title, fieldsJSON, modifiedBy, block.CreateAt, now, now, block.CreatedBy, ) if _, err := insertQuery.Exec(); err != nil { return err } // fileId and attachmentId shoudn't exist at the same time fileID := "" fileIDWithExtention, fileIDExists := block.Fields["fileId"] if fileIDExists { fileID = retrieveFileIDFromBlockFieldStorage(fileIDWithExtention.(string)) } if fileID == "" { attachmentIDWithExtention, attachmentIDExists := block.Fields["attachmentId"] if attachmentIDExists { fileID = retrieveFileIDFromBlockFieldStorage(attachmentIDWithExtention.(string)) } } if fileID != "" { deleteFileInfoQuery := s.getQueryBuilder(db). Update("FileInfo"). Set("DeleteAt", model.GetMillis()). Where(sq.Eq{"id": fileID}) if _, err := deleteFileInfoQuery.Exec(); err != nil { return err } } deleteQuery := s.getQueryBuilder(db). Delete(s.tablePrefix + "blocks"). Where(sq.Eq{"id": blockID}) if _, err := deleteQuery.Exec(); err != nil { return err } if keepChildren { return nil } return s.deleteBlockChildren(db, block.BoardID, block.ID, modifiedBy) } func (s *SQLStore) undeleteBlock(db sq.BaseRunner, blockID string, modifiedBy string) error { blocks, err := s.getBlockHistory(db, blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true}) if err != nil { return err } if len(blocks) == 0 { s.logger.Warn("undeleteBlock block not found", mlog.String("block_id", blockID)) return nil // undeleting non-exiting block is not considered an error (for now) } block := blocks[0] if block.DeleteAt == 0 { s.logger.Warn("undeleteBlock block not deleted", mlog.String("block_id", block.ID)) return nil // undeleting not deleted block is not considered an error (for now) } fieldsJSON, err := json.Marshal(block.Fields) if err != nil { return err } now := utils.GetMillis() columns := []string{ "board_id", "channel_id", "id", "parent_id", s.escapeField("schema"), "type", "title", "fields", "modified_by", "create_at", "update_at", "delete_at", "created_by", } values := []interface{}{ block.BoardID, "", block.ID, block.ParentID, block.Schema, block.Type, block.Title, fieldsJSON, modifiedBy, block.CreateAt, now, 0, block.CreatedBy, } insertHistoryQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "blocks_history"). Columns(columns...). Values(values...) insertQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "blocks"). Columns(columns...). Values(values...) if _, err := insertHistoryQuery.Exec(); err != nil { return err } if _, err := insertQuery.Exec(); err != nil { return err } return s.undeleteBlockChildren(db, block.BoardID, block.ID, modifiedBy) } func (s *SQLStore) getBlockCountsByType(db sq.BaseRunner) (map[string]int64, error) { query := s.getQueryBuilder(db). Select( "type", "COUNT(*) AS count", ). From(s.tablePrefix + "blocks"). GroupBy("type") rows, err := query.Query() if err != nil { s.logger.Error(`GetBlockCountsByType ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) m := make(map[string]int64) for rows.Next() { var blockType string var count int64 err := rows.Scan(&blockType, &count) if err != nil { s.logger.Error("Failed to fetch block count", mlog.Err(err)) return nil, err } m[blockType] = count } return m, nil } func (s *SQLStore) getBoardCount(db sq.BaseRunner) (int64, error) { query := s.getQueryBuilder(db). Select("COUNT(*) AS count"). From(s.tablePrefix + "boards"). Where(sq.Eq{"delete_at": 0}). Where(sq.Eq{"is_template": false}) row := query.QueryRow() var count int64 err := row.Scan(&count) if err != nil { return 0, err } return count, nil } func (s *SQLStore) getBlock(db sq.BaseRunner, blockID string) (*model.Block, error) { query := s.getQueryBuilder(db). Select(s.blockFields("")...). From(s.tablePrefix + "blocks"). Where(sq.Eq{"id": blockID}) rows, err := query.Query() if err != nil { s.logger.Error(`GetBlock ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) blocks, err := s.blocksFromRows(rows) if err != nil { return nil, err } if len(blocks) == 0 { return nil, model.NewErrNotFound("block ID=" + blockID) } return blocks[0], nil } func (s *SQLStore) getBlockHistory(db sq.BaseRunner, blockID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error) { var order string if opts.Descending { order = descClause } query := s.getQueryBuilder(db). Select(s.blockFields("")...). From(s.tablePrefix + "blocks_history"). Where(sq.Eq{"id": blockID}). OrderBy("insert_at " + order + ", update_at" + order) if opts.BeforeUpdateAt != 0 { query = query.Where(sq.Lt{"update_at": opts.BeforeUpdateAt}) } if opts.AfterUpdateAt != 0 { query = query.Where(sq.Gt{"update_at": opts.AfterUpdateAt}) } if opts.Limit != 0 { query = query.Limit(opts.Limit) } rows, err := query.Query() if err != nil { s.logger.Error(`GetBlockHistory ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.blocksFromRows(rows) } func (s *SQLStore) getBlockHistoryDescendants(db sq.BaseRunner, boardID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error) { var order string if opts.Descending { order = descClause } query := s.getQueryBuilder(db). Select(s.blockFields("")...). From(s.tablePrefix + "blocks_history"). Where(sq.Eq{"board_id": boardID}). OrderBy("insert_at " + order + ", update_at" + order) if opts.BeforeUpdateAt != 0 { query = query.Where(sq.Lt{"update_at": opts.BeforeUpdateAt}) } if opts.AfterUpdateAt != 0 { query = query.Where(sq.Gt{"update_at": opts.AfterUpdateAt}) } if opts.Limit != 0 { query = query.Limit(opts.Limit) } rows, err := query.Query() if err != nil { s.logger.Error(`GetBlockHistoryDescendants ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.blocksFromRows(rows) } // getBlockHistoryNewestChildren returns the newest (latest) version child blocks for the // specified parent from the blocks_history table. This includes any deleted children. func (s *SQLStore) getBlockHistoryNewestChildren(db sq.BaseRunner, parentID string, opts model.QueryBlockHistoryChildOptions) ([]*model.Block, bool, error) { // as we're joining 2 queries, we need to avoid numbered // placeholders until the join is done, so we use the default // question mark placeholder here builder := s.getQueryBuilder(db).PlaceholderFormat(sq.Question) sub := builder. Select("bh2.id", "MAX(bh2.insert_at) AS max_insert_at"). From(s.tablePrefix + "blocks_history AS bh2"). Where(sq.Eq{"bh2.parent_id": parentID}). GroupBy("bh2.id") if opts.AfterUpdateAt != 0 { sub = sub.Where(sq.Gt{"bh2.update_at": opts.AfterUpdateAt}) } if opts.BeforeUpdateAt != 0 { sub = sub.Where(sq.Lt{"bh2.update_at": opts.BeforeUpdateAt}) } subQuery, subArgs, err := sub.ToSql() if err != nil { return nil, false, fmt.Errorf("getBlockHistoryNewestChildren unable to generate subquery: %w", err) } query := s.getQueryBuilder(db). Select(s.blockFields("bh")...). From(s.tablePrefix+"blocks_history AS bh"). InnerJoin("("+subQuery+") AS sub ON bh.id=sub.id AND bh.insert_at=sub.max_insert_at", subArgs...) if opts.Page != 0 { query = query.Offset(uint64(opts.Page * opts.PerPage)) } if opts.PerPage > 0 { // limit+1 to detect if more records available query = query.Limit(uint64(opts.PerPage + 1)) } sql, args, err := query.ToSql() if err != nil { return nil, false, fmt.Errorf("getBlockHistoryNewestChildren unable to generate sql: %w", err) } // if we're using postgres or sqlite, we need to replace the // question mark placeholder with the numbered dollar one, now // that the full query is built if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType { var rErr error sql, rErr = sq.Dollar.ReplacePlaceholders(sql) if rErr != nil { return nil, false, fmt.Errorf("getBlockHistoryNewestChildren unable to replace sql placeholders: %w", rErr) } } rows, err := db.Query(sql, args...) if err != nil { s.logger.Error(`getBlockHistoryNewestChildren ERROR`, mlog.Err(err)) return nil, false, err } defer s.CloseRows(rows) blocks, err := s.blocksFromRows(rows) if err != nil { return nil, false, err } hasMore := false if opts.PerPage > 0 && len(blocks) > opts.PerPage { blocks = blocks[:opts.PerPage] hasMore = true } return blocks, hasMore, nil } // getBoardAndCardByID returns the first parent of type `card` and first parent of type `board` for the block specified by ID. // `board` and/or `card` may return nil without error if the block does not belong to a board or card. func (s *SQLStore) getBoardAndCardByID(db sq.BaseRunner, blockID string) (board *model.Board, card *model.Block, err error) { // use block_history to fetch block in case it was deleted and no longer exists in blocks table. opts := model.QueryBlockHistoryOptions{ Limit: 1, Descending: true, } blocks, err := s.getBlockHistory(db, blockID, opts) if err != nil { return nil, nil, err } if len(blocks) == 0 { return nil, nil, model.NewErrNotFound("block history BlockID=" + blockID) } return s.getBoardAndCard(db, blocks[0]) } // getBoardAndCard returns the first parent of type `card` and and the `board` for the specified block. // `board` and/or `card` may return nil without error if the block does not belong to a board or card. func (s *SQLStore) getBoardAndCard(db sq.BaseRunner, block *model.Block) (board *model.Board, card *model.Block, err error) { var count int // don't let invalid blocks hierarchy cause infinite loop. iter := block // use block_history to fetch blocks in case they were deleted and no longer exist in blocks table. opts := model.QueryBlockHistoryOptions{ Limit: 1, Descending: true, } for { count++ if card == nil && iter.Type == model.TypeCard { card = iter } if iter.ParentID == "" || card != nil || count > maxSearchDepth { break } blocks, err2 := s.getBlockHistory(db, iter.ParentID, opts) if err2 != nil { return nil, nil, err2 } if len(blocks) == 0 { return board, card, nil } iter = blocks[0] } board, err = s.getBoard(db, block.BoardID) if err != nil { return nil, nil, err } return board, card, nil } func (s *SQLStore) replaceBlockID(db sq.BaseRunner, currentID, newID, workspaceID string) error { runUpdateForBlocksAndHistory := func(query sq.UpdateBuilder) error { if _, err := query.Table(s.tablePrefix + "blocks").Exec(); err != nil { return err } if _, err := query.Table(s.tablePrefix + "blocks_history").Exec(); err != nil { return err } return nil } baseQuery := s.getQueryBuilder(db). Where(sq.Eq{"workspace_id": workspaceID}) // update ID updateIDQ := baseQuery.Update(""). Set("id", newID). Where(sq.Eq{"id": currentID}) if errID := runUpdateForBlocksAndHistory(updateIDQ); errID != nil { s.logger.Error(`replaceBlockID ERROR`, mlog.Err(errID)) return errID } // update BoardID updateBoardIDQ := baseQuery.Update(""). Set("board_id", newID). Where(sq.Eq{"board_id": currentID}) if errBoardID := runUpdateForBlocksAndHistory(updateBoardIDQ); errBoardID != nil { s.logger.Error(`replaceBlockID ERROR`, mlog.Err(errBoardID)) return errBoardID } // update ParentID updateParentIDQ := baseQuery.Update(""). Set("parent_id", newID). Where(sq.Eq{"parent_id": currentID}) if errParentID := runUpdateForBlocksAndHistory(updateParentIDQ); errParentID != nil { s.logger.Error(`replaceBlockID ERROR`, mlog.Err(errParentID)) return errParentID } // update parent contentOrder updateContentOrder := baseQuery.Update("") if s.dbType == model.PostgresDBType { updateContentOrder = updateContentOrder. Set("fields", sq.Expr("REPLACE(fields::text, ?, ?)::json", currentID, newID)). Where(sq.Like{"fields->>'contentOrder'": "%" + currentID + "%"}). Where(sq.Eq{"type": model.TypeCard}) } else { updateContentOrder = updateContentOrder. Set("fields", sq.Expr("REPLACE(fields, ?, ?)", currentID, newID)). Where(sq.Like{"fields": "%" + currentID + "%"}). Where(sq.Eq{"type": model.TypeCard}) } if errParentID := runUpdateForBlocksAndHistory(updateContentOrder); errParentID != nil { s.logger.Error(`replaceBlockID ERROR`, mlog.Err(errParentID)) return errParentID } return nil } func (s *SQLStore) duplicateBlock(db sq.BaseRunner, boardID string, blockID string, userID string, asTemplate bool) ([]*model.Block, error) { blocks, err := s.getSubTree2(db, boardID, blockID, model.QuerySubtreeOptions{}) if err != nil { return nil, err } if len(blocks) == 0 { message := fmt.Sprintf("block subtree BoardID=%s BlockID=%s", boardID, blockID) return nil, model.NewErrNotFound(message) } var rootBlock *model.Block allBlocks := []*model.Block{} for _, block := range blocks { if block.Type == model.TypeComment { continue } if block.ID == blockID { if block.Fields == nil { block.Fields = make(map[string]interface{}) } block.Fields["isTemplate"] = asTemplate rootBlock = block } else { allBlocks = append(allBlocks, block) } } allBlocks = append([]*model.Block{rootBlock}, allBlocks...) allBlocks = model.GenerateBlockIDs(allBlocks, nil) if err := s.insertBlocks(db, allBlocks, userID); err != nil { return nil, err } return allBlocks, nil } func (s *SQLStore) deleteBlockChildren(db sq.BaseRunner, boardID string, parentID string, modifiedBy string) error { now := utils.GetMillis() selectQuery := s.getQueryBuilder(db). Select( "board_id", "id", "parent_id", s.escapeField("schema"), "type", "title", "fields", "'"+modifiedBy+"'", "create_at", s.castInt(now, "update_at"), s.castInt(now, "delete_at"), "created_by", ). From(s.tablePrefix + "blocks"). Where(sq.Eq{"board_id": boardID}) if parentID != "" { selectQuery = selectQuery.Where(sq.Eq{"parent_id": parentID}) } insertQuery := s.getQueryBuilder(db). Insert(s.tablePrefix+"blocks_history"). Columns( "board_id", "id", "parent_id", s.escapeField("schema"), "type", "title", "fields", "modified_by", "create_at", "update_at", "delete_at", "created_by", ).Select(selectQuery) if _, err := insertQuery.Exec(); err != nil { return err } fileDeleteQuery := s.getQueryBuilder(db). Select(s.blockFields("")...). From(s.tablePrefix + "blocks"). Where(sq.Eq{"board_id": boardID}) if parentID != "" { fileDeleteQuery = fileDeleteQuery.Where(sq.Eq{"parent_id": parentID}) } rows, err := fileDeleteQuery.Query() if err != nil { return err } defer s.CloseRows(rows) blocks, err := s.blocksFromRows(rows) if err != nil { return err } fileIDs := make([]string, 0, len(blocks)) for _, block := range blocks { fileIDWithExtention, fileIDExists := block.Fields["fileId"] if fileIDExists { fileIDs = append(fileIDs, retrieveFileIDFromBlockFieldStorage(fileIDWithExtention.(string))) } attachmentIDWithExtention, attachmentIDExists := block.Fields["attachmentId"] if attachmentIDExists { fileIDs = append(fileIDs, retrieveFileIDFromBlockFieldStorage(attachmentIDWithExtention.(string))) } } if len(fileIDs) > 0 { deleteFileInfoQuery := s.getQueryBuilder(db). Update("FileInfo"). Set("DeleteAt", model.GetMillis()). Where(sq.Eq{"id": fileIDs}) if _, err := deleteFileInfoQuery.Exec(); err != nil { return err } } deleteQuery := s.getQueryBuilder(db). Delete(s.tablePrefix + "blocks"). Where(sq.Eq{"board_id": boardID}) if parentID != "" { deleteQuery = deleteQuery.Where(sq.Eq{"parent_id": parentID}) } if _, err := deleteQuery.Exec(); err != nil { return err } return nil } func (s *SQLStore) undeleteBlockChildren(db sq.BaseRunner, boardID string, parentID string, modifiedBy string) error { if boardID == "" { return model.ErrBlockEmptyBoardID } where := fmt.Sprintf("board_id='%s'", boardID) if parentID != "" { where += fmt.Sprintf(" AND parent_id='%s'", parentID) } selectQuery := s.getQueryBuilder(db). Select( "bh.board_id", "'' AS channel_id", "bh.id", "bh.parent_id", "bh.schema", "bh.type", "bh.title", "bh.fields", "'"+modifiedBy+"' AS modified_by", "bh.create_at", s.castInt(utils.GetMillis(), "update_at"), s.castInt(0, "delete_at"), "bh.created_by", ). From(fmt.Sprintf(` %sblocks_history AS bh, (SELECT id, max(insert_at) AS max_insert_at FROM %sblocks_history WHERE %s GROUP BY id) AS sub`, s.tablePrefix, s.tablePrefix, where)). Where("bh.id=sub.id"). Where("bh.insert_at=sub.max_insert_at"). Where(sq.NotEq{"bh.delete_at": 0}) columns := []string{ "board_id", "channel_id", "id", "parent_id", s.escapeField("schema"), "type", "title", "fields", "modified_by", "create_at", "update_at", "delete_at", "created_by", } insertQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "blocks"). Columns(columns...). Select(selectQuery) insertHistoryQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "blocks_history"). Columns(columns...). Select(selectQuery) sql, args, err := insertQuery.ToSql() s.logger.Trace("undeleteBlockChildren - insertQuery", mlog.String("sql", sql), mlog.Array("args", args), mlog.Err(err), ) sql, args, err = insertHistoryQuery.ToSql() s.logger.Trace("undeleteBlockChildren - insertHistoryQuery", mlog.String("sql", sql), mlog.Array("args", args), mlog.Err(err), ) // insert into blocks table must happen before history table, otherwise the history // table will be changed and the second query will fail to find the same records. result, err := insertQuery.Exec() if err != nil { return err } rowsAffected, _ := result.RowsAffected() s.logger.Debug("undeleteBlockChildren - insertQuery", mlog.Int("rows_affected", rowsAffected)) result, err = insertHistoryQuery.Exec() if err != nil { return err } rowsAffected, _ = result.RowsAffected() s.logger.Debug("undeleteBlockChildren - insertHistoryQuery", mlog.Int("rows_affected", rowsAffected)) return nil } ================================================ FILE: server/services/store/sqlstore/board.go ================================================ package sqlstore import ( //nolint:gosec "crypto/md5" "database/sql" "encoding/json" "fmt" "strings" "time" "github.com/mattermost/focalboard/server/utils" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func boardFields(tableAlias string) []string { if tableAlias != "" && !strings.HasSuffix(tableAlias, ".") { tableAlias += "." } return []string{ tableAlias + "id", tableAlias + "team_id", "COALESCE(" + tableAlias + "channel_id, '')", "COALESCE(" + tableAlias + "created_by, '')", tableAlias + "modified_by", tableAlias + "type", tableAlias + "minimum_role", tableAlias + "title", tableAlias + "description", tableAlias + "icon", tableAlias + "show_description", tableAlias + "is_template", tableAlias + "template_version", "COALESCE(" + tableAlias + "properties, '{}')", "COALESCE(" + tableAlias + "card_properties, '[]')", tableAlias + "create_at", tableAlias + "update_at", tableAlias + "delete_at", } } func boardHistoryFields() []string { fields := []string{ "id", "team_id", "COALESCE(channel_id, '')", "COALESCE(created_by, '')", "COALESCE(modified_by, '')", "type", "minimum_role", "COALESCE(title, '')", "COALESCE(description, '')", "COALESCE(icon, '')", "COALESCE(show_description, false)", "COALESCE(is_template, false)", "template_version", "COALESCE(properties, '{}')", "COALESCE(card_properties, '[]')", "COALESCE(create_at, 0)", "COALESCE(update_at, 0)", "COALESCE(delete_at, 0)", } return fields } var boardMemberFields = []string{ "COALESCE(B.minimum_role, '')", "BM.board_id", "BM.user_id", "BM.roles", "BM.scheme_admin", "BM.scheme_editor", "BM.scheme_commenter", "BM.scheme_viewer", } func (s *SQLStore) boardsFromRows(rows *sql.Rows) ([]*model.Board, error) { boards := []*model.Board{} for rows.Next() { var board model.Board var propertiesBytes []byte var cardPropertiesBytes []byte err := rows.Scan( &board.ID, &board.TeamID, &board.ChannelID, &board.CreatedBy, &board.ModifiedBy, &board.Type, &board.MinimumRole, &board.Title, &board.Description, &board.Icon, &board.ShowDescription, &board.IsTemplate, &board.TemplateVersion, &propertiesBytes, &cardPropertiesBytes, &board.CreateAt, &board.UpdateAt, &board.DeleteAt, ) if err != nil { s.logger.Error("boardsFromRows scan error", mlog.Err(err)) return nil, err } err = json.Unmarshal(propertiesBytes, &board.Properties) if err != nil { s.logger.Error("board properties unmarshal error", mlog.Err(err)) return nil, err } err = json.Unmarshal(cardPropertiesBytes, &board.CardProperties) if err != nil { s.logger.Error("board card properties unmarshal error", mlog.Err(err)) return nil, err } boards = append(boards, &board) } return boards, nil } func (s *SQLStore) boardMembersFromRows(rows *sql.Rows) ([]*model.BoardMember, error) { boardMembers := []*model.BoardMember{} for rows.Next() { var boardMember model.BoardMember err := rows.Scan( &boardMember.MinimumRole, &boardMember.BoardID, &boardMember.UserID, &boardMember.Roles, &boardMember.SchemeAdmin, &boardMember.SchemeEditor, &boardMember.SchemeCommenter, &boardMember.SchemeViewer, ) if err != nil { return nil, err } boardMembers = append(boardMembers, &boardMember) } return boardMembers, nil } func (s *SQLStore) boardMemberHistoryEntriesFromRows(rows *sql.Rows) ([]*model.BoardMemberHistoryEntry, error) { boardMemberHistoryEntries := []*model.BoardMemberHistoryEntry{} for rows.Next() { var boardMemberHistoryEntry model.BoardMemberHistoryEntry var insertAt sql.NullString err := rows.Scan( &boardMemberHistoryEntry.BoardID, &boardMemberHistoryEntry.UserID, &boardMemberHistoryEntry.Action, &insertAt, ) if err != nil { return nil, err } // parse the insert_at timestamp which is different based on database type. dateTemplate := "2006-01-02T15:04:05Z0700" if s.dbType == model.MysqlDBType { dateTemplate = "2006-01-02 15:04:05.000000" } ts, err := time.Parse(dateTemplate, insertAt.String) if err != nil { return nil, fmt.Errorf("cannot parse datetime '%s' for board_members_history scan: %w", insertAt.String, err) } boardMemberHistoryEntry.InsertAt = ts boardMemberHistoryEntries = append(boardMemberHistoryEntries, &boardMemberHistoryEntry) } return boardMemberHistoryEntries, nil } func (s *SQLStore) getBoardByCondition(db sq.BaseRunner, conditions ...interface{}) (*model.Board, error) { boards, err := s.getBoardsByCondition(db, conditions...) if err != nil { return nil, err } return boards[0], nil } func (s *SQLStore) getBoardsByCondition(db sq.BaseRunner, conditions ...interface{}) ([]*model.Board, error) { return s.getBoardsFieldsByCondition(db, boardFields(""), conditions...) } func (s *SQLStore) getBoardsFieldsByCondition(db sq.BaseRunner, fields []string, conditions ...interface{}) ([]*model.Board, error) { query := s.getQueryBuilder(db). Select(fields...). From(s.tablePrefix + "boards") for _, c := range conditions { query = query.Where(c) } rows, err := query.Query() if err != nil { s.logger.Error(`getBoardsFieldsByCondition ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) boards, err := s.boardsFromRows(rows) if err != nil { return nil, err } if len(boards) == 0 { return nil, model.NewErrNotFound("boards") } return boards, nil } func (s *SQLStore) getBoard(db sq.BaseRunner, boardID string) (*model.Board, error) { return s.getBoardByCondition(db, sq.Eq{"id": boardID}) } func (s *SQLStore) getBoardsForUserAndTeam(db sq.BaseRunner, userID, teamID string, includePublicBoards bool) ([]*model.Board, error) { query := s.getQueryBuilder(db). Select(boardFields("b.")...). Distinct(). From(s.tablePrefix + "boards as b"). LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id"). Where(sq.Eq{"b.team_id": teamID}). Where(sq.Eq{"b.is_template": false}) if includePublicBoards { query = query.Where(sq.Or{ sq.Eq{"b.type": model.BoardTypeOpen}, sq.Eq{"bm.user_id": userID}, }) } else { query = query.Where(sq.Or{ sq.Eq{"bm.user_id": userID}, }) } rows, err := query.Query() if err != nil { s.logger.Error(`getBoardsForUserAndTeam ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.boardsFromRows(rows) } func (s *SQLStore) getBoardsInTeamByIds(db sq.BaseRunner, boardIDs []string, teamID string) ([]*model.Board, error) { query := s.getQueryBuilder(db). Select(boardFields("b.")...). From(s.tablePrefix + "boards as b"). Where(sq.Eq{"b.team_id": teamID}). Where(sq.Eq{"b.id": boardIDs}) rows, err := query.Query() if err != nil { s.logger.Error(`getBoardsInTeamByIds ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) boards, err := s.boardsFromRows(rows) if err != nil { return nil, err } if len(boards) != len(boardIDs) { s.logger.Warn("getBoardsInTeamByIds mismatched number of boards found", mlog.Int("len(boards)", len(boards)), mlog.Int("len(boardIDs)", len(boardIDs)), ) return boards, model.NewErrNotAllFound("board", boardIDs) } return boards, nil } func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID string) (*model.Board, error) { // Generate tracking IDs for in-built templates if board.IsTemplate && board.TeamID == model.GlobalTeamID { //nolint:gosec // we don't need cryptographically secure hash, so MD5 is fine board.Properties["trackingTemplateId"] = fmt.Sprintf("%x", md5.Sum([]byte(board.Title))) } propertiesBytes, err := s.MarshalJSONB(board.Properties) if err != nil { s.logger.Error( "failed to marshal board.Properties", mlog.String("board_id", board.ID), mlog.String("board.Properties", fmt.Sprintf("%v", board.Properties)), mlog.Err(err), ) return nil, err } cardPropertiesBytes, err := s.MarshalJSONB(board.CardProperties) if err != nil { s.logger.Error( "failed to marshal board.CardProperties", mlog.String("board_id", board.ID), mlog.String("board.CardProperties", fmt.Sprintf("%v", board.CardProperties)), mlog.Err(err), ) return nil, err } existingBoard, err := s.getBoard(db, board.ID) if err != nil && !model.IsErrNotFound(err) { return nil, fmt.Errorf("insertBoard error occurred while fetching existing board %s: %w", board.ID, err) } insertQuery := s.getQueryBuilder(db).Insert(""). Columns(boardFields("")...) now := utils.GetMillis() board.ModifiedBy = userID board.UpdateAt = now insertQueryValues := map[string]interface{}{ "id": board.ID, "team_id": board.TeamID, "channel_id": board.ChannelID, "created_by": board.CreatedBy, "modified_by": board.ModifiedBy, "type": board.Type, "title": board.Title, "minimum_role": board.MinimumRole, "description": board.Description, "icon": board.Icon, "show_description": board.ShowDescription, "is_template": board.IsTemplate, "template_version": board.TemplateVersion, "properties": propertiesBytes, "card_properties": cardPropertiesBytes, "create_at": board.CreateAt, "update_at": board.UpdateAt, "delete_at": board.DeleteAt, } if existingBoard != nil { query := s.getQueryBuilder(db).Update(s.tablePrefix+"boards"). Where(sq.Eq{"id": board.ID}). Set("modified_by", board.ModifiedBy). Set("type", board.Type). Set("channel_id", board.ChannelID). Set("minimum_role", board.MinimumRole). Set("title", board.Title). Set("description", board.Description). Set("icon", board.Icon). Set("show_description", board.ShowDescription). Set("is_template", board.IsTemplate). Set("template_version", board.TemplateVersion). Set("properties", propertiesBytes). Set("card_properties", cardPropertiesBytes). Set("update_at", board.UpdateAt). Set("delete_at", board.DeleteAt) if _, err := query.Exec(); err != nil { s.logger.Error(`InsertBoard error occurred while updating existing board`, mlog.String("boardID", board.ID), mlog.Err(err)) return nil, fmt.Errorf("insertBoard error occurred while updating existing board %s: %w", board.ID, err) } } else { board.CreatedBy = userID board.CreateAt = now insertQueryValues["created_by"] = board.CreatedBy insertQueryValues["create_at"] = board.CreateAt query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "boards") if _, err := query.Exec(); err != nil { return nil, fmt.Errorf("insertBoard error occurred while inserting board %s: %w", board.ID, err) } } // writing board history query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "boards_history") if _, err := query.Exec(); err != nil { s.logger.Error("failed to insert board history", mlog.String("board_id", board.ID), mlog.Err(err)) return nil, fmt.Errorf("failed to insert board %s history: %w", board.ID, err) } return board, nil } func (s *SQLStore) patchBoard(db sq.BaseRunner, boardID string, boardPatch *model.BoardPatch, userID string) (*model.Board, error) { existingBoard, err := s.getBoard(db, boardID) if err != nil { return nil, err } board := boardPatch.Patch(existingBoard) return s.insertBoard(db, board, userID) } func (s *SQLStore) deleteBoard(db sq.BaseRunner, boardID, userID string) error { return s.deleteBoardAndChildren(db, boardID, userID, false) } func (s *SQLStore) deleteBoardAndChildren(db sq.BaseRunner, boardID, userID string, keepChildren bool) error { now := utils.GetMillis() board, err := s.getBoard(db, boardID) if err != nil { return err } propertiesBytes, err := s.MarshalJSONB(board.Properties) if err != nil { return err } cardPropertiesBytes, err := s.MarshalJSONB(board.CardProperties) if err != nil { return err } insertQueryValues := map[string]interface{}{ "id": board.ID, "team_id": board.TeamID, "channel_id": board.ChannelID, "created_by": board.CreatedBy, "modified_by": userID, "type": board.Type, "minimum_role": board.MinimumRole, "title": board.Title, "description": board.Description, "icon": board.Icon, "show_description": board.ShowDescription, "is_template": board.IsTemplate, "template_version": board.TemplateVersion, "properties": propertiesBytes, "card_properties": cardPropertiesBytes, "create_at": board.CreateAt, "update_at": now, "delete_at": now, } // writing board history insertQuery := s.getQueryBuilder(db).Insert(""). Columns(boardHistoryFields()...) query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "boards_history") if _, err := query.Exec(); err != nil { return err } deleteQuery := s.getQueryBuilder(db). Delete(s.tablePrefix + "boards"). Where(sq.Eq{"id": boardID}). Where(sq.Eq{"COALESCE(team_id, '0')": board.TeamID}) if _, err := deleteQuery.Exec(); err != nil { return err } if keepChildren { return nil } return s.deleteBlockChildren(db, boardID, "", userID) } func (s *SQLStore) insertBoardWithAdmin(db sq.BaseRunner, board *model.Board, userID string) (*model.Board, *model.BoardMember, error) { newBoard, err := s.insertBoard(db, board, userID) if err != nil { return nil, nil, err } bm := &model.BoardMember{ BoardID: newBoard.ID, UserID: newBoard.CreatedBy, SchemeAdmin: true, SchemeEditor: true, } nbm, err := s.saveMember(db, bm) if err != nil { return nil, nil, fmt.Errorf("cannot save member %s while inserting board %s: %w", bm.UserID, bm.BoardID, err) } return newBoard, nbm, nil } func (s *SQLStore) saveMember(db sq.BaseRunner, bm *model.BoardMember) (*model.BoardMember, error) { queryValues := map[string]interface{}{ "board_id": bm.BoardID, "user_id": bm.UserID, "roles": "", "scheme_admin": bm.SchemeAdmin, "scheme_editor": bm.SchemeEditor, "scheme_commenter": bm.SchemeCommenter, "scheme_viewer": bm.SchemeViewer, } oldMember, err := s.getMemberForBoard(db, bm.BoardID, bm.UserID) if err != nil && !model.IsErrNotFound(err) { return nil, err } query := s.getQueryBuilder(db). Insert(s.tablePrefix + "board_members"). SetMap(queryValues) if s.dbType == model.MysqlDBType { query = query.Suffix( "ON DUPLICATE KEY UPDATE scheme_admin = ?, scheme_editor = ?, scheme_commenter = ?, scheme_viewer = ?", bm.SchemeAdmin, bm.SchemeEditor, bm.SchemeCommenter, bm.SchemeViewer) } else { query = query.Suffix( `ON CONFLICT (board_id, user_id) DO UPDATE SET scheme_admin = EXCLUDED.scheme_admin, scheme_editor = EXCLUDED.scheme_editor, scheme_commenter = EXCLUDED.scheme_commenter, scheme_viewer = EXCLUDED.scheme_viewer`, ) } if _, err := query.Exec(); err != nil { return nil, err } if oldMember == nil { addToMembersHistory := s.getQueryBuilder(db). Insert(s.tablePrefix+"board_members_history"). Columns("board_id", "user_id", "action"). Values(bm.BoardID, bm.UserID, "created") if _, err := addToMembersHistory.Exec(); err != nil { return nil, err } } return bm, nil } func (s *SQLStore) deleteMember(db sq.BaseRunner, boardID, userID string) error { deleteQuery := s.getQueryBuilder(db). Delete(s.tablePrefix + "board_members"). Where(sq.Eq{"board_id": boardID}). Where(sq.Eq{"user_id": userID}) result, err := deleteQuery.Exec() if err != nil { return err } rowsAffected, err := result.RowsAffected() if err != nil { return err } if rowsAffected > 0 { addToMembersHistory := s.getQueryBuilder(db). Insert(s.tablePrefix+"board_members_history"). Columns("board_id", "user_id", "action"). Values(boardID, userID, "deleted") if _, err := addToMembersHistory.Exec(); err != nil { return err } } return nil } func (s *SQLStore) getMemberForBoard(db sq.BaseRunner, boardID, userID string) (*model.BoardMember, error) { query := s.getQueryBuilder(db). Select(boardMemberFields...). From(s.tablePrefix + "board_members AS BM"). LeftJoin(s.tablePrefix + "boards AS B ON B.id=BM.board_id"). Where(sq.Eq{"BM.board_id": boardID}). Where(sq.Eq{"BM.user_id": userID}) rows, err := query.Query() if err != nil { s.logger.Error(`getMemberForBoard ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) members, err := s.boardMembersFromRows(rows) if err != nil { return nil, err } if len(members) == 0 { message := fmt.Sprintf("board member BoardID=%s UserID=%s", boardID, userID) return nil, model.NewErrNotFound(message) } return members[0], nil } func (s *SQLStore) getMembersForUser(db sq.BaseRunner, userID string) ([]*model.BoardMember, error) { query := s.getQueryBuilder(db). Select(boardMemberFields...). From(s.tablePrefix + "board_members AS BM"). LeftJoin(s.tablePrefix + "boards AS B ON B.id=BM.board_id"). Where(sq.Eq{"BM.user_id": userID}) rows, err := query.Query() if err != nil { s.logger.Error(`getMembersForUser ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) members, err := s.boardMembersFromRows(rows) if err != nil { return nil, err } return members, nil } func (s *SQLStore) getMembersForBoard(db sq.BaseRunner, boardID string) ([]*model.BoardMember, error) { query := s.getQueryBuilder(db). Select(boardMemberFields...). From(s.tablePrefix + "board_members AS BM"). LeftJoin(s.tablePrefix + "boards AS B ON B.id=BM.board_id"). Where(sq.Eq{"BM.board_id": boardID}) rows, err := query.Query() if err != nil { s.logger.Error(`getMembersForBoard ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.boardMembersFromRows(rows) } // searchBoardsForUser returns all boards that match with the // term that are either private and which the user is a member of, or // they're open, regardless of the user membership. // Search is case-insensitive. func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) { query := s.getQueryBuilder(db). Select(boardFields("b.")...). Distinct(). From(s.tablePrefix + "boards as b"). LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id"). Where(sq.Eq{"b.is_template": false}) if includePublicBoards { query = query.Where(sq.Or{ sq.Eq{"b.type": model.BoardTypeOpen}, sq.Eq{"bm.user_id": userID}, }) } else { query = query.Where(sq.Or{ sq.Eq{"bm.user_id": userID}, }) } if term != "" { if searchField == model.BoardSearchFieldPropertyName { switch s.dbType { case model.PostgresDBType: where := "b.properties->? is not null" query = query.Where(where, term) case model.MysqlDBType, model.SqliteDBType: where := "JSON_EXTRACT(b.properties, ?) IS NOT NULL" query = query.Where(where, "$."+term) default: where := "b.properties LIKE ?" query = query.Where(where, "%\""+term+"\"%") } } else { // model.BoardSearchFieldTitle // break search query into space separated words // and search for all words. // This should later be upgraded to industrial-strength // word tokenizer, that uses much more than space // to break words. conditions := sq.And{} for _, word := range strings.Split(strings.TrimSpace(term), " ") { conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) } query = query.Where(conditions) } } rows, err := query.Query() if err != nil { s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.boardsFromRows(rows) } // searchBoardsForUserInTeam returns all boards that match with the // term that are either private and which the user is a member of, or // they're open, regardless of the user membership. // Search is case-insensitive. func (s *SQLStore) searchBoardsForUserInTeam(db sq.BaseRunner, teamID, term, userID string) ([]*model.Board, error) { query := s.getQueryBuilder(db). Select(boardFields("b.")...). Distinct(). From(s.tablePrefix + "boards as b"). LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id"). Where(sq.Eq{"b.is_template": false}). Where(sq.Eq{"b.team_id": teamID}). Where(sq.Or{ sq.Eq{"b.type": model.BoardTypeOpen}, sq.And{ sq.Eq{"b.type": model.BoardTypePrivate}, sq.Eq{"bm.user_id": userID}, }, }) if term != "" { // break search query into space separated words // and search for all words. // This should later be upgraded to industrial-strength // word tokenizer, that uses much more than space // to break words. conditions := sq.And{} for _, word := range strings.Split(strings.TrimSpace(term), " ") { conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) } query = query.Where(conditions) } rows, err := query.Query() if err != nil { s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.boardsFromRows(rows) } func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) { var order string if opts.Descending { order = " DESC " } query := s.getQueryBuilder(db). Select(boardHistoryFields()...). From(s.tablePrefix + "boards_history"). Where(sq.Eq{"id": boardID}). OrderBy("insert_at " + order + ", update_at" + order) if opts.BeforeUpdateAt != 0 { query = query.Where(sq.Lt{"update_at": opts.BeforeUpdateAt}) } if opts.AfterUpdateAt != 0 { query = query.Where(sq.Gt{"update_at": opts.AfterUpdateAt}) } if opts.Limit != 0 { query = query.Limit(opts.Limit) } rows, err := query.Query() if err != nil { s.logger.Error(`getBoardHistory ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.boardsFromRows(rows) } func (s *SQLStore) undeleteBoard(db sq.BaseRunner, boardID string, modifiedBy string) error { boards, err := s.getBoardHistory(db, boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}) if err != nil { return err } if len(boards) == 0 { s.logger.Warn("undeleteBlock board not found", mlog.String("board_id", boardID)) return nil // undeleting non-existing board is not considered an error (for now) } board := boards[0] if board.DeleteAt == 0 { s.logger.Warn("undeleteBlock board not deleted", mlog.String("board_id", board.ID)) return nil // undeleting not deleted board is not considered an error (for now) } propertiesJSON, err := s.MarshalJSONB(board.Properties) if err != nil { return err } cardPropertiesJSON, err := s.MarshalJSONB(board.CardProperties) if err != nil { return err } now := utils.GetMillis() columns := []string{ "id", "team_id", "channel_id", "created_by", "modified_by", "type", "title", "minimum_role", "description", "icon", "show_description", "is_template", "template_version", "properties", "card_properties", "create_at", "update_at", "delete_at", } values := []interface{}{ board.ID, board.TeamID, "", board.CreatedBy, modifiedBy, board.Type, board.Title, board.MinimumRole, board.Description, board.Icon, board.ShowDescription, board.IsTemplate, board.TemplateVersion, propertiesJSON, cardPropertiesJSON, board.CreateAt, now, 0, } insertHistoryQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "boards_history"). Columns(columns...). Values(values...) insertQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "boards"). Columns(columns...). Values(values...) if _, err := insertHistoryQuery.Exec(); err != nil { return err } if _, err := insertQuery.Exec(); err != nil { return err } return s.undeleteBlockChildren(db, board.ID, "", modifiedBy) } func (s *SQLStore) getBoardMemberHistory(db sq.BaseRunner, boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) { query := s.getQueryBuilder(db). Select("board_id", "user_id", "action", "insert_at"). From(s.tablePrefix + "board_members_history"). Where(sq.Eq{"board_id": boardID}). Where(sq.Eq{"user_id": userID}). OrderBy("insert_at DESC") if limit > 0 { query = query.Limit(limit) } rows, err := query.Query() if err != nil { s.logger.Error(`getBoardMemberHistory ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) memberHistory, err := s.boardMemberHistoryEntriesFromRows(rows) if err != nil { return nil, err } return memberHistory, nil } ================================================ FILE: server/services/store/sqlstore/boards_and_blocks.go ================================================ package sqlstore import ( "fmt" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" ) type BlockDoesntBelongToBoardsErr struct { blockID string } func (e BlockDoesntBelongToBoardsErr) Error() string { return fmt.Sprintf("block %s doesn't belong to any of the boards in the delete request", e.blockID) } func (s *SQLStore) createBoardsAndBlocksWithAdmin(db sq.BaseRunner, bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error) { newBab, err := s.createBoardsAndBlocks(db, bab, userID) if err != nil { return nil, nil, err } members := []*model.BoardMember{} for _, board := range newBab.Boards { bm := &model.BoardMember{ BoardID: board.ID, UserID: board.CreatedBy, SchemeAdmin: true, SchemeEditor: true, } nbm, err := s.saveMember(db, bm) if err != nil { return nil, nil, err } members = append(members, nbm) } return newBab, members, nil } func (s *SQLStore) createBoardsAndBlocks(db sq.BaseRunner, bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) { boards := []*model.Board{} blocks := []*model.Block{} for _, board := range bab.Boards { newBoard, err := s.insertBoard(db, board, userID) if err != nil { return nil, err } boards = append(boards, newBoard) } for _, block := range bab.Blocks { b := block err := s.insertBlock(db, b, userID) if err != nil { return nil, err } blocks = append(blocks, block) } newBab := &model.BoardsAndBlocks{ Boards: boards, Blocks: blocks, } return newBab, nil } func (s *SQLStore) patchBoardsAndBlocks(db sq.BaseRunner, pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) { bab := &model.BoardsAndBlocks{} for i, boardID := range pbab.BoardIDs { board, err := s.patchBoard(db, boardID, pbab.BoardPatches[i], userID) if err != nil { return nil, err } bab.Boards = append(bab.Boards, board) } for i, blockID := range pbab.BlockIDs { if err := s.patchBlock(db, blockID, pbab.BlockPatches[i], userID); err != nil { return nil, err } block, err := s.getBlock(db, blockID) if err != nil { return nil, err } bab.Blocks = append(bab.Blocks, block) } return bab, nil } // deleteBoardsAndBlocks deletes all the boards and blocks entities of // the DeleteBoardsAndBlocks struct, making sure that all the blocks // belong to the boards in the struct. func (s *SQLStore) deleteBoardsAndBlocks(db sq.BaseRunner, dbab *model.DeleteBoardsAndBlocks, userID string) error { boardIDMap := map[string]bool{} for _, boardID := range dbab.Boards { boardIDMap[boardID] = true } // delete the blocks first, since deleting the board will clean up any children and we'll get // not found errors when deleting the blocks after. for _, blockID := range dbab.Blocks { block, err := s.getBlock(db, blockID) if err != nil { return err } if _, ok := boardIDMap[block.BoardID]; !ok { return BlockDoesntBelongToBoardsErr{blockID} } if err := s.deleteBlock(db, blockID, userID); err != nil { return err } } for _, boardID := range dbab.Boards { if err := s.deleteBoard(db, boardID, userID); err != nil { return err } } return nil } func (s *SQLStore) duplicateBoard(db sq.BaseRunner, boardID string, userID string, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) { bab := &model.BoardsAndBlocks{ Boards: []*model.Board{}, Blocks: []*model.Block{}, } board, err := s.getBoard(db, boardID) if err != nil { return nil, nil, err } // todo: server localization if asTemplate == board.IsTemplate { // board -> board or template -> template board.Title += " copy" } else if asTemplate { // template from board board.Title = "New board template" } // make new board private board.Type = "P" board.IsTemplate = asTemplate board.CreatedBy = userID board.ChannelID = "" if toTeam != "" { board.TeamID = toTeam } bab.Boards = []*model.Board{board} blocks, err := s.getBlocksForBoard(db, boardID) if err != nil { return nil, nil, err } newBlocks := []*model.Block{} for _, b := range blocks { if b.Type != model.TypeComment { newBlocks = append(newBlocks, b) } } bab.Blocks = newBlocks bab, err = model.GenerateBoardsAndBlocksIDs(bab, nil) if err != nil { return nil, nil, err } return s.createBoardsAndBlocksWithAdmin(db, bab, userID) } ================================================ FILE: server/services/store/sqlstore/category.go ================================================ package sqlstore import ( "database/sql" "fmt" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const categorySortOrderGap = 10 func (s *SQLStore) categoryFields() []string { return []string{ "id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "COALESCE(sort_order, 0)", "type", } } func (s *SQLStore) getCategory(db sq.BaseRunner, id string) (*model.Category, error) { query := s.getQueryBuilder(db). Select(s.categoryFields()...). From(s.tablePrefix + "categories"). Where(sq.Eq{"id": id}) rows, err := query.Query() if err != nil { s.logger.Error("getCategory error", mlog.Err(err)) return nil, err } categories, err := s.categoriesFromRows(rows) if err != nil { s.logger.Error("getCategory row scan error", mlog.Err(err)) return nil, err } if len(categories) == 0 { return nil, model.NewErrNotFound("category ID=" + id) } return &categories[0], nil } func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) error { // A new category should always end up at the top. // So we first insert the provided category, then bump up // existing user-team categories' order // creating provided category query := s.getQueryBuilder(db). Insert(s.tablePrefix+"categories"). Columns( "id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "sort_order", "type", ). Values( category.ID, category.Name, category.UserID, category.TeamID, category.CreateAt, category.UpdateAt, category.DeleteAt, category.Collapsed, category.SortOrder, category.Type, ) _, err := query.Exec() if err != nil { s.logger.Error("Error creating category", mlog.String("category name", category.Name), mlog.Err(err)) return err } // bumping up order of existing categories updateQuery := s.getQueryBuilder(db). Update(s.tablePrefix+"categories"). Set("sort_order", sq.Expr(fmt.Sprintf("sort_order + %d", categorySortOrderGap))). Where( sq.Eq{ "user_id": category.UserID, "team_id": category.TeamID, "delete_at": 0, }, ) if _, err := updateQuery.Exec(); err != nil { s.logger.Error( "createCategory failed to update sort order of existing user-team categories", mlog.String("user_id", category.UserID), mlog.String("team_id", category.TeamID), mlog.Err(err), ) return err } return nil } func (s *SQLStore) updateCategory(db sq.BaseRunner, category model.Category) error { query := s.getQueryBuilder(db). Update(s.tablePrefix+"categories"). Set("name", category.Name). Set("update_at", category.UpdateAt). Set("collapsed", category.Collapsed). Where(sq.Eq{ "id": category.ID, "delete_at": 0, }) _, err := query.Exec() if err != nil { s.logger.Error("Error updating category", mlog.String("category_id", category.ID), mlog.String("category_name", category.Name), mlog.Err(err)) return err } return nil } func (s *SQLStore) deleteCategory(db sq.BaseRunner, categoryID, userID, teamID string) error { query := s.getQueryBuilder(db). Update(s.tablePrefix+"categories"). Set("delete_at", utils.GetMillis()). Where(sq.Eq{ "id": categoryID, "user_id": userID, "team_id": teamID, "delete_at": 0, }) _, err := query.Exec() if err != nil { s.logger.Error( "Error updating category", mlog.String("category_id", categoryID), mlog.String("user_id", userID), mlog.String("team_id", teamID), mlog.Err(err), ) return err } return nil } func (s *SQLStore) getUserCategories(db sq.BaseRunner, userID, teamID string) ([]model.Category, error) { query := s.getQueryBuilder(db). Select(s.categoryFields()...). From(s.tablePrefix+"categories"). Where(sq.Eq{ "user_id": userID, "team_id": teamID, "delete_at": 0, }). OrderBy("sort_order", "name") rows, err := query.Query() if err != nil { s.logger.Error("getUserCategories error", mlog.Err(err)) return nil, err } return s.categoriesFromRows(rows) } func (s *SQLStore) categoriesFromRows(rows *sql.Rows) ([]model.Category, error) { var categories []model.Category for rows.Next() { category := model.Category{} err := rows.Scan( &category.ID, &category.Name, &category.UserID, &category.TeamID, &category.CreateAt, &category.UpdateAt, &category.DeleteAt, &category.Collapsed, &category.SortOrder, &category.Type, ) if err != nil { s.logger.Error("categoriesFromRows row parsing error", mlog.Err(err)) return nil, err } categories = append(categories, category) } return categories, nil } func (s *SQLStore) reorderCategories(db sq.BaseRunner, userID, teamID string, newCategoryOrder []string) ([]string, error) { if len(newCategoryOrder) == 0 { return nil, nil } updateCase := sq.Case("id") for i, categoryID := range newCategoryOrder { updateCase = updateCase.When("'"+categoryID+"'", sq.Expr(fmt.Sprintf("%d", i*categorySortOrderGap))) } updateCase = updateCase.Else("sort_order") query := s.getQueryBuilder(db). Update(s.tablePrefix+"categories"). Set("sort_order", updateCase). Where(sq.Eq{ "user_id": userID, "team_id": teamID, }) if _, err := query.Exec(); err != nil { s.logger.Error( "reorderCategories failed to update category order", mlog.String("user_id", userID), mlog.String("team_id", teamID), mlog.Err(err), ) return nil, err } return newCategoryOrder, nil } ================================================ FILE: server/services/store/sqlstore/category_boards.go ================================================ package sqlstore import ( "database/sql" "fmt" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (s *SQLStore) getUserCategoryBoards(db sq.BaseRunner, userID, teamID string) ([]model.CategoryBoards, error) { categories, err := s.getUserCategories(db, userID, teamID) if err != nil { return nil, err } userCategoryBoards := []model.CategoryBoards{} for _, category := range categories { boardMetadata, err := s.getCategoryBoardAttributes(db, category.ID) if err != nil { return nil, err } userCategoryBoard := model.CategoryBoards{ Category: category, BoardMetadata: boardMetadata, } userCategoryBoards = append(userCategoryBoards, userCategoryBoard) } return userCategoryBoards, nil } func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID string) ([]model.CategoryBoardMetadata, error) { query := s.getQueryBuilder(db). Select("board_id, COALESCE(hidden, false)"). From(s.tablePrefix + "category_boards"). Where(sq.Eq{ "category_id": categoryID, }). OrderBy("sort_order") rows, err := query.Query() if err != nil { s.logger.Error("getCategoryBoards error fetching categoryblocks", mlog.String("categoryID", categoryID), mlog.Err(err)) return nil, err } return s.categoryBoardsFromRows(rows) } func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID, categoryID string, boardIDsParam []string) error { // we need to de-duplicate this array as Postgres failes to // handle upsert if there are multiple incoming rows // that conflict the same existing row. // For example, having the entry "1" in DB and trying to upsert "1" and "1" will fail // as there are multiple duplicates of the same "1". // // Source: https://stackoverflow.com/questions/42994373/postgresql-on-conflict-cannot-affect-row-a-second-time boardIDs := utils.DedupeStringArr(boardIDsParam) if len(boardIDs) == 0 { return nil } query := s.getQueryBuilder(db). Insert(s.tablePrefix+"category_boards"). Columns( "id", "user_id", "category_id", "board_id", "create_at", "update_at", "sort_order", "hidden", ) now := utils.GetMillis() for _, boardID := range boardIDs { query = query.Values( utils.NewID(utils.IDTypeNone), userID, categoryID, boardID, now, now, 0, false, ) } if s.dbType == model.MysqlDBType { query = query.Suffix( "ON DUPLICATE KEY UPDATE category_id = ?", categoryID, ) } else { query = query.Suffix( `ON CONFLICT (user_id, board_id) DO UPDATE SET category_id = EXCLUDED.category_id, update_at = EXCLUDED.update_at`, ) } if _, err := query.Exec(); err != nil { return fmt.Errorf( "store addUpdateCategoryBoard: failed to upsert user-board-category userID: %s, categoryID: %s, board_count: %d, error: %w", userID, categoryID, len(boardIDs), err, ) } return nil } func (s *SQLStore) categoryBoardsFromRows(rows *sql.Rows) ([]model.CategoryBoardMetadata, error) { metadata := []model.CategoryBoardMetadata{} for rows.Next() { datum := model.CategoryBoardMetadata{} err := rows.Scan(&datum.BoardID, &datum.Hidden) if err != nil { s.logger.Error("categoryBoardsFromRows row scan error", mlog.Err(err)) return nil, err } metadata = append(metadata, datum) } return metadata, nil } func (s *SQLStore) reorderCategoryBoards(db sq.BaseRunner, categoryID string, newBoardsOrder []string) ([]string, error) { if len(newBoardsOrder) == 0 { return nil, nil } updateCase := sq.Case("board_id") for i, boardID := range newBoardsOrder { updateCase = updateCase.When("'"+boardID+"'", sq.Expr(fmt.Sprintf("%d", i+model.CategoryBoardsSortOrderGap))) } updateCase.Else("sort_order") query := s.getQueryBuilder(db). Update(s.tablePrefix+"category_boards"). Set("sort_order", updateCase). Where(sq.Eq{ "category_id": categoryID, }) if _, err := query.Exec(); err != nil { s.logger.Error( "reorderCategoryBoards failed to update category board order", mlog.String("category_id", categoryID), mlog.Err(err), ) return nil, err } return newBoardsOrder, nil } func (s *SQLStore) setBoardVisibility(db sq.BaseRunner, userID, categoryID, boardID string, visible bool) error { query := s.getQueryBuilder(db). Update(s.tablePrefix+"category_boards"). Set("hidden", !visible). Where(sq.Eq{ "user_id": userID, "category_id": categoryID, "board_id": boardID, }) if _, err := query.Exec(); err != nil { s.logger.Error( "SQLStore setBoardVisibility: failed to update board visibility", mlog.String("user_id", userID), mlog.String("board_id", boardID), mlog.Bool("visible", visible), mlog.Err(err), ) return err } return nil } ================================================ FILE: server/services/store/sqlstore/cloud.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package sqlstore import ( "database/sql" "errors" "strconv" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" ) var ErrInvalidCardLimitValue = errors.New("card limit value is invalid") // activeCardsQuery applies the necessary filters to the query for it // to fetch an active cards window if the cardLimit is set, or all the // active cards if it's 0. func (s *SQLStore) activeCardsQuery(builder sq.StatementBuilderType, selectStr string, cardLimit int) sq.SelectBuilder { query := builder. Select(selectStr). From(s.tablePrefix + "blocks b"). Join(s.tablePrefix + "boards bd on b.board_id=bd.id"). Where(sq.Eq{ "b.delete_at": 0, "b.type": model.TypeCard, "bd.is_template": false, }) if cardLimit != 0 { query = query. Limit(1). Offset(uint64(cardLimit - 1)) } return query } // getUsedCardsCount returns the amount of active cards in the server. func (s *SQLStore) getUsedCardsCount(db sq.BaseRunner) (int, error) { row := s.activeCardsQuery(s.getQueryBuilder(db), "count(b.id)", 0). QueryRow() var usedCards int err := row.Scan(&usedCards) if err != nil { return 0, err } return usedCards, nil } // getCardLimitTimestamp returns the timestamp value from the // system_settings table or zero if it doesn't exist. func (s *SQLStore) getCardLimitTimestamp(db sq.BaseRunner) (int64, error) { scanner := s.getQueryBuilder(db). Select("value"). From(s.tablePrefix + "system_settings"). Where(sq.Eq{"id": store.CardLimitTimestampSystemKey}). QueryRow() var result string err := scanner.Scan(&result) if errors.Is(sql.ErrNoRows, err) { return 0, nil } if err != nil { return 0, err } cardLimitTimestamp, err := strconv.Atoi(result) if err != nil { return 0, ErrInvalidCardLimitValue } return int64(cardLimitTimestamp), nil } // updateCardLimitTimestamp updates the card limit value in the // system_settings table with the timestamp of the nth last updated // card, being nth the value of the cardLimit parameter. If cardLimit // is zero, the timestamp will be set to zero. func (s *SQLStore) updateCardLimitTimestamp(db sq.BaseRunner, cardLimit int) (int64, error) { query := s.getQueryBuilder(db). Insert(s.tablePrefix+"system_settings"). Columns("id", "value") var value interface{} = 0 if cardLimit != 0 { value = s.activeCardsQuery(sq.StatementBuilder, "b.update_at", cardLimit). OrderBy("b.update_at DESC"). Prefix("COALESCE((").Suffix("), 0)") } query = query.Values(store.CardLimitTimestampSystemKey, value) if s.dbType == model.MysqlDBType { query = query.Suffix("ON DUPLICATE KEY UPDATE value = ?", value) } else { query = query.Suffix( `ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value`, ) } result, err := query.Exec() if err != nil { return 0, err } if _, err := result.RowsAffected(); err != nil { return 0, err } return s.getCardLimitTimestamp(db) } ================================================ FILE: server/services/store/sqlstore/compliance.go ================================================ package sqlstore import ( "database/sql" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (s *SQLStore) getBoardsForCompliance(db sq.BaseRunner, opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) { query := s.getQueryBuilder(db). Select(boardFields("b.")...). From(s.tablePrefix + "boards as b") if opts.TeamID != "" { query = query.Where(sq.Eq{"b.team_id": opts.TeamID}) } if opts.Page != 0 { query = query.Offset(uint64(opts.Page * opts.PerPage)) } if opts.PerPage > 0 { // N+1 to check if there's a next page for pagination query = query.Limit(uint64(opts.PerPage) + 1) } rows, err := query.Query() if err != nil { s.logger.Error(`GetBoardsForCompliance ERROR`, mlog.Err(err)) return nil, false, err } defer s.CloseRows(rows) boards, err := s.boardsFromRows(rows) if err != nil { return nil, false, err } var hasMore bool if opts.PerPage > 0 && len(boards) > opts.PerPage { boards = boards[0:opts.PerPage] hasMore = true } return boards, hasMore, nil } func (s *SQLStore) getBoardsComplianceHistory(db sq.BaseRunner, opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) { queryDescendentLastUpdate := s.getQueryBuilder(db). Select("MAX(blk1.update_at)"). From(s.tablePrefix + "blocks_history as blk1"). Where("blk1.board_id=bh.id") if !opts.IncludeDeleted { queryDescendentLastUpdate.Where(sq.Eq{"blk1.delete_at": 0}) } sqlDescendentLastUpdate, _, _ := queryDescendentLastUpdate.ToSql() queryDescendentFirstUpdate := s.getQueryBuilder(db). Select("MIN(blk2.update_at)"). From(s.tablePrefix + "blocks_history as blk2"). Where("blk2.board_id=bh.id") if !opts.IncludeDeleted { queryDescendentFirstUpdate.Where(sq.Eq{"blk2.delete_at": 0}) } sqlDescendentFirstUpdate, _, _ := queryDescendentFirstUpdate.ToSql() query := s.getQueryBuilder(db). Select( "bh.id", "bh.team_id", "CASE WHEN bh.delete_at=0 THEN false ELSE true END AS isDeleted", "COALESCE(("+sqlDescendentLastUpdate+"),0) as decendentLastUpdateAt", "COALESCE(("+sqlDescendentFirstUpdate+"),0) as decendentFirstUpdateAt", "bh.created_by", "bh.modified_by", ). From(s.tablePrefix + "boards_history as bh") if !opts.IncludeDeleted { // filtering out deleted boards; join with boards table to ensure no history // for deleted boards are returned. Deleted boards won't exist in boards table. query = query.Join(s.tablePrefix + "boards as b ON b.id=bh.id") } query = query.Where(sq.Gt{"bh.update_at": opts.ModifiedSince}). GroupBy("bh.id", "bh.team_id", "bh.delete_at", "bh.created_by", "bh.modified_by"). OrderBy("decendentLastUpdateAt desc", "bh.id") if opts.TeamID != "" { query = query.Where(sq.Eq{"bh.team_id": opts.TeamID}) } if opts.Page != 0 { query = query.Offset(uint64(opts.Page * opts.PerPage)) } if opts.PerPage > 0 { // N+1 to check if there's a next page for pagination query = query.Limit(uint64(opts.PerPage) + 1) } rows, err := query.Query() if err != nil { s.logger.Error(`GetBoardsComplianceHistory ERROR`, mlog.Err(err)) return nil, false, err } defer s.CloseRows(rows) history, err := s.boardsHistoryFromRows(rows) if err != nil { return nil, false, err } var hasMore bool if opts.PerPage > 0 && len(history) > opts.PerPage { history = history[0:opts.PerPage] hasMore = true } return history, hasMore, nil } func (s *SQLStore) getBlocksComplianceHistory(db sq.BaseRunner, opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) { query := s.getQueryBuilder(db). Select( "bh.id", "brd.team_id", "bh.board_id", "bh.type", "CASE WHEN bh.delete_at=0 THEN false ELSE true END AS isDeleted", "max(bh.update_at) as lastUpdateAt", "min(bh.update_at) as firstUpdateAt", "bh.created_by", "bh.modified_by", ). From(s.tablePrefix + "blocks_history as bh"). Join(s.tablePrefix + "boards_history as brd on brd.id=bh.board_id") if !opts.IncludeDeleted { // filtering out deleted blocks; join with blocks table to ensure no history // for deleted blocks are returned. Deleted blocks won't exist in blocks table. query = query.Join(s.tablePrefix + "blocks as b ON b.id=bh.id") } query = query.Where(sq.Gt{"bh.update_at": opts.ModifiedSince}). GroupBy("bh.id", "brd.team_id", "bh.board_id", "bh.type", "bh.delete_at", "bh.created_by", "bh.modified_by"). OrderBy("lastUpdateAt desc", "bh.id") if opts.TeamID != "" { query = query.Where(sq.Eq{"brd.team_id": opts.TeamID}) } if opts.BoardID != "" { query = query.Where(sq.Eq{"bh.board_id": opts.BoardID}) } if opts.Page != 0 { query = query.Offset(uint64(opts.Page * opts.PerPage)) } if opts.PerPage > 0 { // N+1 to check if there's a next page for pagination query = query.Limit(uint64(opts.PerPage) + 1) } rows, err := query.Query() if err != nil { s.logger.Error(`GetBlocksComplianceHistory ERROR`, mlog.Err(err)) return nil, false, err } defer s.CloseRows(rows) history, err := s.blocksHistoryFromRows(rows) if err != nil { return nil, false, err } var hasMore bool if opts.PerPage > 0 && len(history) > opts.PerPage { history = history[0:opts.PerPage] hasMore = true } return history, hasMore, nil } func (s *SQLStore) boardsHistoryFromRows(rows *sql.Rows) ([]*model.BoardHistory, error) { history := []*model.BoardHistory{} for rows.Next() { boardHistory := &model.BoardHistory{} err := rows.Scan( &boardHistory.ID, &boardHistory.TeamID, &boardHistory.IsDeleted, &boardHistory.DescendantLastUpdateAt, &boardHistory.DescendantFirstUpdateAt, &boardHistory.CreatedBy, &boardHistory.LastModifiedBy, ) if err != nil { s.logger.Error("boardsHistoryFromRows scan error", mlog.Err(err)) return nil, err } history = append(history, boardHistory) } return history, nil } func (s *SQLStore) blocksHistoryFromRows(rows *sql.Rows) ([]*model.BlockHistory, error) { history := []*model.BlockHistory{} for rows.Next() { blockHistory := &model.BlockHistory{} err := rows.Scan( &blockHistory.ID, &blockHistory.TeamID, &blockHistory.BoardID, &blockHistory.Type, &blockHistory.IsDeleted, &blockHistory.LastUpdateAt, &blockHistory.FirstUpdateAt, &blockHistory.CreatedBy, &blockHistory.LastModifiedBy, ) if err != nil { s.logger.Error("blocksHistoryFromRows scan error", mlog.Err(err)) return nil, err } history = append(history, blockHistory) } return history, nil } ================================================ FILE: server/services/store/sqlstore/data_migrations.go ================================================ package sqlstore import ( "context" "fmt" "os" "strconv" "strings" sq "github.com/Masterminds/squirrel" "github.com/wiggin77/merror" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const ( // we group the inserts on batches of 1000 because PostgreSQL // supports a limit of around 64K values (not rows) on an insert // query, so we want to stay safely below. CategoryInsertBatch = 1000 TemplatesToTeamsMigrationKey = "TemplatesToTeamsMigrationComplete" UniqueIDsMigrationKey = "UniqueIDsMigrationComplete" CategoryUUIDIDMigrationKey = "CategoryUuidIdMigrationComplete" TeamLessBoardsMigrationKey = "TeamLessBoardsMigrationComplete" DeletedMembershipBoardsMigrationKey = "DeletedMembershipBoardsMigrationComplete" DeDuplicateCategoryBoardTableMigrationKey = "DeDuplicateCategoryBoardTableComplete" ) func (s *SQLStore) getBlocksWithSameID(db sq.BaseRunner) ([]*model.Block, error) { subquery, _, _ := s.getQueryBuilder(db). Select("id"). From(s.tablePrefix + "blocks"). Having("count(id) > 1"). GroupBy("id"). ToSql() blocksFields := []string{ "id", "parent_id", "root_id", "created_by", "modified_by", s.escapeField("schema"), "type", "title", "COALESCE(fields, '{}')", s.timestampToCharField("insert_at", "insertAt"), "create_at", "update_at", "delete_at", "COALESCE(workspace_id, '0')", } rows, err := s.getQueryBuilder(db). Select(blocksFields...). From(s.tablePrefix + "blocks"). Where(fmt.Sprintf("id IN (%s)", subquery)). Query() if err != nil { s.logger.Error(`getBlocksWithSameID ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) return s.blocksFromRows(rows) } func (s *SQLStore) RunUniqueIDsMigration() error { setting, err := s.GetSystemSetting(UniqueIDsMigrationKey) if err != nil { return fmt.Errorf("cannot get migration state: %w", err) } // If the migration is already completed, do not run it again. if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun { return nil } s.logger.Debug("Running Unique IDs migration") tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } blocks, err := s.getBlocksWithSameID(tx) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "getBlocksWithSameID")) } return fmt.Errorf("cannot get blocks with same ID: %w", err) } blocksByID := map[string][]*model.Block{} for _, block := range blocks { blocksByID[block.ID] = append(blocksByID[block.ID], block) } for _, blocks := range blocksByID { for i, block := range blocks { if i == 0 { // do nothing for the first ID, only updating the others continue } newID := utils.NewID(model.BlockType2IDType(block.Type)) if err := s.replaceBlockID(tx, block.ID, newID, block.WorkspaceID); err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "replaceBlockID")) } return fmt.Errorf("cannot replace blockID %s: %w", block.ID, err) } } } if err := s.setSystemSetting(tx, UniqueIDsMigrationKey, strconv.FormatBool(true)); err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting")) } return fmt.Errorf("cannot mark migration as completed: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("cannot commit unique IDs transaction: %w", err) } s.logger.Debug("Unique IDs migration finished successfully") return nil } // RunCategoryUUIDIDMigration takes care of deriving the categories // from the boards and its memberships. The name references UUID // because of the preexisting purpose of this migration, and has been // preserved for compatibility with already migrated instances. func (s *SQLStore) RunCategoryUUIDIDMigration() error { setting, err := s.GetSystemSetting(CategoryUUIDIDMigrationKey) if err != nil { return fmt.Errorf("cannot get migration state: %w", err) } // If the migration is already completed, do not run it again. if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun { return nil } s.logger.Debug("Running category UUID ID migration") tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } if err := s.setSystemSetting(tx, CategoryUUIDIDMigrationKey, strconv.FormatBool(true)); err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("category UUIDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting")) } return fmt.Errorf("cannot mark migration as completed: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("cannot commit category UUIDs transaction: %w", err) } s.logger.Debug("category UUIDs migration finished successfully") return nil } func (s *SQLStore) RunFixCollationsAndCharsetsMigration() error { // This is for MySQL only if s.dbType != model.MysqlDBType { return nil } // get collation and charSet setting that Channels is using. // when personal server or unit testing, no channels tables exist so just set to a default. var collation string var charSet string var err error if os.Getenv("FOCALBOARD_UNIT_TESTING") == "1" { collation = "utf8mb4_general_ci" charSet = "utf8mb4" } else { collation, charSet, err = s.getCollationAndCharset("Channels") if err != nil { return err } } // get all FocalBoard tables tableNames, err := s.getFocalBoardTableNames() if err != nil { return err } merr := merror.New() // alter each table if there is a collation or charset mismatch for _, name := range tableNames { tableCollation, tableCharSet, err := s.getCollationAndCharset(name) if err != nil { return err } if collation == tableCollation && charSet == tableCharSet { // nothing to do continue } s.logger.Warn( "found collation/charset mismatch, fixing table", mlog.String("tableName", name), mlog.String("tableCollation", tableCollation), mlog.String("tableCharSet", tableCharSet), mlog.String("collation", collation), mlog.String("charSet", charSet), ) sql := fmt.Sprintf("ALTER TABLE %s CONVERT TO CHARACTER SET '%s' COLLATE '%s'", name, charSet, collation) result, err := s.db.Exec(sql) if err != nil { merr.Append(err) continue } num, err := result.RowsAffected() if err != nil { merr.Append(err) } if num > 0 { s.logger.Debug("table collation and/or charSet fixed", mlog.String("table_name", name), ) } } return merr.ErrorOrNil() } func (s *SQLStore) getFocalBoardTableNames() ([]string, error) { if s.dbType != model.MysqlDBType { return nil, newErrInvalidDBType("getFocalBoardTableNames requires MySQL") } query := s.getQueryBuilder(s.db). Select("table_name"). From("information_schema.tables"). Where(sq.Like{"table_name": s.tablePrefix + "%"}). Where("table_schema=(SELECT DATABASE())") rows, err := query.Query() if err != nil { return nil, fmt.Errorf("error fetching FocalBoard table names: %w", err) } defer rows.Close() names := make([]string, 0) for rows.Next() { var tableName string err := rows.Scan(&tableName) if err != nil { return nil, fmt.Errorf("cannot scan result while fetching table names: %w", err) } names = append(names, tableName) } return names, nil } func (s *SQLStore) getCollationAndCharset(tableName string) (string, string, error) { if s.dbType != model.MysqlDBType { return "", "", newErrInvalidDBType("getCollationAndCharset requires MySQL") } query := s.getQueryBuilder(s.db). Select("table_collation"). From("information_schema.tables"). Where(sq.Eq{"table_name": tableName}). Where("table_schema=(SELECT DATABASE())") row := query.QueryRow() var collation string err := row.Scan(&collation) if err != nil { return "", "", fmt.Errorf("error fetching collation for table %s: %w", tableName, err) } // obtains the charset from the first column that has it set query = s.getQueryBuilder(s.db). Select("CHARACTER_SET_NAME"). From("information_schema.columns"). Where(sq.Eq{ "table_name": tableName, }). Where("table_schema=(SELECT DATABASE())"). Where(sq.NotEq{"CHARACTER_SET_NAME": "NULL"}). Limit(1) row = query.QueryRow() var charSet string err = row.Scan(&charSet) if err != nil { return "", "", fmt.Errorf("error fetching charSet: %w", err) } return collation, charSet, nil } func (s *SQLStore) RunDeDuplicateCategoryBoardsMigration(currentMigration int) error { // not supported for SQLite if s.dbType == model.SqliteDBType { if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil { return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr) } return nil } setting, err := s.GetSystemSetting(DeDuplicateCategoryBoardTableMigrationKey) if err != nil { return fmt.Errorf("cannot get DeDuplicateCategoryBoardTableMigration state: %w", err) } // If the migration is already completed, do not run it again. if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun { return nil } if currentMigration >= (deDuplicateCategoryBoards + 1) { // if the migration for which we're fixing the data is already applied, // no need to check fix anything if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil { return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr) } return nil } needed, err := s.doesDuplicateCategoryBoardsExist() if err != nil { return err } if !needed { if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil { return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr) } } if s.dbType == model.MysqlDBType { return s.runMySQLDeDuplicateCategoryBoardsMigration() } else if s.dbType == model.PostgresDBType { return s.runPostgresDeDuplicateCategoryBoardsMigration() } if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil { return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr) } return nil } func (s *SQLStore) doesDuplicateCategoryBoardsExist() (bool, error) { subQuery := s.getQueryBuilder(s.db). Select("user_id", "board_id", "count(*) AS count"). From(s.tablePrefix+"category_boards"). GroupBy("user_id", "board_id"). Having("count(*) > 1") query := s.getQueryBuilder(s.db). Select("COUNT(user_id)"). FromSelect(subQuery, "duplicate_dataset") row := query.QueryRow() count := 0 if err := row.Scan(&count); err != nil { s.logger.Error("Error occurred reading number of duplicate records in category_boards table", mlog.Err(err)) return false, err } return count > 0, nil } func (s *SQLStore) runMySQLDeDuplicateCategoryBoardsMigration() error { tablePrefix := s.tablePrefix var queryBuilder strings.Builder queryBuilder.WriteString("DELETE FROM ") queryBuilder.WriteString(tablePrefix) queryBuilder.WriteString("category_boards WHERE id NOT IN ") queryBuilder.WriteString("(SELECT * FROM ( SELECT min(id) FROM ") queryBuilder.WriteString(tablePrefix) queryBuilder.WriteString("category_boards GROUP BY user_id, board_id ) as data)") query := queryBuilder.String() if _, err := s.db.Exec(query); err != nil { s.logger.Error("Failed to de-duplicate data in category_boards table", mlog.Err(err)) } return nil } func (s *SQLStore) runPostgresDeDuplicateCategoryBoardsMigration() error { tablePrefix := s.tablePrefix var queryBuilder strings.Builder queryBuilder.WriteString("WITH duplicates AS (SELECT id, ROW_NUMBER() OVER(PARTITION BY user_id, board_id) AS rownum ") queryBuilder.WriteString("FROM ") queryBuilder.WriteString(tablePrefix) queryBuilder.WriteString("category_boards) ") queryBuilder.WriteString("DELETE FROM ") queryBuilder.WriteString(tablePrefix) queryBuilder.WriteString("category_boards USING duplicates ") queryBuilder.WriteString("WHERE ") queryBuilder.WriteString(tablePrefix) queryBuilder.WriteString("category_boards.id = duplicates.id AND duplicates.rownum > 1;") query := queryBuilder.String() if _, err := s.db.Exec(query); err != nil { s.logger.Error("Failed to de-duplicate data in category_boards table", mlog.Err(err)) } return nil } ================================================ FILE: server/services/store/sqlstore/data_migrations_test.go ================================================ package sqlstore import ( "testing" "time" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetBlocksWithSameID(t *testing.T) { t.Skip("we need to setup a test with the database migrated up to version 14 and then run these tests") store, tearDown := SetupTests(t) sqlStore := store.(*SQLStore) defer tearDown() container1 := "1" container2 := "2" container3 := "3" block1 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"} block2 := &model.Block{ID: "block-id-2", BoardID: "board-id-2"} block3 := &model.Block{ID: "block-id-3", BoardID: "board-id-3"} block4 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"} block5 := &model.Block{ID: "block-id-2", BoardID: "board-id-2"} block6 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"} block7 := &model.Block{ID: "block-id-7", BoardID: "board-id-7"} block8 := &model.Block{ID: "block-id-8", BoardID: "board-id-8"} for _, block := range []*model.Block{block1, block2, block3} { err := sqlStore.insertLegacyBlock(sqlStore.db, container1, block, "user-id") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } for _, block := range []*model.Block{block4, block5} { err := sqlStore.insertLegacyBlock(sqlStore.db, container2, block, "user-id") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } for _, block := range []*model.Block{block6, block7, block8} { err := sqlStore.insertLegacyBlock(sqlStore.db, container3, block, "user-id") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } blocksWithDuplicatedID := []*model.Block{block1, block2, block4, block5, block6} blocks, err := sqlStore.getBlocksWithSameID(sqlStore.db) require.NoError(t, err) // we process the found blocks to remove extra information and be // able to compare both expected and found sets foundBlocks := []*model.Block{} for _, foundBlock := range blocks { foundBlocks = append(foundBlocks, &model.Block{ID: foundBlock.ID, BoardID: foundBlock.BoardID}) } require.ElementsMatch(t, blocksWithDuplicatedID, foundBlocks) } func TestReplaceBlockID(t *testing.T) { t.Skip("we need to setup a test with the database migrated up to version 14 and then run these tests") store, tearDown := SetupTests(t) sqlStore := store.(*SQLStore) defer tearDown() container1 := "1" container2 := "2" // blocks from team1 block1 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"} block2 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"} block3 := &model.Block{ID: "block-id-3", BoardID: "block-id-1"} block4 := &model.Block{ID: "block-id-4", BoardID: "block-id-2"} block5 := &model.Block{ID: "block-id-5", BoardID: "block-id-1", ParentID: "block-id-1"} block8 := &model.Block{ ID: "block-id-8", BoardID: "board-id-2", Type: model.TypeCard, Fields: map[string]interface{}{"contentOrder": []string{"block-id-1", "block-id-2"}}, } // blocks from team2. They're identical to blocks 1 and 2, // but they shouldn't change block6 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"} block7 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"} block9 := &model.Block{ ID: "block-id-8", BoardID: "board-id-2", Type: model.TypeCard, Fields: map[string]interface{}{"contentOrder": []string{"block-id-1", "block-id-2"}}, } for _, block := range []*model.Block{block1, block2, block3, block4, block5, block8} { err := sqlStore.insertLegacyBlock(sqlStore.db, container1, block, "user-id") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } for _, block := range []*model.Block{block6, block7, block9} { err := sqlStore.insertLegacyBlock(sqlStore.db, container2, block, "user-id") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } currentID := "block-id-1" newID := "new-id-1" err := sqlStore.replaceBlockID(sqlStore.db, currentID, newID, "1") require.NoError(t, err) newBlock1, err := sqlStore.getLegacyBlock(sqlStore.db, container1, newID) require.NoError(t, err) newBlock2, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block2.ID) require.NoError(t, err) newBlock3, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block3.ID) require.NoError(t, err) newBlock5, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block5.ID) require.NoError(t, err) newBlock6, err := sqlStore.getLegacyBlock(sqlStore.db, container2, block6.ID) require.NoError(t, err) newBlock7, err := sqlStore.getLegacyBlock(sqlStore.db, container2, block7.ID) require.NoError(t, err) newBlock8, err := sqlStore.GetBlock(block8.ID) require.NoError(t, err) newBlock9, err := sqlStore.GetBlock(block9.ID) require.NoError(t, err) require.Equal(t, newID, newBlock1.ID) require.Equal(t, newID, newBlock2.ParentID) require.Equal(t, newID, newBlock3.BoardID) require.Equal(t, newID, newBlock5.BoardID) require.Equal(t, newID, newBlock5.ParentID) require.Equal(t, newBlock8.Fields["contentOrder"].([]interface{})[0], newID) require.Equal(t, newBlock8.Fields["contentOrder"].([]interface{})[1], "block-id-2") require.Equal(t, currentID, newBlock6.ID) require.Equal(t, currentID, newBlock7.ParentID) require.Equal(t, newBlock9.Fields["contentOrder"].([]interface{})[0], "block-id-1") require.Equal(t, newBlock9.Fields["contentOrder"].([]interface{})[1], "block-id-2") } func TestRunUniqueIDsMigration(t *testing.T) { t.Skip("we need to setup a test with the database migrated up to version 14 and then run these tests") store, tearDown := SetupTests(t) sqlStore := store.(*SQLStore) defer tearDown() // we need to mark the migration as not done so we can run it // again with the test data keyErr := sqlStore.SetSystemSetting(UniqueIDsMigrationKey, "false") require.NoError(t, keyErr) container1 := "1" container2 := "2" container3 := "3" // blocks from workspace1. They shouldn't change, as the first // duplicated ID is preserved block1 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"} block2 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"} block3 := &model.Block{ID: "block-id-3", BoardID: "block-id-1"} // blocks from workspace2. They're identical to blocks 1, 2 and 3, // and they should change block4 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"} block5 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"} block6 := &model.Block{ID: "block-id-6", BoardID: "block-id-1", ParentID: "block-id-2"} // block from workspace3. It should change as well block7 := &model.Block{ID: "block-id-2", BoardID: "board-id-2"} for _, block := range []*model.Block{block1, block2, block3} { err := sqlStore.insertLegacyBlock(sqlStore.db, container1, block, "user-id-2") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } for _, block := range []*model.Block{block4, block5, block6} { err := sqlStore.insertLegacyBlock(sqlStore.db, container2, block, "user-id-2") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } for _, block := range []*model.Block{block7} { err := sqlStore.insertLegacyBlock(sqlStore.db, container3, block, "user-id-2") require.NoError(t, err) time.Sleep(100 * time.Millisecond) } err := sqlStore.RunUniqueIDsMigration() require.NoError(t, err) // blocks from workspace 1 haven't changed, so we can simply fetch them newBlock1, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block1.ID) require.NoError(t, err) require.NotNil(t, newBlock1) newBlock2, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block2.ID) require.NoError(t, err) require.NotNil(t, newBlock2) newBlock3, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block3.ID) require.NoError(t, err) require.NotNil(t, newBlock3) // first two blocks from workspace 2 have changed, so we fetch // them through the third one, which points to the new IDs newBlock6, err := sqlStore.getLegacyBlock(sqlStore.db, container2, block6.ID) require.NoError(t, err) require.NotNil(t, newBlock6) newBlock4, err := sqlStore.getLegacyBlock(sqlStore.db, container2, newBlock6.BoardID) require.NoError(t, err) require.NotNil(t, newBlock4) newBlock5, err := sqlStore.getLegacyBlock(sqlStore.db, container2, newBlock6.ParentID) require.NoError(t, err) require.NotNil(t, newBlock5) // block from workspace 3 changed as well, so we shouldn't be able // to fetch it newBlock7, err := sqlStore.getLegacyBlock(sqlStore.db, container3, block7.ID) require.NoError(t, err) require.Nil(t, newBlock7) // workspace 1 block links are maintained require.Equal(t, newBlock1.ID, newBlock2.ParentID) require.Equal(t, newBlock1.ID, newBlock3.BoardID) // workspace 2 first two block IDs have changed require.NotEqual(t, block4.ID, newBlock4.BoardID) require.NotEqual(t, block5.ID, newBlock5.ParentID) } func TestCheckForMismatchedCollation(t *testing.T) { store, tearDown := SetupTests(t) sqlStore := store.(*SQLStore) defer tearDown() if sqlStore.dbType != model.MysqlDBType { return } // make sure all collations are consistent. tableNames, err := sqlStore.getFocalBoardTableNames() require.NoError(t, err) sqlCollation := "SELECT table_collation FROM information_schema.tables WHERE table_name=? and table_schema=(SELECT DATABASE())" stmtCollation, err := sqlStore.db.Prepare(sqlCollation) require.NoError(t, err) defer stmtCollation.Close() var collation string // make sure the correct charset is applied to each table. for i, name := range tableNames { row := stmtCollation.QueryRow(name) var actualCollation string err = row.Scan(&actualCollation) require.NoError(t, err) if collation == "" { collation = actualCollation } assert.Equalf(t, collation, actualCollation, "for table_name='%s', index=%d", name, i) } } ================================================ FILE: server/services/store/sqlstore/data_retention.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package sqlstore import ( "database/sql" "strings" "time" "github.com/pkg/errors" sq "github.com/Masterminds/squirrel" _ "github.com/lib/pq" // postgres driver "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type RetentionTableDeletionInfo struct { Table string PrimaryKeys []string BoardIDColumn string } func (s *SQLStore) runDataRetention(db sq.BaseRunner, globalRetentionDate int64, batchSize int64) (int64, error) { s.logger.Info("Start Boards Data Retention", mlog.String("Global Retention Date", time.Unix(globalRetentionDate/1000, 0).String()), mlog.Int("Raw Date", globalRetentionDate)) deleteTables := []RetentionTableDeletionInfo{ { Table: "blocks", PrimaryKeys: []string{"id"}, BoardIDColumn: "board_id", }, { Table: "blocks_history", PrimaryKeys: []string{"id"}, BoardIDColumn: "board_id", }, { Table: "boards", PrimaryKeys: []string{"id"}, BoardIDColumn: "id", }, { Table: "boards_history", PrimaryKeys: []string{"id"}, BoardIDColumn: "id", }, { Table: "board_members", PrimaryKeys: []string{"board_id"}, BoardIDColumn: "board_id", }, { Table: "board_members_history", PrimaryKeys: []string{"board_id"}, BoardIDColumn: "board_id", }, { Table: "sharing", PrimaryKeys: []string{"id"}, BoardIDColumn: "id", }, { Table: "category_boards", PrimaryKeys: []string{"id"}, BoardIDColumn: "board_id", }, } subBuilder := s.getQueryBuilder(db). Select("board_id, MAX(update_at) AS maxDate"). From(s.tablePrefix + "blocks"). GroupBy("board_id") subQuery, _, _ := subBuilder.ToSql() builder := s.getQueryBuilder(db). Select("id"). From(s.tablePrefix + "boards"). LeftJoin("( " + subQuery + " ) As subquery ON (subquery.board_id = id)"). Where(sq.Lt{"maxDate": globalRetentionDate}). Where(sq.NotEq{"team_id": "0"}). Where(sq.Eq{"is_template": false}) rows, err := builder.Query() if err != nil { s.logger.Error(`dataRetention subquery ERROR`, mlog.Err(err)) return 0, err } defer s.CloseRows(rows) deleteIds, err := idsFromRows(rows) if err != nil { return 0, err } totalAffected := 0 if len(deleteIds) > 0 { for _, table := range deleteTables { affected, err := s.genericRetentionPoliciesDeletion(db, table, deleteIds, batchSize) if err != nil { return int64(totalAffected), err } totalAffected += int(affected) } } s.logger.Info("Complete Boards Data Retention", mlog.Int("Total deletion ids", len(deleteIds)), mlog.Int("TotalAffected", totalAffected)) return int64(totalAffected), nil } func idsFromRows(rows *sql.Rows) ([]string, error) { deleteIds := []string{} for rows.Next() { var boardID string err := rows.Scan( &boardID, ) if err != nil { return nil, err } deleteIds = append(deleteIds, boardID) } return deleteIds, nil } // genericRetentionPoliciesDeletion actually executes the DELETE query // using a sq.SelectBuilder which selects the rows to delete. func (s *SQLStore) genericRetentionPoliciesDeletion( db sq.BaseRunner, info RetentionTableDeletionInfo, deleteIds []string, batchSize int64, ) (int64, error) { whereClause := info.BoardIDColumn + " IN ('" + strings.Join(deleteIds, "','") + "')" deleteQuery := s.getQueryBuilder(db). Delete(s.tablePrefix + info.Table). Where(whereClause) if batchSize > 0 { deleteQuery.Limit(uint64(batchSize)) primaryKeysStr := "(" + strings.Join(info.PrimaryKeys, ",") + ")" if s.dbType != model.MysqlDBType { selectQuery := s.getQueryBuilder(db). Select(primaryKeysStr). From(s.tablePrefix + info.Table). Where(whereClause). Limit(uint64(batchSize)) selectString, _, _ := selectQuery.ToSql() deleteQuery = s.getQueryBuilder(db). Delete(s.tablePrefix + info.Table). Where(primaryKeysStr + " IN (" + selectString + ")") } } var totalRowsAffected int64 var batchRowsAffected int64 for { result, err := deleteQuery.Exec() if err != nil { return 0, errors.Wrap(err, "failed to delete "+info.Table) } batchRowsAffected, err = result.RowsAffected() if err != nil { return 0, errors.Wrap(err, "failed to get rows affected for "+info.Table) } totalRowsAffected += batchRowsAffected if batchRowsAffected != batchSize { break } } return totalRowsAffected, nil } ================================================ FILE: server/services/store/sqlstore/file.go ================================================ package sqlstore import ( "database/sql" "errors" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (s *SQLStore) saveFileInfo(db sq.BaseRunner, fileInfo *mmModel.FileInfo) error { query := s.getQueryBuilder(db). Insert(s.tablePrefix+"file_info"). Columns( "id", "create_at", "name", "extension", "size", "delete_at", "path", "archived", ). Values( fileInfo.Id, fileInfo.CreateAt, fileInfo.Name, fileInfo.Extension, fileInfo.Size, fileInfo.DeleteAt, fileInfo.Path, false, ) if _, err := query.Exec(); err != nil { s.logger.Error( "failed to save fileinfo", mlog.String("file_name", fileInfo.Name), mlog.Int("size", fileInfo.Size), mlog.Err(err), ) return err } return nil } func (s *SQLStore) getFileInfo(db sq.BaseRunner, id string) (*mmModel.FileInfo, error) { query := s.getQueryBuilder(db). Select( "id", "create_at", "delete_at", "name", "extension", "size", "archived", "path", ). From(s.tablePrefix + "file_info"). Where(sq.Eq{"Id": id}) row := query.QueryRow() fileInfo := mmModel.FileInfo{} err := row.Scan( &fileInfo.Id, &fileInfo.CreateAt, &fileInfo.DeleteAt, &fileInfo.Name, &fileInfo.Extension, &fileInfo.Size, &fileInfo.Archived, &fileInfo.Path, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, model.NewErrNotFound("file info ID=" + id) } s.logger.Error("error scanning fileinfo row", mlog.String("id", id), mlog.Err(err)) return nil, err } return &fileInfo, nil } ================================================ FILE: server/services/store/sqlstore/helpers_test.go ================================================ package sqlstore import ( "database/sql" "os" "testing" "github.com/mattermost/focalboard/server/services/store" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func SetupTests(t *testing.T) (store.Store, func()) { origUnitTesting := os.Getenv("FOCALBOARD_UNIT_TESTING") os.Setenv("FOCALBOARD_UNIT_TESTING", "1") dbType, connectionString, err := PrepareNewTestDatabase() require.NoError(t, err) logger, _ := mlog.NewLogger() sqlDB, err := sql.Open(dbType, connectionString) require.NoError(t, err) err = sqlDB.Ping() require.NoError(t, err) storeParams := Params{ DBType: dbType, ConnectionString: connectionString, DBPingAttempts: 5, TablePrefix: "test_", Logger: logger, DB: sqlDB, } store, err := New(storeParams) require.NoError(t, err) tearDown := func() { defer func() { _ = logger.Shutdown() }() err = store.Shutdown() require.Nil(t, err) if err = os.Remove(connectionString); err == nil { logger.Debug("Removed test database", mlog.String("file", connectionString)) } os.Setenv("FOCALBOARD_UNIT_TESTING", origUnitTesting) } return store, tearDown } ================================================ FILE: server/services/store/sqlstore/legacy_blocks.go ================================================ package sqlstore import ( "database/sql" "encoding/json" "github.com/mattermost/focalboard/server/utils" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // legacyBlocksFromRows is the old getBlock version that still uses // the old block model. This method is kept to enable the unique IDs // data migration. func (s *SQLStore) legacyBlocksFromRows(rows *sql.Rows) ([]*model.Block, error) { results := []*model.Block{} for rows.Next() { var block model.Block var fieldsJSON string var modifiedBy sql.NullString var insertAt string err := rows.Scan( &block.ID, &block.ParentID, &block.BoardID, &block.CreatedBy, &modifiedBy, &block.Schema, &block.Type, &block.Title, &fieldsJSON, &insertAt, &block.CreateAt, &block.UpdateAt, &block.DeleteAt, &block.WorkspaceID) if err != nil { // handle this error s.logger.Error(`ERROR blocksFromRows`, mlog.Err(err)) return nil, err } if modifiedBy.Valid { block.ModifiedBy = modifiedBy.String } err = json.Unmarshal([]byte(fieldsJSON), &block.Fields) if err != nil { // handle this error s.logger.Error(`ERROR blocksFromRows fields`, mlog.Err(err)) return nil, err } results = append(results, &block) } return results, nil } // getLegacyBlock is the old getBlock version that still uses the old // block model. This method is kept to enable the unique IDs data // migration. func (s *SQLStore) getLegacyBlock(db sq.BaseRunner, workspaceID string, blockID string) (*model.Block, error) { query := s.getQueryBuilder(db). Select( "id", "parent_id", "root_id", "created_by", "modified_by", s.escapeField("schema"), "type", "title", "COALESCE(fields, '{}')", "insert_at", "create_at", "update_at", "delete_at", "COALESCE(workspace_id, '0')", ). From(s.tablePrefix + "blocks"). Where(sq.Eq{"id": blockID}). Where(sq.Eq{"coalesce(workspace_id, '0')": workspaceID}) rows, err := query.Query() if err != nil { s.logger.Error(`GetBlock ERROR`, mlog.Err(err)) return nil, err } blocks, err := s.legacyBlocksFromRows(rows) if err != nil { return nil, err } if len(blocks) == 0 { return nil, nil } return blocks[0], nil } // insertLegacyBlock is the old insertBlock version that still uses // the old block model. This method is kept to enable the unique IDs // data migration. func (s *SQLStore) insertLegacyBlock(db sq.BaseRunner, workspaceID string, block *model.Block, userID string) error { if block.BoardID == "" { return model.ErrBlockEmptyBoardID } fieldsJSON, err := json.Marshal(block.Fields) if err != nil { return err } existingBlock, err := s.getLegacyBlock(db, workspaceID, block.ID) if err != nil { return err } block.UpdateAt = utils.GetMillis() block.ModifiedBy = userID insertQuery := s.getQueryBuilder(db).Insert(""). Columns( "workspace_id", "id", "parent_id", "root_id", "created_by", "modified_by", s.escapeField("schema"), "type", "title", "fields", "create_at", "update_at", "delete_at", ) insertQueryValues := map[string]interface{}{ "workspace_id": workspaceID, "id": block.ID, "parent_id": block.ParentID, "root_id": block.BoardID, s.escapeField("schema"): block.Schema, "type": block.Type, "title": block.Title, "fields": fieldsJSON, "delete_at": block.DeleteAt, "created_by": block.CreatedBy, "modified_by": block.ModifiedBy, "create_at": block.CreateAt, "update_at": block.UpdateAt, } if existingBlock != nil { // block with ID exists, so this is an update operation query := s.getQueryBuilder(db).Update(s.tablePrefix+"blocks"). Where(sq.Eq{"id": block.ID}). Where(sq.Eq{"COALESCE(workspace_id, '0')": workspaceID}). Set("parent_id", block.ParentID). Set("root_id", block.BoardID). Set("modified_by", block.ModifiedBy). Set(s.escapeField("schema"), block.Schema). Set("type", block.Type). Set("title", block.Title). Set("fields", fieldsJSON). Set("update_at", block.UpdateAt). Set("delete_at", block.DeleteAt) if _, err := query.Exec(); err != nil { s.logger.Error(`InsertBlock error occurred while updating existing block`, mlog.String("blockID", block.ID), mlog.Err(err)) return err } } else { block.CreatedBy = userID block.CreateAt = utils.GetMillis() insertQueryValues["created_by"] = block.CreatedBy insertQueryValues["create_at"] = block.CreateAt insertQueryValues["update_at"] = block.UpdateAt insertQueryValues["modified_by"] = block.ModifiedBy query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "blocks") if _, err := query.Exec(); err != nil { return err } } // writing block history query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "blocks_history") if _, err := query.Exec(); err != nil { return err } return nil } ================================================ FILE: server/services/store/sqlstore/migrate.go ================================================ package sqlstore import ( "bytes" "context" "database/sql" "embed" "fmt" "strings" "text/template" sq "github.com/Masterminds/squirrel" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" sqlUtils "github.com/mattermost/mattermost/server/public/utils/sql" "github.com/mattermost/morph" drivers "github.com/mattermost/morph/drivers" mysql "github.com/mattermost/morph/drivers/mysql" postgres "github.com/mattermost/morph/drivers/postgres" sqlite "github.com/mattermost/morph/drivers/sqlite" embedded "github.com/mattermost/morph/sources/embedded" _ "github.com/lib/pq" // postgres driver "github.com/mattermost/focalboard/server/model" ) //go:embed migrations/*.sql var Assets embed.FS const ( uniqueIDsMigrationRequiredVersion = 14 teamLessBoardsMigrationRequiredVersion = 18 categoriesUUIDIDMigrationRequiredVersion = 20 deDuplicateCategoryBoards = 35 tempSchemaMigrationTableName = "temp_schema_migration" ) // migrations in MySQL need to run with the multiStatements flag // enabled, so this method creates a new connection ensuring that it's // enabled. func (s *SQLStore) getMigrationConnection() (*sql.DB, error) { connectionString := s.connectionString if s.dbType == model.MysqlDBType { var err error connectionString, err = sqlUtils.ResetReadTimeout(connectionString) if err != nil { return nil, err } connectionString, err = sqlUtils.AppendMultipleStatementsFlag(connectionString) if err != nil { return nil, err } } var settings mmModel.SqlSettings settings.SetDefaults(false) if s.configFn != nil { settings = s.configFn().SqlSettings } *settings.DriverName = s.dbType db, _ := sqlUtils.SetupConnection(s.logger, "master", connectionString, &settings, s.dbPingAttempts) return db, nil } func (s *SQLStore) Migrate() error { if err := s.EnsureSchemaMigrationFormat(); err != nil { return err } defer func() { // the old schema migration table deletion happens after the // migrations have run, to be able to recover its information // in case there would be errors during the process. if err := s.deleteOldSchemaMigrationTable(); err != nil { s.logger.Error("cannot delete the old schema migration table", mlog.Err(err)) } }() var driver drivers.Driver var err error if s.dbType == model.SqliteDBType { driver, err = sqlite.WithInstance(s.db) if err != nil { return err } } var db *sql.DB if s.dbType != model.SqliteDBType { s.logger.Debug("Getting migrations connection") db, err = s.getMigrationConnection() if err != nil { return err } defer func() { s.logger.Debug("Closing migrations connection") db.Close() }() } if s.dbType == model.PostgresDBType { driver, err = postgres.WithInstance(db) if err != nil { return err } } if s.dbType == model.MysqlDBType { driver, err = mysql.WithInstance(db) if err != nil { return err } } assetsList, err := Assets.ReadDir("migrations") if err != nil { return err } assetNamesForDriver := make([]string, len(assetsList)) for i, dirEntry := range assetsList { assetNamesForDriver[i] = dirEntry.Name() } params := map[string]interface{}{ "prefix": s.tablePrefix, "postgres": s.dbType == model.PostgresDBType, "sqlite": s.dbType == model.SqliteDBType, "mysql": s.dbType == model.MysqlDBType, "singleUser": s.isSingleUser, } migrationAssets := &embedded.AssetSource{ Names: assetNamesForDriver, AssetFunc: func(name string) ([]byte, error) { asset, mErr := Assets.ReadFile("migrations/" + name) if mErr != nil { return nil, mErr } tmpl, pErr := template.New("sql").Funcs(s.GetTemplateHelperFuncs()).Parse(string(asset)) if pErr != nil { return nil, pErr } buffer := bytes.NewBufferString("") err = tmpl.Execute(buffer, params) if err != nil { return nil, err } s.logger.Trace("migration template", mlog.String("name", name), mlog.String("sql", buffer.String()), ) return buffer.Bytes(), nil }, } src, err := embedded.WithInstance(migrationAssets) if err != nil { return err } opts := []morph.EngineOption{ morph.WithLock("boards-lock-key"), morph.SetMigrationTableName(fmt.Sprintf("%sschema_migrations", s.tablePrefix)), morph.SetStatementTimeoutInSeconds(1000000), } if s.dbType == model.SqliteDBType { opts = opts[:0] // sqlite driver does not support locking, it doesn't need to anyway. } s.logger.Debug("Creating migration engine") engine, err := morph.New(context.Background(), driver, src, opts...) if err != nil { return err } defer func() { s.logger.Debug("Closing migration engine") engine.Close() }() return s.runMigrationSequence(engine, driver) } // runMigrationSequence executes all the migrations in order, both // plain SQL and data migrations. func (s *SQLStore) runMigrationSequence(engine *morph.Morph, driver drivers.Driver) error { if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, uniqueIDsMigrationRequiredVersion); mErr != nil { return mErr } if mErr := s.RunUniqueIDsMigration(); mErr != nil { return fmt.Errorf("error running unique IDs migration: %w", mErr) } if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, teamLessBoardsMigrationRequiredVersion); mErr != nil { return mErr } if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, categoriesUUIDIDMigrationRequiredVersion); mErr != nil { return mErr } if mErr := s.RunCategoryUUIDIDMigration(); mErr != nil { return fmt.Errorf("error running categoryID migration: %w", mErr) } appliedMigrations, err := driver.AppliedMigrations() if err != nil { return err } if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, deDuplicateCategoryBoards); mErr != nil { return mErr } currentMigrationVersion := len(appliedMigrations) if mErr := s.RunDeDuplicateCategoryBoardsMigration(currentMigrationVersion); mErr != nil { return mErr } s.logger.Debug("== Applying all remaining migrations ====================", mlog.Int("current_version", len(appliedMigrations)), ) if err := engine.ApplyAll(); err != nil { return err } // always run the collations & charset fix-ups if mErr := s.RunFixCollationsAndCharsetsMigration(); mErr != nil { return fmt.Errorf("error running fix collations and charsets migration: %w", mErr) } return nil } func (s *SQLStore) ensureMigrationsAppliedUpToVersion(engine *morph.Morph, driver drivers.Driver, version int) error { applied, err := driver.AppliedMigrations() if err != nil { return err } currentVersion := len(applied) s.logger.Debug("== Ensuring migrations applied up to version ====================", mlog.Int("version", version), mlog.Int("current_version", currentVersion)) // if the target version is below or equal to the current one, do // not migrate either because is not needed (both are equal) or // because it would downgrade the database (is below) if version <= currentVersion { s.logger.Debug("-- There is no need of applying any migration --------------------") return nil } for _, migration := range applied { s.logger.Debug("-- Found applied migration --------------------", mlog.Uint("version", migration.Version), mlog.String("name", migration.Name)) } if _, err = engine.Apply(version - currentVersion); err != nil { return err } return nil } func (s *SQLStore) GetTemplateHelperFuncs() template.FuncMap { funcs := template.FuncMap{ "addColumnIfNeeded": s.genAddColumnIfNeeded, "dropColumnIfNeeded": s.genDropColumnIfNeeded, "createIndexIfNeeded": s.genCreateIndexIfNeeded, "renameTableIfNeeded": s.genRenameTableIfNeeded, "renameColumnIfNeeded": s.genRenameColumnIfNeeded, "doesTableExist": s.doesTableExist, "doesColumnExist": s.doesColumnExist, "addConstraintIfNeeded": s.genAddConstraintIfNeeded, } return funcs } func (s *SQLStore) genAddColumnIfNeeded(tableName, columnName, datatype, constraint string) (string, error) { tableName = addPrefixIfNeeded(tableName, s.tablePrefix) normTableName := s.normalizeTablename(tableName) switch s.dbType { case model.SqliteDBType: // Sqlite does not support any conditionals that can contain DDL commands. No idempotent migrations for Sqlite :-( return fmt.Sprintf("\nALTER TABLE %s ADD COLUMN %s %s %s;\n", normTableName, columnName, datatype, constraint), nil case model.MysqlDBType: vars := map[string]string{ "schema": s.schemaName, "table_name": tableName, "norm_table_name": normTableName, "column_name": columnName, "data_type": datatype, "constraint": constraint, } return replaceVars(` SET @stmt = (SELECT IF( ( SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = '[[table_name]]' AND table_schema = '[[schema]]' AND column_name = '[[column_name]]' ) > 0, 'SELECT 1;', 'ALTER TABLE [[norm_table_name]] ADD COLUMN [[column_name]] [[data_type]] [[constraint]];' )); PREPARE addColumnIfNeeded FROM @stmt; EXECUTE addColumnIfNeeded; DEALLOCATE PREPARE addColumnIfNeeded; `, vars), nil case model.PostgresDBType: return fmt.Sprintf("\nALTER TABLE %s ADD COLUMN IF NOT EXISTS %s %s %s;\n", normTableName, columnName, datatype, constraint), nil default: return "", ErrUnsupportedDatabaseType } } func (s *SQLStore) genDropColumnIfNeeded(tableName, columnName string) (string, error) { tableName = addPrefixIfNeeded(tableName, s.tablePrefix) normTableName := s.normalizeTablename(tableName) switch s.dbType { case model.SqliteDBType: return fmt.Sprintf("\n-- Sqlite3 cannot drop columns for versions less than 3.35.0; drop column '%s' in table '%s' skipped\n", columnName, tableName), nil case model.MysqlDBType: vars := map[string]string{ "schema": s.schemaName, "table_name": tableName, "norm_table_name": normTableName, "column_name": columnName, } return replaceVars(` SET @stmt = (SELECT IF( ( SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = '[[table_name]]' AND table_schema = '[[schema]]' AND column_name = '[[column_name]]' ) > 0, 'ALTER TABLE [[norm_table_name]] DROP COLUMN [[column_name]];', 'SELECT 1;' )); PREPARE dropColumnIfNeeded FROM @stmt; EXECUTE dropColumnIfNeeded; DEALLOCATE PREPARE dropColumnIfNeeded; `, vars), nil case model.PostgresDBType: return fmt.Sprintf("\nALTER TABLE %s DROP COLUMN IF EXISTS %s;\n", normTableName, columnName), nil default: return "", ErrUnsupportedDatabaseType } } func (s *SQLStore) genCreateIndexIfNeeded(tableName, columns string) (string, error) { indexName := getIndexName(tableName, columns) tableName = addPrefixIfNeeded(tableName, s.tablePrefix) normTableName := s.normalizeTablename(tableName) switch s.dbType { case model.SqliteDBType: // No support for idempotent index creation in Sqlite. return fmt.Sprintf("\nCREATE INDEX %s ON %s (%s);\n", indexName, normTableName, columns), nil case model.MysqlDBType: vars := map[string]string{ "schema": s.schemaName, "table_name": tableName, "norm_table_name": normTableName, "index_name": indexName, "columns": columns, } return replaceVars(` SET @stmt = (SELECT IF( ( SELECT COUNT(index_name) FROM INFORMATION_SCHEMA.STATISTICS WHERE table_name = '[[table_name]]' AND table_schema = '[[schema]]' AND index_name = '[[index_name]]' ) > 0, 'SELECT 1;', 'CREATE INDEX [[index_name]] ON [[norm_table_name]] ([[columns]]);' )); PREPARE createIndexIfNeeded FROM @stmt; EXECUTE createIndexIfNeeded; DEALLOCATE PREPARE createIndexIfNeeded; `, vars), nil case model.PostgresDBType: return fmt.Sprintf("\nCREATE INDEX IF NOT EXISTS %s ON %s (%s);\n", indexName, normTableName, columns), nil default: return "", ErrUnsupportedDatabaseType } } func (s *SQLStore) genRenameTableIfNeeded(oldTableName, newTableName string) (string, error) { oldTableName = addPrefixIfNeeded(oldTableName, s.tablePrefix) newTableName = addPrefixIfNeeded(newTableName, s.tablePrefix) normOldTableName := s.normalizeTablename(oldTableName) vars := map[string]string{ "schema": s.schemaName, "table_name": newTableName, "norm_old_table_name": normOldTableName, "new_table_name": newTableName, } switch s.dbType { case model.SqliteDBType: // No support for idempotent table renaming in Sqlite. return fmt.Sprintf("\nALTER TABLE %s RENAME TO %s;\n", normOldTableName, newTableName), nil case model.MysqlDBType: return replaceVars(` SET @stmt = (SELECT IF( ( SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_name = '[[table_name]]' AND table_schema = '[[schema]]' ) > 0, 'SELECT 1;', 'RENAME TABLE [[norm_old_table_name]] TO [[new_table_name]];' )); PREPARE renameTableIfNeeded FROM @stmt; EXECUTE renameTableIfNeeded; DEALLOCATE PREPARE renameTableIfNeeded; `, vars), nil case model.PostgresDBType: return replaceVars(` do $$ begin if (SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_name = '[[new_table_name]]' AND table_schema = '[[schema]]' ) = 0 then ALTER TABLE [[norm_old_table_name]] RENAME TO [[new_table_name]]; end if; end$$; `, vars), nil default: return "", ErrUnsupportedDatabaseType } } func (s *SQLStore) genRenameColumnIfNeeded(tableName, oldColumnName, newColumnName, dataType string) (string, error) { tableName = addPrefixIfNeeded(tableName, s.tablePrefix) normTableName := s.normalizeTablename(tableName) vars := map[string]string{ "schema": s.schemaName, "table_name": tableName, "norm_table_name": normTableName, "old_column_name": oldColumnName, "new_column_name": newColumnName, "data_type": dataType, } switch s.dbType { case model.SqliteDBType: // No support for idempotent column renaming in Sqlite. return fmt.Sprintf("\nALTER TABLE %s RENAME COLUMN %s TO %s;\n", normTableName, oldColumnName, newColumnName), nil case model.MysqlDBType: return replaceVars(` SET @stmt = (SELECT IF( ( SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = '[[table_name]]' AND table_schema = '[[schema]]' AND column_name = '[[new_column_name]]' ) > 0, 'SELECT 1;', 'ALTER TABLE [[norm_table_name]] CHANGE [[old_column_name]] [[new_column_name]] [[data_type]];' )); PREPARE renameColumnIfNeeded FROM @stmt; EXECUTE renameColumnIfNeeded; DEALLOCATE PREPARE renameColumnIfNeeded; `, vars), nil case model.PostgresDBType: return replaceVars(` do $$ begin if (SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = '[[table_name]]' AND table_schema = '[[schema]]' AND column_name = '[[new_column_name]]' ) = 0 then ALTER TABLE [[norm_table_name]] RENAME COLUMN [[old_column_name]] TO [[new_column_name]]; end if; end$$; `, vars), nil default: return "", ErrUnsupportedDatabaseType } } func (s *SQLStore) doesTableExist(tableName string) (bool, error) { tableName = addPrefixIfNeeded(tableName, s.tablePrefix) var query sq.SelectBuilder switch s.dbType { case model.MysqlDBType, model.PostgresDBType: query = s.getQueryBuilder(s.db). Select("table_name"). From("INFORMATION_SCHEMA.TABLES"). Where(sq.Eq{ "table_name": tableName, "table_schema": s.schemaName, }) case model.SqliteDBType: query = s.getQueryBuilder(s.db). Select("name"). From("sqlite_master"). Where(sq.Eq{ "name": tableName, "type": "table", }) default: return false, ErrUnsupportedDatabaseType } rows, err := query.Query() if err != nil { s.logger.Error(`doesTableExist ERROR`, mlog.Err(err)) return false, err } defer s.CloseRows(rows) exists := rows.Next() sql, _, _ := query.ToSql() s.logger.Trace("doesTableExist", mlog.String("table", tableName), mlog.Bool("exists", exists), mlog.String("sql", sql), ) return exists, nil } func (s *SQLStore) doesColumnExist(tableName, columnName string) (bool, error) { tableName = addPrefixIfNeeded(tableName, s.tablePrefix) var query sq.SelectBuilder switch s.dbType { case model.MysqlDBType, model.PostgresDBType: query = s.getQueryBuilder(s.db). Select("table_name"). From("INFORMATION_SCHEMA.COLUMNS"). Where(sq.Eq{ "table_name": tableName, "table_schema": s.schemaName, "column_name": columnName, }) case model.SqliteDBType: query = s.getQueryBuilder(s.db). Select("name"). From(fmt.Sprintf("pragma_table_info('%s')", tableName)). Where(sq.Eq{ "name": columnName, }) default: return false, ErrUnsupportedDatabaseType } rows, err := query.Query() if err != nil { s.logger.Error(`doesColumnExist ERROR`, mlog.Err(err)) return false, err } defer s.CloseRows(rows) exists := rows.Next() sql, _, _ := query.ToSql() s.logger.Trace("doesColumnExist", mlog.String("table", tableName), mlog.String("column", columnName), mlog.Bool("exists", exists), mlog.String("sql", sql), ) return exists, nil } func (s *SQLStore) genAddConstraintIfNeeded(tableName, constraintName, constraintType, constraintDefinition string) (string, error) { tableName = addPrefixIfNeeded(tableName, s.tablePrefix) normTableName := s.normalizeTablename(tableName) var query string vars := map[string]string{ "schema": s.schemaName, "constraint_name": constraintName, "constraint_type": constraintType, "table_name": tableName, "constraint_definition": constraintDefinition, "norm_table_name": normTableName, } switch s.dbType { case model.SqliteDBType: // SQLite doesn't have a generic way to add constraint. For example, you can only create indexes on existing tables. // For other constraints, you need to re-build the table. So skipping here. // Include SQLite specific migration in original migration file. query = fmt.Sprintf("\n-- Sqlite3 cannot drop constraints; drop constraint '%s' in table '%s' skipped\n", constraintName, tableName) case model.MysqlDBType: query = replaceVars(` SET @stmt = (SELECT IF( ( SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE constraint_schema = '[[schema]]' AND constraint_name = '[[constraint_name]]' AND constraint_type = '[[constraint_type]]' AND table_name = '[[table_name]]' ) > 0, 'SELECT 1;', 'ALTER TABLE [[norm_table_name]] ADD CONSTRAINT [[constraint_name]] [[constraint_definition]];' )); PREPARE addConstraintIfNeeded FROM @stmt; EXECUTE addConstraintIfNeeded; DEALLOCATE PREPARE addConstraintIfNeeded; `, vars) case model.PostgresDBType: query = replaceVars(` DO $$ BEGIN IF NOT EXISTS ( SELECT * FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE constraint_schema = '[[schema]]' AND constraint_name = '[[constraint_name]]' AND constraint_type = '[[constraint_type]]' AND table_name = '[[table_name]]' ) THEN ALTER TABLE [[norm_table_name]] ADD CONSTRAINT [[constraint_name]] [[constraint_definition]]; END IF; END; $$ LANGUAGE plpgsql; `, vars) } return query, nil } func addPrefixIfNeeded(s, prefix string) string { if !strings.HasPrefix(s, prefix) { return prefix + s } return s } func (s *SQLStore) normalizeTablename(tableName string) string { if s.schemaName != "" && !strings.HasPrefix(tableName, s.schemaName+".") { schemaName := s.schemaName if s.dbType == model.MysqlDBType { schemaName = "`" + schemaName + "`" } tableName = schemaName + "." + tableName } return tableName } func getIndexName(tableName string, columns string) string { var sb strings.Builder _, _ = sb.WriteString("idx_") _, _ = sb.WriteString(tableName) // allow developers to separate column names with spaces and/or commas columns = strings.ReplaceAll(columns, ",", " ") cols := strings.Split(columns, " ") for _, s := range cols { sub := strings.TrimSpace(s) if sub == "" { continue } _, _ = sb.WriteString("_") _, _ = sb.WriteString(s) } return sb.String() } // replaceVars replaces instances of variable placeholders with the // values provided via a map. Variable placeholders are of the form // `[[var_name]]`. func replaceVars(s string, vars map[string]string) string { for key, val := range vars { placeholder := "[[" + key + "]]" val = strings.ReplaceAll(val, "'", "\\'") s = strings.ReplaceAll(s, placeholder, val) } return s } ================================================ FILE: server/services/store/sqlstore/migrations/000001_init.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000001_init.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}blocks ( id VARCHAR(36), {{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}} {{if .sqlite}}insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),{{end}} {{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}} parent_id VARCHAR(36), {{if .mysql}}`schema`{{else}}schema{{end}} BIGINT, type TEXT, title TEXT, fields {{if .postgres}}JSON{{else}}TEXT{{end}}, create_at BIGINT, update_at BIGINT, delete_at BIGINT, PRIMARY KEY (id, insert_at) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; ================================================ FILE: server/services/store/sqlstore/migrations/000002_system_settings_table.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000002_system_settings_table.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}system_settings ( id VARCHAR(100), value TEXT, PRIMARY KEY (id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; ================================================ FILE: server/services/store/sqlstore/migrations/000003_blocks_rootid.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000003_blocks_rootid.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "blocks" "root_id" "varchar(36)" ""}} ================================================ FILE: server/services/store/sqlstore/migrations/000004_auth_table.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000004_auth_table.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}users ( id VARCHAR(100), username VARCHAR(100), email VARCHAR(255), password VARCHAR(100), mfa_secret VARCHAR(100), auth_service VARCHAR(20), auth_data VARCHAR(255), props {{if .postgres}}JSON{{else}}TEXT{{end}}, create_at BIGINT, update_at BIGINT, delete_at BIGINT, PRIMARY KEY (id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; CREATE TABLE IF NOT EXISTS {{.prefix}}sessions ( id VARCHAR(100), token VARCHAR(100), user_id VARCHAR(100), props {{if .postgres}}JSON{{else}}TEXT{{end}}, create_at BIGINT, update_at BIGINT, PRIMARY KEY (id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; ================================================ FILE: server/services/store/sqlstore/migrations/000005_blocks_modifiedby.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000005_blocks_modifiedby.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "blocks" "modified_by" "varchar(36)" ""}} ================================================ FILE: server/services/store/sqlstore/migrations/000006_sharing_table.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000006_sharing_table.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}sharing ( id VARCHAR(36), enabled BOOLEAN, token VARCHAR(100), modified_by VARCHAR(36), update_at BIGINT, PRIMARY KEY (id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; ================================================ FILE: server/services/store/sqlstore/migrations/000007_workspaces_table.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000007_workspaces_table.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}workspaces ( id VARCHAR(36), signup_token VARCHAR(100) NOT NULL, settings {{if .postgres}}JSON{{else}}TEXT{{end}}, modified_by VARCHAR(36), update_at BIGINT, PRIMARY KEY (id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; ================================================ FILE: server/services/store/sqlstore/migrations/000008_teams.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000008_teams.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "blocks" "workspace_id" "varchar(36)" ""}} {{ addColumnIfNeeded "sharing" "workspace_id" "varchar(36)" ""}} {{ addColumnIfNeeded "sessions" "auth_service" "varchar(20)" ""}} UPDATE {{.prefix}}blocks SET workspace_id = '0' WHERE workspace_id = '' OR workspace_id IS NULL; ================================================ FILE: server/services/store/sqlstore/migrations/000009_blocks_history.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000009_blocks_history.up.sql ================================================ {{- /* Only perform this migration if the blocks_history table does not already exist */ -}} {{- /* doesTableExist tableName */ -}} {{if doesTableExist "blocks_history" }} SELECT 1; {{else}} {{- /* renameTableIfNeeded oldTableName newTableName */ -}} {{ renameTableIfNeeded "blocks" "blocks_history" }} CREATE TABLE IF NOT EXISTS {{.prefix}}blocks ( id VARCHAR(36), {{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}} {{if .sqlite}}insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),{{end}} {{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}} parent_id VARCHAR(36), {{if .mysql}}`schema`{{else}}schema{{end}} BIGINT, type TEXT, title TEXT, fields {{if .postgres}}JSON{{else}}TEXT{{end}}, create_at BIGINT, update_at BIGINT, delete_at BIGINT, root_id VARCHAR(36), modified_by VARCHAR(36), workspace_id VARCHAR(36), PRIMARY KEY (workspace_id,id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; {{if .mysql}} INSERT IGNORE INTO {{.prefix}}blocks (SELECT * FROM {{.prefix}}blocks_history ORDER BY insert_at DESC); {{end}} {{if .postgres}} INSERT INTO {{.prefix}}blocks (SELECT * FROM {{.prefix}}blocks_history ORDER BY insert_at DESC) ON CONFLICT DO NOTHING; {{end}} {{if .sqlite}} INSERT OR IGNORE INTO {{.prefix}}blocks SELECT * FROM {{.prefix}}blocks_history ORDER BY insert_at DESC; {{end}} {{end}} DELETE FROM {{.prefix}}blocks where delete_at > 0; ================================================ FILE: server/services/store/sqlstore/migrations/000010_blocks_created_by.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000010_blocks_created_by.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint) */ -}} {{ addColumnIfNeeded "blocks" "created_by" "varchar(36)" ""}} {{ addColumnIfNeeded "blocks_history" "created_by" "varchar(36)" ""}} UPDATE {{.prefix}}blocks SET created_by = COALESCE(NULLIF((select modified_by from {{.prefix}}blocks_history where {{.prefix}}blocks_history.id = {{.prefix}}blocks.id ORDER BY {{.prefix}}blocks_history.insert_at ASC limit 1), ''), 'system') WHERE created_by IS NULL; ================================================ FILE: server/services/store/sqlstore/migrations/000011_match_collation.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000011_match_collation.up.sql ================================================ {{- /* All tables have collation fixed via code at startup so this migration is no longer needed. */ -}} {{- /* See https://github.com/mattermost/focalboard/pull/4002 */ -}} SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000012_match_column_collation.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000012_match_column_collation.up.sql ================================================ {{if and .mysql .plugin}} -- this migration applies collation on column level. -- collation of mattermost's Channels table SET @mattermostCollation = (SELECT table_collation from information_schema.tables WHERE table_name = 'Channels' AND table_schema = (SELECT DATABASE())); -- charset of mattermost's CHannels table's Name column SET @mattermostCharset = (SELECT CHARACTER_SET_NAME from information_schema.columns WHERE table_name = 'Channels' AND table_schema = (SELECT DATABASE()) AND COLUMN_NAME = 'Name'); -- blocks SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}blocks CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation); PREPARE stmt FROM @updateCollationQuery; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- blocks history SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}blocks_history CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation); PREPARE stmt FROM @updateCollationQuery; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- sessions SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}sessions CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation); PREPARE stmt FROM @updateCollationQuery; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- sharing SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}sharing CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation); PREPARE stmt FROM @updateCollationQuery; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- system settings SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}system_settings CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation); PREPARE stmt FROM @updateCollationQuery; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- users SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}users CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation); PREPARE stmt FROM @updateCollationQuery; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- workspaces SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}workspaces CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation); PREPARE stmt FROM @updateCollationQuery; EXECUTE stmt; DEALLOCATE PREPARE stmt; {{else}} -- We need a query here otherwise the migration will result -- in an empty query when the if condition is false. -- Empty query causes a "Query was empty" error. SELECT 1; {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000013_millisecond_timestamps.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000013_millisecond_timestamps.up.sql ================================================ UPDATE {{.prefix}}users SET create_at = create_at*1000, update_at = update_at*1000, delete_at = delete_at*1000 WHERE create_at < 1000000000000; UPDATE {{.prefix}}blocks SET create_at = create_at*1000, update_at = update_at*1000, delete_at = delete_at*1000 WHERE create_at < 1000000000000; UPDATE {{.prefix}}blocks_history SET create_at = create_at*1000, update_at = update_at*1000, delete_at = delete_at*1000 WHERE create_at < 1000000000000; UPDATE {{.prefix}}workspaces SET update_at = update_at*1000 WHERE update_at < 1000000000000; UPDATE {{.prefix}}sharing SET update_at = update_at*1000 WHERE update_at < 1000000000000; UPDATE {{.prefix}}sessions SET create_at = create_at*1000, update_at = update_at*1000 WHERE create_at < 1000000000000; ================================================ FILE: server/services/store/sqlstore/migrations/000014_add_not_null_constraint.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000014_add_not_null_constraint.up.sql ================================================ UPDATE {{.prefix}}blocks SET created_by = 'system' where created_by IS NULL; UPDATE {{.prefix}}blocks SET modified_by = 'system' where modified_by IS NULL; {{if .mysql}} ALTER TABLE {{.prefix}}blocks MODIFY created_by varchar(36) NOT NULL; ALTER TABLE {{.prefix}}blocks MODIFY modified_by varchar(36) NOT NULL; {{end}} {{if .postgres}} ALTER TABLE {{.prefix}}blocks ALTER COLUMN created_by set NOT NULL; ALTER TABLE {{.prefix}}blocks ALTER COLUMN modified_by set NOT NULL; {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000015_blocks_history_no_nulls.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000015_blocks_history_no_nulls.up.sql ================================================ {{if .mysql}} UPDATE {{.prefix}}blocks_history AS bh SET bh.parent_id='' WHERE bh.parent_id IS NULL; UPDATE {{.prefix}}blocks_history AS bh SET bh.schema=1 WHERE bh.schema IS NULL; UPDATE {{.prefix}}blocks_history AS bh SET bh.type='' WHERE bh.type IS NULL; UPDATE {{.prefix}}blocks_history AS bh SET bh.title='' WHERE bh.title IS NULL; UPDATE {{.prefix}}blocks_history AS bh SET bh.fields='' WHERE bh.fields IS NULL; UPDATE {{.prefix}}blocks_history AS bh SET bh.create_at=0 WHERE bh.create_at IS NULL; UPDATE {{.prefix}}blocks_history AS bh SET bh.root_id='' WHERE bh.root_id IS NULL; UPDATE {{.prefix}}blocks_history AS bh SET bh.created_by='system' WHERE bh.created_by IS NULL; {{else}} /* parent_id */ UPDATE {{.prefix}}blocks_history AS bh1 SET parent_id = COALESCE( (SELECT bh2.parent_id FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.parent_id IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , '') WHERE parent_id IS NULL; /* schema */ UPDATE {{.prefix}}blocks_history AS bh1 SET schema = COALESCE( (SELECT bh2.schema FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.schema IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , 1) WHERE schema IS NULL; /* type */ UPDATE {{.prefix}}blocks_history AS bh1 SET type = COALESCE( (SELECT bh2.type FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.type IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , '') WHERE type IS NULL; /* title */ UPDATE {{.prefix}}blocks_history AS bh1 SET title = COALESCE( (SELECT bh2.title FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.title IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , '') WHERE title IS NULL; /* fields */ {{if .postgres}} UPDATE {{.prefix}}blocks_history AS bh1 SET fields = COALESCE( (SELECT bh2.fields FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.fields IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , '{}'::json) WHERE fields IS NULL; {{else}} UPDATE {{.prefix}}blocks_history AS bh1 SET fields = COALESCE( (SELECT bh2.fields FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.fields IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , '') WHERE fields IS NULL; {{end}} /* create_at */ UPDATE {{.prefix}}blocks_history AS bh1 SET create_at = COALESCE( (SELECT bh2.create_at FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.create_at IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , bh1.update_at) WHERE create_at IS NULL; /* root_id */ UPDATE {{.prefix}}blocks_history AS bh1 SET root_id = COALESCE( (SELECT bh2.root_id FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.root_id IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , '') WHERE root_id IS NULL; /* created_by */ UPDATE {{.prefix}}blocks_history AS bh1 SET created_by = COALESCE( (SELECT bh2.created_by FROM {{.prefix}}blocks_history AS bh2 WHERE bh1.id = bh2.id AND bh2.created_by IS NOT NULL ORDER BY bh2.insert_at ASC limit 1) , 'system') WHERE created_by IS NULL; {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000016_subscriptions_table.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000016_subscriptions_table.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}subscriptions ( block_type VARCHAR(10), block_id VARCHAR(36), workspace_id VARCHAR(36), subscriber_type VARCHAR(10), subscriber_id VARCHAR(36), notified_at BIGINT, create_at BIGINT, delete_at BIGINT, PRIMARY KEY (block_id, subscriber_id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; CREATE TABLE IF NOT EXISTS {{.prefix}}notification_hints ( block_type VARCHAR(10), block_id VARCHAR(36), workspace_id VARCHAR(36), modified_by_id VARCHAR(36), create_at BIGINT, notify_at BIGINT, PRIMARY KEY (block_id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; ================================================ FILE: server/services/store/sqlstore/migrations/000017_add_file_info.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000017_add_file_info.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}file_info ( id varchar(26) NOT NULL, create_at BIGINT NOT NULL, delete_at BIGINT, name TEXT NOT NULL, extension VARCHAR(50) NOT NULL, size BIGINT NOT NULL, archived BOOLEAN ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; ================================================ FILE: server/services/store/sqlstore/migrations/000018_add_teams_and_boards.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000018_add_teams_and_boards.up.sql ================================================ {{- /* renameTableIfNeeded oldTableName newTableName string */ -}} {{ renameTableIfNeeded "workspaces" "teams" }} {{- /* renameColumnIfNeeded tableName oldColumnName newColumnName dataType */ -}} {{ renameColumnIfNeeded "blocks" "workspace_id" "channel_id" "varchar(36)" }} {{ renameColumnIfNeeded "blocks_history" "workspace_id" "channel_id" "varchar(36)" }} {{- /* dropColumnIfNeeded tableName columnName */ -}} {{ dropColumnIfNeeded "blocks" "workspace_id" }} {{ dropColumnIfNeeded "blocks_history" "workspace_id" }} {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "blocks" "board_id" "varchar(36)" ""}} {{ addColumnIfNeeded "blocks_history" "board_id" "varchar(36)" ""}} {{- /* cleanup incorrect data format in column calculations */ -}} {{- /* then move from 'board' type to 'view' type*/ -}} {{if .mysql}} UPDATE {{.prefix}}blocks SET fields = JSON_SET(fields, '$.columnCalculations', JSON_OBJECT()) WHERE JSON_EXTRACT(fields, '$.columnCalculations') = JSON_ARRAY(); UPDATE {{.prefix}}blocks b JOIN ( SELECT id, JSON_EXTRACT(fields, '$.columnCalculations') as board_calculations from {{.prefix}}blocks WHERE JSON_EXTRACT(fields, '$.columnCalculations') <> JSON_OBJECT() ) AS s on s.id = b.root_id SET fields = JSON_SET(fields, '$.columnCalculations', JSON_ARRAY(s.board_calculations)) WHERE JSON_EXTRACT(b.fields, '$.viewType') = 'table' AND b.type = 'view'; {{end}} {{if .postgres}} UPDATE {{.prefix}}blocks SET fields = fields::jsonb - 'columnCalculations' || '{"columnCalculations": {}}' WHERE fields->>'columnCalculations' = '[]'; WITH subquery AS ( SELECT id, fields->'columnCalculations' as board_calculations from {{.prefix}}blocks WHERE fields ->> 'columnCalculations' <> '{}') UPDATE {{.prefix}}blocks b SET fields = b.fields::jsonb|| json_build_object('columnCalculations', s.board_calculations::jsonb)::jsonb FROM subquery AS s WHERE s.id = b.root_id AND b.fields ->> 'viewType' = 'table' AND b.type = 'view'; {{end}} {{if .sqlite}} UPDATE {{.prefix}}blocks SET fields = replace(fields, '"columnCalculations":[]', '"columnCalculations":{}'); UPDATE {{.prefix}}blocks AS b SET fields = ( SELECT json_set(a.fields, '$.columnCalculations',json_extract(c.fields, '$.columnCalculations')) from {{.prefix}}blocks AS a JOIN {{.prefix}}blocks AS c on c.id = a.root_id WHERE a.id = b.id) WHERE json_extract(b.fields,'$.viewType') = 'table' AND b.type = 'view'; {{end}} {{- /* TODO: Migrate the columnCalculations at app level and remove it from the boards and boards_history tables */ -}} {{- /* add boards tables */ -}} CREATE TABLE IF NOT EXISTS {{.prefix}}boards ( id VARCHAR(36) NOT NULL PRIMARY KEY, {{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}} {{if .sqlite}}insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),{{end}} {{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}} team_id VARCHAR(36) NOT NULL, channel_id VARCHAR(36), created_by VARCHAR(36), modified_by VARCHAR(36), type VARCHAR(1) NOT NULL, title TEXT NOT NULL, description TEXT, icon VARCHAR(256), show_description BOOLEAN, is_template BOOLEAN, template_version INT DEFAULT 0, {{if .mysql}} properties JSON, card_properties JSON, {{end}} {{if .postgres}} properties JSONB, card_properties JSONB, {{end}} {{if .sqlite}} properties TEXT, card_properties TEXT, {{end}} create_at BIGINT, update_at BIGINT, delete_at BIGINT ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; {{- /* createIndexIfNeeded tableName columns */ -}} {{ createIndexIfNeeded "boards" "team_id, is_template" }} {{ createIndexIfNeeded "boards" "channel_id" }} CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history ( id VARCHAR(36) NOT NULL, {{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}} {{if .sqlite}}insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),{{end}} {{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}} team_id VARCHAR(36) NOT NULL, channel_id VARCHAR(36), created_by VARCHAR(36), modified_by VARCHAR(36), type VARCHAR(1) NOT NULL, title TEXT NOT NULL, description TEXT, icon VARCHAR(256), show_description BOOLEAN, is_template BOOLEAN, template_version INT DEFAULT 0, {{if .mysql}} properties JSON, card_properties JSON, {{end}} {{if .postgres}} properties JSONB, card_properties JSONB, {{end}} {{if .sqlite}} properties TEXT, card_properties TEXT, {{end}} create_at BIGINT, update_at BIGINT, delete_at BIGINT, PRIMARY KEY (id, insert_at) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; {{- /* migrate board blocks to boards table */ -}} {{if .plugin}} {{if .postgres}} INSERT INTO {{.prefix}}boards ( SELECT B.id, B.insert_at, C.TeamId, B.channel_id, B.created_by, B.modified_by, C.type, COALESCE(B.title, ''), COALESCE((B.fields->>'description')::text, ''), B.fields->>'icon', COALESCE((fields->'showDescription')::text::boolean, false), COALESCE((fields->'isTemplate')::text::boolean, false), COALESCE((B.fields->'templateVer')::text::int, 0), '{}', B.fields->'cardProperties', B.create_at, B.update_at, B.delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'' {{end}} FROM {{.prefix}}blocks AS B INNER JOIN channels AS C ON C.Id=B.channel_id WHERE B.type='board' ); INSERT INTO {{.prefix}}boards_history ( SELECT B.id, B.insert_at, C.TeamId, B.channel_id, B.created_by, B.modified_by, C.type, COALESCE(B.title, ''), COALESCE((B.fields->>'description')::text, ''), B.fields->>'icon', COALESCE((fields->'showDescription')::text::boolean, false), COALESCE((fields->'isTemplate')::text::boolean, false), COALESCE((B.fields->'templateVer')::text::int, 0), '{}', B.fields->'cardProperties', B.create_at, B.update_at, B.delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'' {{end}} FROM {{.prefix}}blocks_history AS B INNER JOIN channels AS C ON C.Id=B.channel_id WHERE B.type='board' ); {{end}} {{if .mysql}} INSERT INTO {{.prefix}}boards ( SELECT B.id, B.insert_at, C.TeamId, B.channel_id, B.created_by, B.modified_by, C.Type, COALESCE(B.title, ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(B.fields,'$.description')), ''), JSON_UNQUOTE(JSON_EXTRACT(B.fields,'$.icon')), COALESCE(JSON_EXTRACT(B.fields, '$.showDescription'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0), '{}', JSON_EXTRACT(B.fields, '$.cardProperties'), B.create_at, B.update_at, B.delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'' {{end}} FROM {{.prefix}}blocks AS B INNER JOIN Channels AS C ON C.Id=B.channel_id WHERE B.type='board' ); INSERT INTO {{.prefix}}boards_history ( SELECT B.id, B.insert_at, C.TeamId, B.channel_id, B.created_by, B.modified_by, C.Type, COALESCE(B.title, ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(B.fields,'$.description')), ''), JSON_UNQUOTE(JSON_EXTRACT(B.fields,'$.icon')), COALESCE(JSON_EXTRACT(B.fields, '$.showDescription'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0), '{}', JSON_EXTRACT(B.fields, '$.cardProperties'), B.create_at, B.update_at, B.delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'' {{end}} FROM {{.prefix}}blocks_history AS B INNER JOIN Channels AS C ON C.Id=B.channel_id WHERE B.type='board' ); {{end}} {{else}} {{if .postgres}} INSERT INTO {{.prefix}}boards ( SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', COALESCE(B.title, ''), COALESCE((fields->>'description')::text, ''), B.fields->>'icon', COALESCE((fields->'showDescription')::text::boolean, false), COALESCE((fields->'isTemplate')::text::boolean, false), COALESCE((B.fields->'templateVer')::text::int, 0), '{}', fields->'cardProperties', create_at, update_at, delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'editor' {{end}} FROM {{.prefix}}blocks AS B WHERE type='board' ); INSERT INTO {{.prefix}}boards_history ( SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', COALESCE(B.title, ''), COALESCE((fields->>'description')::text, ''), B.fields->>'icon', COALESCE((fields->'showDescription')::text::boolean, false), COALESCE((fields->'isTemplate')::text::boolean, false), COALESCE((B.fields->'templateVer')::text::int, 0), '{}', fields->'cardProperties', create_at, update_at, delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'editor' {{end}} FROM {{.prefix}}blocks_history AS B WHERE type='board' ); {{end}} {{if .mysql}} INSERT INTO {{.prefix}}boards ( SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', COALESCE(B.title, ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(B.fields,'$.description')), ''), JSON_UNQUOTE(JSON_EXTRACT(fields,'$.icon')), COALESCE(JSON_EXTRACT(B.fields, '$.showDescription'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0), '{}', JSON_EXTRACT(fields, '$.cardProperties'), create_at, update_at, delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'editor' {{end}} FROM {{.prefix}}blocks AS B WHERE type='board' ); INSERT INTO {{.prefix}}boards_history ( SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', COALESCE(B.title, ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(B.fields,'$.description')), ''), JSON_UNQUOTE(JSON_EXTRACT(fields,'$.icon')), COALESCE(JSON_EXTRACT(B.fields, '$.showDescription'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true', COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0), '{}', JSON_EXTRACT(fields, '$.cardProperties'), create_at, update_at, delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'editor' {{end}} FROM {{.prefix}}blocks_history AS B WHERE type='board' ); {{end}} {{if .sqlite}} INSERT INTO {{.prefix}}boards SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', COALESCE(title, ''), COALESCE(json_extract(fields, '$.description'), ''), json_extract(fields, '$.icon'), json_extract(fields, '$.showDescription'), json_extract(fields, '$.isTemplate'), COALESCE(json_extract(fields, '$.templateVer'), 0), '{}', json_extract(fields, '$.cardProperties'), create_at, update_at, delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'editor' {{end}} FROM {{.prefix}}blocks WHERE type='board' ; INSERT INTO {{.prefix}}boards_history SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', COALESCE(title, ''), COALESCE(json_extract(fields, '$.description'), ''), json_extract(fields, '$.icon'), json_extract(fields, '$.showDescription'), json_extract(fields, '$.isTemplate'), COALESCE(json_extract(fields, '$.templateVer'), 0), '{}', json_extract(fields, '$.cardProperties'), create_at, update_at, delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'editor' {{end}} FROM {{.prefix}}blocks_history WHERE type='board' ; {{end}} {{end}} {{- /* Update block references to boards*/ -}} UPDATE {{.prefix}}blocks SET board_id=root_id WHERE board_id IS NULL OR board_id=''; UPDATE {{.prefix}}blocks_history SET board_id=root_id WHERE board_id IS NULL OR board_id=''; {{- /* Remove boards, including templates */ -}} DELETE FROM {{.prefix}}blocks WHERE type = 'board'; DELETE FROM {{.prefix}}blocks_history WHERE type = 'board'; {{- /* add board_members (only if boards_members doesn't already exist) */ -}} {{if not (doesTableExist "board_members") }} CREATE TABLE IF NOT EXISTS {{.prefix}}board_members ( board_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL, roles VARCHAR(64), scheme_admin BOOLEAN, scheme_editor BOOLEAN, scheme_commenter BOOLEAN, scheme_viewer BOOLEAN, PRIMARY KEY (board_id, user_id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; {{- /* if we're in plugin, migrate channel memberships to the board */ -}} {{if .plugin}} INSERT INTO {{.prefix}}board_members ( SELECT B.Id, CM.UserId, CM.Roles, TRUE, TRUE, FALSE, FALSE FROM {{.prefix}}boards AS B INNER JOIN ChannelMembers as CM ON CM.ChannelId=B.channel_id WHERE CM.SchemeAdmin=True OR (CM.UserId=B.created_by) ); {{end}} {{- /* if we're in personal server or desktop, create memberships for everyone */ -}} {{if and (not .plugin) (not .singleUser)}} {{- /* for personal server, create a membership per user and board */ -}} INSERT INTO {{.prefix}}board_members SELECT B.id, U.id, '', B.created_by=U.id, TRUE, FALSE, FALSE FROM {{.prefix}}boards AS B, {{.prefix}}users AS U; {{end}} {{if and (not .plugin) .singleUser}} {{- /* for personal desktop, as we don't have users, create a membership */ -}} {{- /* per board with a fixed user id */ -}} INSERT INTO {{.prefix}}board_members SELECT B.id, 'single-user', '', TRUE, TRUE, FALSE, FALSE FROM {{.prefix}}boards AS B; {{end}} {{end}} {{- /* createIndexIfNeeded tableName columns */ -}} {{ createIndexIfNeeded "board_members" "user_id" }} ================================================ FILE: server/services/store/sqlstore/migrations/000019_populate_categories.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000019_populate_categories.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}categories ( id varchar(36) NOT NULL, name varchar(100) NOT NULL, user_id varchar(36) NOT NULL, team_id varchar(36) NOT NULL, channel_id varchar(36), create_at BIGINT, update_at BIGINT, delete_at BIGINT, PRIMARY KEY (id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; {{- /* createIndexIfNeeded tableName columns */ -}} {{ createIndexIfNeeded "categories" "user_id, team_id" }} ================================================ FILE: server/services/store/sqlstore/migrations/000020_populate_category_blocks.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000020_populate_category_blocks.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}category_boards ( id varchar(36) NOT NULL, user_id varchar(36) NOT NULL, category_id varchar(36) NOT NULL, board_id VARCHAR(36) NOT NULL, create_at BIGINT, update_at BIGINT, delete_at BIGINT, PRIMARY KEY (id) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; {{- /* createIndexIfNeeded tableName columns */ -}} {{ createIndexIfNeeded "category_boards" "category_id" }} ================================================ FILE: server/services/store/sqlstore/migrations/000021_create_boards_members_history.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000021_create_boards_members_history.up.sql ================================================ {{- /* Only perform this migration if the board_members_history table does not already exist */ -}} {{if doesTableExist "board_members_history" }} SELECT 1; {{else}} CREATE TABLE IF NOT EXISTS {{.prefix}}board_members_history ( board_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL, action VARCHAR(10), {{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}} {{if .sqlite}}insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),{{end}} {{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}} PRIMARY KEY (board_id, user_id, insert_at) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; INSERT INTO {{.prefix}}board_members_history (board_id, user_id, action) SELECT board_id, user_id, 'created' from {{.prefix}}board_members; {{end}} {{- /* createIndexIfNeeded tableName columns */ -}} {{ createIndexIfNeeded "board_members_history" "user_id" }} {{ createIndexIfNeeded "board_members_history" "board_id, user_id" }} ================================================ FILE: server/services/store/sqlstore/migrations/000022_create_default_board_role.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000022_create_default_board_role.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "boards" "minimum_role" "varchar(36)" "NOT NULL DEFAULT ''"}} {{ addColumnIfNeeded "boards_history" "minimum_role" "varchar(36)" "NOT NULL DEFAULT ''"}} UPDATE {{.prefix}}boards SET minimum_role = 'editor' WHERE minimum_role IS NULL OR minimum_role=''; UPDATE {{.prefix}}boards_history SET minimum_role = 'editor' WHERE minimum_role IS NULL OR minimum_role=''; ================================================ FILE: server/services/store/sqlstore/migrations/000023_persist_category_collapsed_state.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000023_persist_category_collapsed_state.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "categories" "collapsed" "boolean" "default false"}} ================================================ FILE: server/services/store/sqlstore/migrations/000024_mark_existsing_categories_collapsed.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000024_mark_existsing_categories_collapsed.up.sql ================================================ UPDATE {{.prefix}}categories SET collapsed = true; ================================================ FILE: server/services/store/sqlstore/migrations/000025_indexes_update.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000025_indexes_update.up.sql ================================================ {{- /* delete old blocks PK and add id as the new one */ -}} {{if .mysql}} ALTER TABLE {{.prefix}}blocks DROP PRIMARY KEY; ALTER TABLE {{.prefix}}blocks ADD PRIMARY KEY (id); {{end}} {{if .postgres}} ALTER TABLE {{.prefix}}blocks DROP CONSTRAINT {{.prefix}}blocks_pkey1; ALTER TABLE {{.prefix}}blocks ADD PRIMARY KEY (id); {{end}} {{- /* there is no way for SQLite to update the PK or add a unique constraint */ -}} {{if .sqlite}} ALTER TABLE {{.prefix}}blocks RENAME TO {{.prefix}}blocks_tmp; CREATE TABLE IF NOT EXISTS {{.prefix}}blocks ( id VARCHAR(36), insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), parent_id VARCHAR(36), schema BIGINT, type TEXT, title TEXT, fields TEXT, create_at BIGINT, update_at BIGINT, delete_at BIGINT, root_id VARCHAR(36), modified_by VARCHAR(36), channel_id VARCHAR(36), created_by VARCHAR(36), board_id VARCHAR(36), PRIMARY KEY (id) ); INSERT INTO {{.prefix}}blocks SELECT * FROM {{.prefix}}blocks_tmp; DROP TABLE {{.prefix}}blocks_tmp; {{end}} {{- /* most block searches use board_id or a combination of board and parent ids */ -}} {{ createIndexIfNeeded "blocks" "board_id, parent_id" }} {{- /* get subscriptions is used once per board page load */ -}} {{ createIndexIfNeeded "subscriptions" "subscriber_id" }} ================================================ FILE: server/services/store/sqlstore/migrations/000026_create_preferences_table.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000026_create_preferences_table.up.sql ================================================ CREATE TABLE IF NOT EXISTS {{.prefix}}preferences ( userid VARCHAR(36) NOT NULL, category VARCHAR(32) NOT NULL, name VARCHAR(32) NOT NULL, value TEXT NULL, PRIMARY KEY (userid, category, name) ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; {{- /* createIndexIfNeeded tableName columns */ -}} {{ createIndexIfNeeded "preferences" "category" }} {{ createIndexIfNeeded "preferences" "name" }} ================================================ FILE: server/services/store/sqlstore/migrations/000027_migrate_user_props_to_preferences.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000027_migrate_user_props_to_preferences.up.sql ================================================ {{if .plugin}} {{- /* For plugin mode, we need to write into Mattermost's `Preferences` table, hence, no use of `prefix`. */ -}} {{if .postgres}} INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace((Props->'focalboard_welcomePageViewed')::varchar, '"', '') FROM Users WHERE Props->'focalboard_welcomePageViewed' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace((Props->'hiddenBoardIDs')::varchar, '"[', '['), ']"', ']'), '\"', '"') FROM Users WHERE Props->'hiddenBoardIDs' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace((Props->'focalboard_tourCategory')::varchar, '"', '') FROM Users WHERE Props->'focalboard_tourCategory' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace((Props->'focalboard_onboardingTourStep')::varchar, '"', '') FROM Users WHERE Props->'focalboard_onboardingTourStep' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace((Props->'focalboard_onboardingTourStarted')::varchar, '"', '') FROM Users WHERE Props->'focalboard_onboardingTourStarted' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace((Props->'focalboard_version72MessageCanceled')::varchar, '"', '') FROM Users WHERE Props->'focalboard_version72MessageCanceled' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace((Props->'focalboard_lastWelcomeVersion')::varchar, '"', '') FROM Users WHERE Props->'focalboard_lastWelcomeVersion' IS NOT NULL ON CONFLICT DO NOTHING; UPDATE Users SET props = (props - 'focalboard_welcomePageViewed' - 'hiddenBoardIDs' - 'focalboard_tourCategory' - 'focalboard_onboardingTourStep' - 'focalboard_onboardingTourStarted' - 'focalboard_version72MessageCanceled' - 'focalboard_lastWelcomeVersion') WHERE jsonb_typeof(props) = 'object'; {{end}} {{if .mysql}} INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(Props, '$."focalboard_welcomePageViewed"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_welcomePageViewed') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(Props, '$."hiddenBoardIDs"'), '"[', '['), ']"', ']'), '\\"', '"') FROM Users WHERE JSON_EXTRACT(Props, '$.hiddenBoardIDs') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(Props, '$."focalboard_tourCategory"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_tourCategory') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStep"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStep') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStarted"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStarted') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(Props, '$."focalboard_version72MessageCanceled"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_version72MessageCanceled') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(Props, '$."focalboard_lastWelcomeVersion"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_lastWelcomeVersion') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; UPDATE Users SET Props = JSON_REMOVE(Props, '$."focalboard_welcomePageViewed"', '$."hiddenBoardIDs"', '$."focalboard_tourCategory"', '$."focalboard_onboardingTourStep"', '$."focalboard_onboardingTourStarted"', '$."focalboard_version72MessageCanceled"', '$."focalboard_lastWelcomeVersion"'); {{end}} {{if .sqlite}} INSERT OR IGNORE INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(Props, '$."focalboard_welcomePageViewed"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_welcomePageViewed') IS NOT NULL; INSERT OR IGNORE INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(Props, '$."hiddenBoardIDs'), '"[', '['), ']"', ']'), '\\"', '"') FROM Users WHERE JSON_EXTRACT(Props, '$.hiddenBoardIDs') IS NOT NULL; INSERT OR IGNORE INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(Props, '$."focalboard_tourCategory"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_tourCategory') IS NOT NULL; INSERT OR IGNORE INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStep"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStep') IS NOT NULL; INSERT OR IGNORE INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStarted"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStarted') IS NOT NULL; INSERT OR IGNORE INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(Props, '$."focalboard_version72MessageCanceled"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_version72MessageCanceled') IS NOT NULL; INSERT OR IGNORE INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(Props, '$."focalboard_lastWelcomeVersion"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_lastWelcomeVersion') IS NOT NULL; UPDATE Users SET Props = JSON_REMOVE(Props, '$."focalboard_welcomePageViewed"', '$."hiddenBoardIDs"', '$."focalboard_tourCategory"', '$."focalboard_onboardingTourStep"', '$."focalboard_onboardingTourStarted"', '$."focalboard_version72MessageCanceled"', '$."focalboard_lastWelcomeVersion"'); {{end}} {{else}} {{- /* For personal server, we need to write to Focalboard's preferences table, hence the use of `prefix`. */ -}} {{if .postgres}} INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace((Props->'focalboard_welcomePageViewed')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_welcomePageViewed' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace((Props->'hiddenBoardIDs')::varchar, '"[', '['), ']"', ']'), '\"', '"') from {{.prefix}}users WHERE Props->'hiddenBoardIDs' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace((Props->'focalboard_tourCategory')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_tourCategory' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace((Props->'focalboard_onboardingTourStep')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_onboardingTourStep' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace((Props->'focalboard_onboardingTourStarted')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_onboardingTourStarted' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace((Props->'focalboard_version72MessageCanceled')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_version72MessageCanceled' IS NOT NULL ON CONFLICT DO NOTHING; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace((Props->'focalboard_lastWelcomeVersion')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_lastWelcomeVersion' IS NOT NULL ON CONFLICT DO NOTHING; UPDATE {{.prefix}}users SET props = (props::jsonb - 'focalboard_welcomePageViewed' - 'hiddenBoardIDs' - 'focalboard_tourCategory' - 'focalboard_onboardingTourStep' - 'focalboard_onboardingTourStarted' - 'focalboard_version72MessageCanceled' - 'focalboard_lastWelcomeVersion')::json WHERE jsonb_typeof(props::jsonb) = 'object'; {{end}} {{if .mysql}} INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(Props, '$."focalboard_welcomePageViewed"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_welcomePageViewed') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(Props, '$."hiddenBoardIDs"'), '"[', '['), ']"', ']'), '\\"', '"') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.hiddenBoardIDs') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(Props, '$."focalboard_tourCategory"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_tourCategory') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStep"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStep') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStarted"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStarted') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(Props, '$."focalboard_version72MessageCanceled"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_version72MessageCanceled') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(Props, '$."focalboard_lastWelcomeVersion"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_lastWelcomeVersion') IS NOT NULL ON DUPLICATE KEY UPDATE value = value; UPDATE {{.prefix}}users SET Props = JSON_REMOVE(Props, '$."focalboard_welcomePageViewed"', '$."hiddenBoardIDs"', '$."focalboard_tourCategory"', '$."focalboard_onboardingTourStep"', '$."focalboard_onboardingTourStarted"', '$."focalboard_version72MessageCanceled"', '$."focalboard_lastWelcomeVersion"'); {{end}} {{if .sqlite}} {{- /* Surprisingly SQLite and MySQL have same JSON functions and syntax! */ -}} INSERT OR IGNORE INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(Props, '$."focalboard_welcomePageViewed"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_welcomePageViewed') IS NOT NULL; INSERT OR IGNORE INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(Props, '$."hiddenBoardIDs"'), '"[', '['), ']"', ']'), '\\"', '"') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.hiddenBoardIDs') IS NOT NULL; INSERT OR IGNORE INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(Props, '$."focalboard_tourCategory"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_tourCategory') IS NOT NULL; INSERT OR IGNORE INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStep"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStep') IS NOT NULL; INSERT OR IGNORE INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStarted"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStarted') IS NOT NULL; INSERT OR IGNORE INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(Props, '$."focalboard_version72MessageCanceled"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_version72MessageCanceled') IS NOT NULL; INSERT OR IGNORE INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(Props, '$."focalboard_lastWelcomeVersion"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_lastWelcomeVersion') IS NOT NULL; UPDATE {{.prefix}}users SET Props = JSON_REMOVE(Props, '$."focalboard_welcomePageViewed"', '$."hiddenBoardIDs"', '$."focalboard_tourCategory"', '$."focalboard_onboardingTourStep"', '$."focalboard_onboardingTourStarted"', '$."focalboard_version72MessageCanceled"', '$."focalboard_lastWelcomeVersion"'); {{end}} {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000028_remove_template_channel_link.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000028_remove_template_channel_link.up.sql ================================================ UPDATE {{.prefix}}boards SET channel_id = '' WHERE is_template; ================================================ FILE: server/services/store/sqlstore/migrations/000029_add_category_type_field.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000029_add_category_type_field.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "categories" "type" "varchar(64)" ""}} UPDATE {{.prefix}}categories SET type = 'custom' WHERE type IS NULL; ================================================ FILE: server/services/store/sqlstore/migrations/000030_add_category_sort_order.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000030_add_category_sort_order.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "categories" "sort_order" "BIGINT" ""}} ================================================ FILE: server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.up.sql ================================================ {{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}} {{ addColumnIfNeeded "category_boards" "sort_order" "BIGINT" ""}} ================================================ FILE: server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.up.sql ================================================ {{- /* To move Boards category to to the last value, we just need a relatively large value. */ -}} {{- /* Assigning 10x total number of categories works perfectly. The sort_order is anyways updated */ -}} {{- /* when the user manually DNDs a category. */ -}} {{if or .postgres .sqlite}} UPDATE {{.prefix}}categories SET sort_order = (10 * (SELECT COUNT(*) FROM {{.prefix}}categories)) WHERE lower(name) = 'boards'; {{end}} {{if .mysql}} {{- /* MySQL doesn't allow referencing the same table in subquery and update query like Postgres, */ -}} {{- /* So we save the subquery result in a variable to use later. */ -}} SET @focalboard_numCategories = (SELECT COUNT(*) FROM {{.prefix}}categories); UPDATE {{.prefix}}categories SET sort_order = (10 * @focalboard_numCategories) WHERE lower(name) = 'boards'; SET @focalboard_numCategories = NULL; {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.up.sql ================================================ DELETE FROM {{.prefix}}category_boards WHERE delete_at > 0; ================================================ FILE: server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.up.sql ================================================ {{ if or .postgres .mysql }} {{ dropColumnIfNeeded "category_boards" "delete_at" }} {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000035_add_hidden_board_column.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000035_add_hidden_board_column.up.sql ================================================ {{ addColumnIfNeeded "category_boards" "hidden" "boolean" "" }} ================================================ FILE: server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.up.sql ================================================ {{if or .mysql .postgres}} {{ addConstraintIfNeeded "category_boards" "unique_user_category_board" "UNIQUE" "UNIQUE(user_id, board_id)"}} {{end}} {{if .sqlite}} ALTER TABLE {{.prefix}}category_boards RENAME TO {{.prefix}}category_boards_old; CREATE TABLE {{.prefix}}category_boards ( id varchar(36) NOT NULL, user_id varchar(36) NOT NULL, category_id varchar(36) NOT NULL, board_id VARCHAR(36) NOT NULL, create_at BIGINT, update_at BIGINT, sort_order BIGINT, hidden boolean, PRIMARY KEY (id), CONSTRAINT unique_user_category_board UNIQUE (user_id, board_id) ); INSERT INTO {{.prefix}}category_boards (id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden) SELECT id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden FROM {{.prefix}}category_boards_old; DROP TABLE {{.prefix}}category_boards_old; {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.up.sql ================================================ {{if .plugin}} {{if .mysql}} UPDATE {{.prefix}}category_boards AS fcb JOIN Preferences p ON fcb.user_id = p.userid AND p.category = 'focalboard' AND p.name = 'hiddenBoardIDs' SET hidden = true WHERE p.value LIKE concat('%', fcb.board_id, '%'); {{end}} {{if .postgres}} UPDATE {{.prefix}}category_boards as fcb SET hidden = true FROM preferences p WHERE p.userid = fcb.user_id AND p.category = 'focalboard' AND p.name = 'hiddenBoardIDs' AND p.value like ('%' || fcb.board_id || '%'); {{end}} {{else}} {{if .mysql}} UPDATE {{.prefix}}category_boards AS fcb JOIN {{.prefix}}preferences p ON fcb.user_id = p.userid AND p.category = 'focalboard' AND p.name = 'hiddenBoardIDs' SET hidden = true WHERE p.value LIKE concat('%', fcb.board_id, '%'); {{end}} {{if .postgres}} UPDATE {{.prefix}}category_boards as fcb SET hidden = true FROM {{.prefix}}preferences p WHERE p.userid = fcb.user_id AND p.category = 'focalboard' AND p.name = 'hiddenBoardIDs' AND p.value like ('%' || fcb.board_id || '%'); {{end}} {{end}} {{if .sqlite}} UPDATE {{.prefix}}category_boards SET hidden = true WHERE (user_id || '_' || board_id) IN ( SELECT (fcb.user_id || '_' || fcb.board_id) FROM {{.prefix}}category_boards AS fcb JOIN {{.prefix}}preferences p ON p.userid = fcb.user_id AND p.category = 'focalboard' AND p.name = 'hiddenBoardIDs' WHERE p.value LIKE ('%' || fcb.board_id || '%') ); {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.up.sql ================================================ {{if .plugin}} DELETE FROM Preferences WHERE category = 'focalboard' AND name = 'hiddenBoardIDs'; {{else}} DELETE FROM {{.prefix}}preferences WHERE category = 'focalboard' AND name = 'hiddenBoardIDs'; {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/000039_add_path_to_file_info.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000039_add_path_to_file_info.up.sql ================================================ {{ addColumnIfNeeded "file_info" "path" "varchar(512)" "" }} ================================================ FILE: server/services/store/sqlstore/migrations/000040_fix_fileinfo_soft_deletes.down.sql ================================================ SELECT 1; ================================================ FILE: server/services/store/sqlstore/migrations/000040_fix_fileinfo_soft_deletes.up.sql ================================================ {{if .plugin}} UPDATE FileInfo SET DeleteAt = 0 WHERE CreatorId = 'boards' AND DeleteAt != 0; {{else}} SELECT 1; {{end}} ================================================ FILE: server/services/store/sqlstore/migrations/README.md ================================================ # Migration Scripts These scripts are executed against the current database on server start-up. Any scripts previously executed are skipped, however these scripts are designed to be idempotent for Postgres and MySQL. To correct common problems with schema and data migrations the `focalboard_schema_migrations` table can be cleared of all records and the server restarted. The following built-in variables are available: | Name | Syntax | Description | | ----- | ----- | ----- | | schemaName | {{ .schemaName }} | Returns the database/schema name (e.g. `mattermost_`, `mattermost_test`, `public`, ...) | | prefix | {{ .prefix }} | Returns the table name prefix (e.g. `focalbaord_`) | | postgres | {{if .postgres }} ... {{end}} | Returns true if the current database is Postgres. | | sqlite | {{if .sqlite }} ... {{end}} | Returns true if the current database is Sqlite3. | | mysql | {{if .mysql }} ... {{end}} | Returns true if the current database is MySQL. | | plugin | {{if .plugin }} ... {{end}} | Returns true if the server is currently running as a plugin (or product). In others words this is true if the server is not running as stand-alone or personal server. | | singleUser | {{if .singleUser }} ... {{end}} | Returns true if the server is currently running in single user mode. | To help with creating scripts that are idempotent some template functions have been added to the migration engine. | Name | Syntax | Description | | ----- | ----- | ----- | | addColumnIfNeeded | {{ addColumnIfNeeded schemaName tableName columnName datatype constraint }} | Adds column to table only if column doesn't already exist. | | dropColumnIfNeeded | {{ dropColumnIfNeeded schemaName tableName columnName }} | Drops column from table if the column exists. | | createIndexIfNeeded | {{ createIndexIfNeeded schemaName tableName columns }} | Creates an index if it does not already exist. The index name follows the existing convention of using `idx_` plus the table name and all columns separated by underscores. | | renameTableIfNeeded | {{ renameTableIfNeeded schemaName oldTableName newTableName }} | Renames the table if the new table name does not exist. | | renameColumnIfNeeded | {{ renameColumnIfNeeded schemaName tableName oldVolumnName newColumnName datatype }} | Renames a column if the new column name does not exist. | | doesTableExist | {{if doesTableExist schemaName tableName }} ... {{end}} | Returns true if the table exists. Typically used in a `if` statement to conditionally include a section of script. Currently the existence of the table is determined before any scripts are executed (limitation of Morph). | | doesColumnExist | {{if doesTableExist schemaName tableName columnName }} ... {{end}} | Returns true if the column exists. Typically used in a `if` statement to conditionally include a section of script. Currently the existence of the column is determined before any scripts are executed (limitation of Morph). | **Note, table names should not include table prefix or schema name.** ## Examples ```bash {{ addColumnIfNeeded .schemaName "categories" "type" "varchar(64)" ""}} {{ addColumnIfNeeded .schemaName "boards_history" "minimum_role" "varchar(36)" "NOT NULL DEFAULT ''"}} ``` ```bash {{ dropColumnIfNeeded .schemaName "blocks_history" "workspace_id" }} ``` ```bash {{ createIndexIfNeeded .schemaName "boards" "team_id, is_template" }} ``` ```bash {{ renameTableIfNeeded .schemaName "blocks" "blocks_history" }} ``` ```bash {{ renameColumnIfNeeded .schemaName "blocks_history" "workspace_id" "channel_id" "varchar(36)" }} ``` ```bash {{if doesTableExist .schemaName "blocks_history" }} SELECT 'table exists'; {{end}} {{if not (doesTableExist .schemaName "blocks_history") }} SELECT 1; {{end}} ``` ```bash {{if doesColumnExist .schemaName "boards_history" "minimum_role"}} UPDATE ... {{end}} ``` ================================================ FILE: server/services/store/sqlstore/migrationstests/boards_migrator_test.go ================================================ package migrationstests import ( "bytes" "context" "database/sql" "fmt" "path/filepath" "text/template" "github.com/mattermost/mattermost/server/public/pluginapi/cluster" "github.com/mattermost/morph" "github.com/mattermost/morph/drivers" "github.com/mattermost/morph/drivers/mysql" "github.com/mattermost/morph/drivers/postgres" "github.com/mattermost/morph/drivers/sqlite" embedded "github.com/mattermost/morph/sources/embedded" "github.com/mattermost/mattermost/server/public/shared/mlog" mmSqlStore "github.com/mattermost/mattermost/server/public/utils/sql" "github.com/mattermost/mattermost/server/v8/channels/db" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store/sqlstore" ) var tablePrefix = "focalboard_" type BoardsMigrator struct { withMattermostMigrations bool connString string driverName string db *sql.DB store *sqlstore.SQLStore morphEngine *morph.Morph morphDriver drivers.Driver } func NewBoardsMigrator(withMattermostMigrations bool) *BoardsMigrator { return &BoardsMigrator{ withMattermostMigrations: withMattermostMigrations, } } func (bm *BoardsMigrator) runMattermostMigrations() error { assets := db.Assets() assetsList, err := assets.ReadDir(filepath.Join("migrations", bm.driverName)) if err != nil { return err } assetNames := make([]string, len(assetsList)) for i, entry := range assetsList { assetNames[i] = entry.Name() } src, err := embedded.WithInstance(&embedded.AssetSource{ Names: assetNames, AssetFunc: func(name string) ([]byte, error) { return assets.ReadFile(filepath.Join("migrations", bm.driverName, name)) }, }) if err != nil { return err } driver, err := bm.getDriver() if err != nil { return err } options := []morph.EngineOption{ morph.SetStatementTimeoutInSeconds(1000000), } engine, err := morph.New(context.Background(), driver, src, options...) if err != nil { return err } defer engine.Close() return engine.ApplyAll() } func (bm *BoardsMigrator) getDriver() (drivers.Driver, error) { var driver drivers.Driver var err error switch bm.driverName { case model.PostgresDBType: driver, err = postgres.WithInstance(bm.db) if err != nil { return nil, err } case model.MysqlDBType: driver, err = mysql.WithInstance(bm.db) if err != nil { return nil, err } case model.SqliteDBType: driver, err = sqlite.WithInstance(bm.db) if err != nil { return nil, err } } return driver, nil } func (bm *BoardsMigrator) getMorphConnection() (*morph.Morph, drivers.Driver, error) { driver, err := bm.getDriver() if err != nil { return nil, nil, err } assetsList, err := sqlstore.Assets.ReadDir("migrations") if err != nil { return nil, nil, err } assetNamesForDriver := make([]string, len(assetsList)) for i, dirEntry := range assetsList { assetNamesForDriver[i] = dirEntry.Name() } params := map[string]interface{}{ "prefix": tablePrefix, "postgres": bm.driverName == model.PostgresDBType, "sqlite": bm.driverName == model.SqliteDBType, "mysql": bm.driverName == model.MysqlDBType, "plugin": bm.withMattermostMigrations, "singleUser": false, } migrationAssets := &embedded.AssetSource{ Names: assetNamesForDriver, AssetFunc: func(name string) ([]byte, error) { asset, mErr := sqlstore.Assets.ReadFile("migrations/" + name) if mErr != nil { return nil, mErr } tmpl, pErr := template.New("sql").Funcs(bm.store.GetTemplateHelperFuncs()).Parse(string(asset)) if pErr != nil { return nil, pErr } buffer := bytes.NewBufferString("") err = tmpl.Execute(buffer, params) if err != nil { return nil, err } return buffer.Bytes(), nil }, } src, err := embedded.WithInstance(migrationAssets) if err != nil { return nil, nil, err } engine, err := morph.New(context.Background(), driver, src, morph.SetMigrationTableName(fmt.Sprintf("%sschema_migrations", tablePrefix))) if err != nil { return nil, nil, err } return engine, driver, nil } func (bm *BoardsMigrator) Setup() error { var err error bm.driverName, bm.connString, err = sqlstore.PrepareNewTestDatabase() if err != nil { return err } if bm.driverName == model.MysqlDBType { bm.connString, err = mmSqlStore.ResetReadTimeout(bm.connString) if err != nil { return err } bm.connString, err = mmSqlStore.AppendMultipleStatementsFlag(bm.connString) if err != nil { return err } } var dbErr error bm.db, dbErr = sql.Open(bm.driverName, bm.connString) if dbErr != nil { return dbErr } if err := bm.db.Ping(); err != nil { return err } if bm.withMattermostMigrations { if err := bm.runMattermostMigrations(); err != nil { return err } } logger, _ := mlog.NewLogger() storeParams := sqlstore.Params{ DBType: bm.driverName, DBPingAttempts: 5, ConnectionString: bm.connString, TablePrefix: tablePrefix, Logger: logger, DB: bm.db, NewMutexFn: func(name string) (*cluster.Mutex, error) { return nil, fmt.Errorf("not implemented") }, SkipMigrations: true, } bm.store, err = sqlstore.New(storeParams) if err != nil { return err } morphEngine, morphDriver, err := bm.getMorphConnection() if err != nil { return err } bm.morphEngine = morphEngine bm.morphDriver = morphDriver return nil } func (bm *BoardsMigrator) MigrateToStep(step int) error { applied, err := bm.morphDriver.AppliedMigrations() if err != nil { return err } currentVersion := len(applied) if _, err := bm.morphEngine.Apply(step - currentVersion); err != nil { return err } return nil } func (bm *BoardsMigrator) Interceptors() map[int]func() error { return map[int]func() error{ 35: func() error { return bm.store.RunDeDuplicateCategoryBoardsMigration(35) }, } } func (bm *BoardsMigrator) TearDown() error { if err := bm.morphEngine.Close(); err != nil { return err } if err := bm.db.Close(); err != nil { return err } return nil } func (bm *BoardsMigrator) DriverName() string { return bm.driverName } func (bm *BoardsMigrator) DB() *sql.DB { return bm.db } ================================================ FILE: server/services/store/sqlstore/migrationstests/de_duplicate_category_boards_migration_test.go ================================================ package migrationstests import ( "github.com/stretchr/testify/assert" "testing" ) func TestRunDeDuplicateCategoryBoardsMigration(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() if th.IsSQLite() { t.Skip("SQLite is not supported for this") } th.f.MigrateToStepSkippingLastInterceptor(35). ExecFile("./fixtures/testDeDuplicateCategoryBoardsMigration.sql") th.f.RunInterceptor(35) // verifying count of rows var count int countQuery := "SELECT COUNT(*) FROM focalboard_category_boards" row := th.f.DB().QueryRow(countQuery) err := row.Scan(&count) assert.NoError(t, err) assert.Equal(t, 4, count) } ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/deletedMembershipBoardsMigrationFixtures.sql ================================================ INSERT INTO Teams (Id, Name, Type, DeleteAt) VALUES ('team-one', 'team-one', 'O', 0), ('team-two', 'team-two', 'O', 0), ('team-three', 'team-three', 'O', 0); INSERT INTO Channels (Id, DeleteAt, TeamId, Type, Name, CreatorId) VALUES ('group-channel', 0, 'team-one', 'G', 'group-channel', 'user-one'), ('direct-channel', 0, 'team-one', 'D', 'direct-channel', 'user-one'); INSERT INTO Users (Id, Username, Email) VALUES ('user-one', 'john-doe', 'john-doe@sample.com'), ('user-two', 'jane-doe', 'jane-doe@sample.com'); INSERT INTO focalboard_boards (id, team_id, channel_id, created_by, modified_by, type, title, description, icon, show_description, is_template, create_at, update_at, delete_at) VALUES ('board-group-channel', 'team-one', 'group-channel', 'user-one', 'user-one', 'P', 'Group Channel Board', '', '', false, false, 123, 123, 0), ('board-direct-channel', 'team-one', 'direct-channel', 'user-one', 'user-one', 'P', 'Direct Channel Board', '', '', false, false, 123, 123, 0); INSERT INTO focalboard_board_members (board_id, user_id, scheme_admin) VALUES ('board-group-channel', 'user-one', true), ('board-direct-channel', 'user-one', true); INSERT INTO TeamMembers (TeamId, UserId, DeleteAt, SchemeAdmin) VALUES ('team-one', 'user-one', 123, true), ('team-one', 'user-two', 123, true), ('team-two', 'user-one', 123, true), ('team-two', 'user-two', 123, true), ('team-three', 'user-one', 0, true), ('team-three', 'user-two', 0, true); INSERT INTO ChannelMembers (ChannelId, UserId, SchemeUser, SchemeAdmin) VALUES ('group-channel', 'user-one', true, true), ('group-channel', 'two-one', true, false), ('direct-channel', 'user-one', true, true); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test18AddTeamsAndBoardsSQLMigrationFixtures.sql ================================================ INSERT INTO Channels (Id, CreateAt, UpdateAt, DeleteAt, TeamId, Type, Name, CreatorId) VALUES ('chan-id', 123, 123, 0, 'team-id', 'O', 'channel', 'user-id'); INSERT INTO focalboard_blocks (id, workspace_id, root_id, parent_id, created_by, modified_by, type, title, create_at, update_at, delete_at, fields) VALUES ('board-id', 'chan-id', 'board-id', 'board-id', 'user-id', 'user-id', 'board', 'My Board', 123, 123, 0, '{"columnCalculations": {"__title":"countUniqueValue"}}'), ('card-id', 'chan-id', 'board-id', 'board-id', 'user-id', 'user-id', 'card', 'A card', 123, 123, 0, '{}'), ('view-id', 'chan-id', 'board-id', 'board-id', 'user-id', 'user-id', 'view', 'A view', 123, 123, 0, '{"viewType":"table"}'), ('view-id2', 'chan-id', 'board-id', 'board-id', 'user-id', 'user-id', 'view', 'A view2', 123, 123, 0, '{"viewType":"board"}'), ('board-id2', 'chan-id', 'board-id2', 'board-id2', 'user-id', 'user-id', 'board', 'My Board Two', 123, 123, 0, '{"description": "My Description","showDescription":true,"isTemplate":true,"templateVer":1,"columnCalculations":[]}'); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test27MigrateUserPropsToPreferences.sql ================================================ INSERT INTO focalboard_users (id, username, props) VALUES ('user-id', 'johndoe', '{"focalboard_welcomePageViewed": true, "hiddenBoardIDs": ["board1", "board2"], "focalboard_tourCategory": "onboarding", "focalboard_onboardingTourStep": 1, "focalboard_onboardingTourStarted": false, "focalboard_version72MessageCanceled": true, "focalboard_lastWelcomeVersion": 7}'); INSERT INTO focalboard_preferences (UserId, Category, Name, Value) VALUES ('user-id', 'focalboard', 'onboardingTourStarted', true); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test28RemoveTemplateChannelLink.sql ================================================ INSERT INTO focalboard_boards (id, title, type, is_template, channel_id, team_id) VALUES ('board-id', 'Board', 'O', false, 'linked-channel', 'team-id'), ('template-id', 'Template', 'O', true, 'linked-channel', 'team-id'); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test33_with_deleted_data.sql ================================================ INSERT INTO focalboard_category_boards (id, user_id, category_id, board_id, create_at, update_at, delete_at, sort_order) values ('id-1', 'user_id-1', 'category-id-1', 'board-id-1', 1672988834402, 1672988834402, 0, 0), ('id-2', 'user_id-1', 'category-id-2', 'board-id-1', 1672988834402, 1672988834402, 0, 0), ('id-3', 'user_id-2', 'category-id-3', 'board-id-2', 1672988834402, 1672988834402, 1672988834402, 0), ('id-4', 'user_id-2', 'category-id-3', 'board-id-4', 1672988834402, 1672988834402, 0, 0), ('id-5', 'user_id-3', 'category-id-4', 'board-id-3', 1672988834402, 1672988834402, 1672988834402, 0); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test33_with_no_deleted_data.sql ================================================ INSERT INTO focalboard_category_boards (id, user_id, category_id, board_id, create_at, update_at, delete_at, sort_order) values ('id-1', 'user_id-1', 'category-id-1', 'board-id-1', 1672988834402, 1672988834402, 0, 0), ('id-2', 'user_id-1', 'category-id-2', 'board-id-1', 1672988834402, 1672988834402, 0, 0), ('id-3', 'user_id-2', 'category-id-3', 'board-id-2', 1672988834402, 1672988834402, 0, 0), ('id-4', 'user_id-2', 'category-id-3', 'board-id-4', 1672988834402, 1672988834402, 0, 0), ('id-5', 'user_id-3', 'category-id-4', 'board-id-3', 1672988834402, 1672988834402, 0, 0); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test34_drop_delete_at_column.sql ================================================ ALTER TABLE focalboard_category_boards DROP COLUMN delete_at; ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test35_add_hidden_column.sql ================================================ ALTER TABLE focalboard_category_boards ADD COLUMN hidden boolean; ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test36_add_unique_constraint.sql ================================================ ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data.sql ================================================ INSERT INTO focalboard_category_boards (id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden) VALUES ('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false), ('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false), ('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false), ('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false), ('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false); INSERT INTO Preferences VALUES ('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'), ('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]'); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_no_hidden_boards.sql ================================================ INSERT INTO focalboard_category_boards (id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden) VALUES ('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false), ('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false), ('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false), ('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false), ('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_preference_but_no_hidden_board.sql ================================================ INSERT INTO focalboard_category_boards (id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden) VALUES ('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false), ('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false), ('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false), ('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false), ('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false); INSERT INTO Preferences VALUES ('user-id-1', 'focalboard', 'hiddenBoardIDs', ''), ('user-id-2', 'focalboard', 'hiddenBoardIDs', ''); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite.sql ================================================ INSERT INTO focalboard_category_boards (id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden) VALUES ('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false), ('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false), ('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false), ('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false), ('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false); INSERT INTO focalboard_preferences VALUES ('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'), ('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]'); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite_preference_but_no_hidden_board.sql ================================================ INSERT INTO focalboard_category_boards (id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden) VALUES ('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false), ('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false), ('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false), ('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false), ('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false); INSERT INTO focalboard_preferences VALUES ('user-id-1', 'focalboard', 'hiddenBoardIDs', ''), ('user-id-2', 'focalboard', 'hiddenBoardIDs', ''); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test38_add_plugin_preferences.sql ================================================ INSERT INTO Preferences VALUES ('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'), ('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]'), ('user-id-3', 'lorem', 'lorem', ''), ('user-id-4', 'ipsum', 'ipsum', ''); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test38_add_standalone_preferences.sql ================================================ INSERT INTO focalboard_preferences VALUES ('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'), ('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]'), ('user-id-2', 'lorem', 'lorem', ''), ('user-id-2', 'ipsum', 'ipsum', ''); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/test40FixFileinfoSoftDeletes.sql ================================================ INSERT INTO FileInfo (Id, CreatorId, CreateAt, UpdateAt, DeleteAt) VALUES ('fileinfo-1', 'user-id', 1, 1, 1000), ('fileinfo-2', 'user-id', 1, 1, 1000), ('fileinfo-3', 'user-id', 1, 1, 0), ('fileinfo-4', 'boards', 1, 1, 2000), ('fileinfo-5', 'boards', 1, 1, 2000), ('fileinfo-6', 'boards', 1, 1, 0); ================================================ FILE: server/services/store/sqlstore/migrationstests/fixtures/testDeDuplicateCategoryBoardsMigration.sql ================================================ INSERT INTO focalboard_category_boards(id, user_id, category_id, board_id, create_at, update_at, sort_order) VALUES ('id_1', 'user_id_1', 'category_id_1', 'board_id_1', 0, 0, 0), ('id_2', 'user_id_1', 'category_id_2', 'board_id_1', 0, 0, 0), ('id_3', 'user_id_1', 'category_id_3', 'board_id_1', 0, 0, 0), ('id_4', 'user_id_2', 'category_id_4', 'board_id_2', 0, 0, 0), ('id_5', 'user_id_2', 'category_id_5', 'board_id_2', 0, 0, 0), ('id_6', 'user_id_3', 'category_id_6', 'board_id_3', 0, 0, 0), ('id_7', 'user_id_4', 'category_id_6', 'board_id_4', 0, 0, 0); ================================================ FILE: server/services/store/sqlstore/migrationstests/helpers_test.go ================================================ package migrationstests import ( "testing" "github.com/mgdelacroix/foundation" ) type TestHelper struct { t *testing.T f *foundation.Foundation } func (th *TestHelper) IsPostgres() bool { return th.f.DB().DriverName() == "postgres" } func (th *TestHelper) IsMySQL() bool { return th.f.DB().DriverName() == "mysql" } func (th *TestHelper) IsSQLite() bool { return th.f.DB().DriverName() == "sqlite3" } func SetupTestHelper(t *testing.T) (*TestHelper, func()) { return setupTestHelper(t) } func setupTestHelper(t *testing.T) (*TestHelper, func()) { f := foundation.New(t, NewBoardsMigrator(false)) th := &TestHelper{ t: t, f: f, } tearDown := func() { th.f.TearDown() } return th, tearDown } ================================================ FILE: server/services/store/sqlstore/migrationstests/migrate_34_test.go ================================================ package migrationstests import ( "testing" "github.com/stretchr/testify/require" ) func Test34DropDeleteAtColumnMySQLPostgres(t *testing.T) { t.Run("column exists", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(34) // migration 34 only works for MySQL and PostgreSQL if th.IsMySQL() { var count int query := "SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'focalboard_category_boards' AND column_name = 'delete_at'" th.f.DB().Get(&count, query) require.Equal(t, 0, count) } else if th.IsPostgres() { var count int query := "select count(*) from information_schema.columns where table_name = 'focalboard_category_boards' and column_name = 'delete_at'" th.f.DB().Get(&count, query) require.Equal(t, 0, count) } }) t.Run("column already deleted", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() // For migration 34, we don't drop column // on SQLite, so no need to test for it. if th.IsSQLite() { return } th.f.MigrateToStep(33). ExecFile("./fixtures/test34_drop_delete_at_column.sql") th.f.MigrateToStep(34) if th.IsMySQL() { var count int query := "SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'focalboard_category_boards' AND column_name = 'delete_at'" th.f.DB().Get(&count, query) require.Equal(t, 0, count) } else if th.IsPostgres() { var count int query := "select count(*) from information_schema.columns where table_name = 'focalboard_category_boards' and column_name = 'delete_at'" th.f.DB().Get(&count, query) require.Equal(t, 0, count) } }) } ================================================ FILE: server/services/store/sqlstore/migrationstests/migration35_test.go ================================================ package migrationstests import "testing" func Test35AddHIddenColumnToCategoryBoards(t *testing.T) { t.Run("base case - column doesn't already exist", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(35) }) t.Run("column already exist", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() // We don't support adding column in idempotent manner // for SQLite, so no need to check for it. if th.IsSQLite() { return } th.f.MigrateToStep(34). ExecFile("./fixtures/test35_add_hidden_column.sql") th.f.MigrateToStep(35) }) } ================================================ FILE: server/services/store/sqlstore/migrationstests/migration36_test.go ================================================ package migrationstests import ( "testing" "github.com/stretchr/testify/require" ) func Test36AddUniqueConstraintToCategoryBoards(t *testing.T) { t.Run("constraint doesn't alreadt exists", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(36) // verifying if constraint has been added //can't verify in sqlite, so skipping it if th.IsSQLite() { return } var count int query := "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE constraint_name = 'unique_user_category_board' " + "AND constraint_type = 'UNIQUE' " + "AND table_name = 'focalboard_category_boards'" th.f.DB().Get(&count, query) require.Equal(t, 1, count) }) t.Run("constraint already exists", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() // SQLIte doesn't support adding constraint to existing table // and neither do we, so skipping for sqlite if th.IsSQLite() { return } th.f.MigrateToStep(35) if th.IsMySQL() { th.f.DB().Exec("alter table focalboard_category_boards add constraint unique_user_category_board UNIQUE(user_id, board_id);") } else if th.IsPostgres() { th.f.DB().Exec("ALTER TABLE focalboard_category_boards ADD CONSTRAINT unique_user_category_board UNIQUE(user_id, board_id);") } th.f.MigrateToStep(36) var schema string if th.IsMySQL() { schema = "DATABASE()" } else if th.IsPostgres() { schema = "'public'" } var count int query := "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE constraint_schema = " + schema + " " + "AND constraint_name = 'unique_user_category_board' " + "AND constraint_type = 'UNIQUE' " + "AND table_name = 'focalboard_category_boards'" th.f.DB().Get(&count, query) require.Equal(t, 1, count) }) } ================================================ FILE: server/services/store/sqlstore/migrationstests/migration37_test.go ================================================ package migrationstests import ( "testing" "github.com/stretchr/testify/require" ) func Test37MigrateHiddenBoardIDTest(t *testing.T) { t.Run("no existing hidden boards exist", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(37) }) t.Run("SQLite - existsing category boards with some hidden boards", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() if th.IsMySQL() || th.IsPostgres() { return } th.f.MigrateToStep(36). ExecFile("./fixtures/test37_valid_data_sqlite.sql") th.f.MigrateToStep(37) type categoryBoard struct { User_ID string Category_ID string Board_ID string Hidden bool } var hiddenCategoryBoards []categoryBoard query := "SELECT user_id, category_id, board_id, hidden FROM focalboard_category_boards WHERE hidden = true" err := th.f.DB().Select(&hiddenCategoryBoards, query) require.NoError(t, err) require.Equal(t, 3, len(hiddenCategoryBoards)) require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-1", Category_ID: "category-id-1", Board_ID: "board-id-1", Hidden: true}) require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-2", Category_ID: "category-id-3", Board_ID: "board-id-3", Hidden: true}) require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-2", Category_ID: "category-id-3", Board_ID: "board-id-4", Hidden: true}) }) t.Run("SQLite - preference but no hidden board", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() if th.IsMySQL() || th.IsPostgres() { return } th.f.MigrateToStep(36). ExecFile("./fixtures/test37_valid_data_sqlite_preference_but_no_hidden_board.sql") th.f.MigrateToStep(37) var count int query := "SELECT count(*) FROM focalboard_category_boards WHERE hidden = true" err := th.f.DB().Get(&count, query) require.NoError(t, err) require.Equal(t, 0, count) }) } ================================================ FILE: server/services/store/sqlstore/migrationstests/migration38_test.go ================================================ package migrationstests import ( "testing" "github.com/stretchr/testify/require" ) func Test38RemoveHiddenBoardIDsFromPreferences(t *testing.T) { t.Run("standalone - no data exist", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(38) }) t.Run("plugin - no data exist", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(38) }) t.Run("standalone - some data exist", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(37). ExecFile("./fixtures/test38_add_standalone_preferences.sql") // verify existing data count var count int countQuery := "SELECT COUNT(*) FROM focalboard_preferences" err := th.f.DB().Get(&count, countQuery) require.NoError(t, err) require.Equal(t, 4, count) th.f.MigrateToStep(38) // now the count should be 0 err = th.f.DB().Get(&count, countQuery) require.NoError(t, err) require.Equal(t, 2, count) }) } ================================================ FILE: server/services/store/sqlstore/migrationstests/migration_27_test.go ================================================ package migrationstests import ( "encoding/json" "testing" "github.com/stretchr/testify/require" ) func Test27MigrateUserPropsToPreferences(t *testing.T) { t.Run("should correctly migrate properties on personal server and desktop", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(26). ExecFile("./fixtures/test27MigrateUserPropsToPreferences.sql") // first we check that the data was correctly loaded from the // fixtures. We could perfectly skip this step, but as the // failing data is in a JSON field, I preferred to leave it // for clarity user := struct { ID string Username string Props string }{} err := th.f.DB().Get(&user, "SELECT id, username, props FROM focalboard_users WHERE id = 'user-id'") require.NoError(t, err) userProps := map[string]any{} require.NoError(t, json.Unmarshal([]byte(user.Props), &userProps)) require.Equal(t, "johndoe", user.Username) require.Contains(t, userProps, "focalboard_welcomePageViewed") require.True(t, userProps["focalboard_welcomePageViewed"].(bool)) require.Contains(t, userProps, "hiddenBoardIDs") require.ElementsMatch(t, []string{"board1", "board2"}, userProps["hiddenBoardIDs"]) require.Contains(t, userProps, "focalboard_tourCategory") require.Equal(t, "onboarding", userProps["focalboard_tourCategory"]) require.Contains(t, userProps, "focalboard_onboardingTourStep") require.Equal(t, float64(1), userProps["focalboard_onboardingTourStep"]) require.Contains(t, userProps, "focalboard_onboardingTourStarted") // initially, onboardingTourStarted will be false on the user, // but already inserted in the preferences table as true. The // migration should not overwrite the already existing value, // so after migration #27, this value should be true require.False(t, userProps["focalboard_onboardingTourStarted"].(bool)) require.Contains(t, userProps, "focalboard_version72MessageCanceled") require.True(t, userProps["focalboard_version72MessageCanceled"].(bool)) require.Contains(t, userProps, "focalboard_lastWelcomeVersion") require.Equal(t, float64(7), userProps["focalboard_lastWelcomeVersion"]) // we apply the migration th.f.MigrateToStep(27) // then we load the preferences on a new struct userPreferences := []struct { Name string Value string }{} nErr := th.f.DB().Select(&userPreferences, "SELECT name, value FROM focalboard_preferences WHERE UserId = 'user-id'") require.NoError(t, nErr) // helper function to quickly get a preference value from the // userPreferences slice getValue := func(name string) string { for _, userPreference := range userPreferences { if userPreference.Name == name { return userPreference.Value } } require.FailNow(t, "could not found preference", "while searching for name %q", name) return "this should never be reached" } // and we check that the values are correct welcomePageViewedValue := getValue("welcomePageViewed") // the checks for true or 1 make the test work for all DBs, // that were representing the boolean values in the JSON // struct in different ways require.True(t, welcomePageViewedValue == "true" || welcomePageViewedValue == "1") hiddenBoardIDsValue := getValue("hiddenBoardIDs") require.Contains(t, hiddenBoardIDsValue, "board1") require.Contains(t, hiddenBoardIDsValue, "board2") require.Equal(t, "onboarding", getValue("tourCategory")) onboardingTourStepValue := getValue("onboardingTourStep") require.True(t, onboardingTourStepValue == "true" || onboardingTourStepValue == "1") onboardingTourStartedValue := getValue("onboardingTourStarted") require.True(t, onboardingTourStartedValue == "true" || onboardingTourStartedValue == "1") version72MessageCanceledValue := getValue("version72MessageCanceled") require.True(t, version72MessageCanceledValue == "true" || version72MessageCanceledValue == "1") require.Equal(t, "7", getValue("lastWelcomeVersion")) }) } ================================================ FILE: server/services/store/sqlstore/migrationstests/migration_28_test.go ================================================ package migrationstests import ( "testing" "github.com/stretchr/testify/require" ) func Test28RemoveTemplateChannelLink(t *testing.T) { t.Run("should correctly remove the channel link from templates", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(27). ExecFile("./fixtures/test28RemoveTemplateChannelLink.sql") // first we check that the data has the expected shape board := struct { ID string Is_template bool Channel_id string }{} template := struct { ID string Is_template bool Channel_id string }{} bErr := th.f.DB().Get(&board, "SELECT id, is_template, channel_id FROM focalboard_boards WHERE id = 'board-id'") require.NoError(t, bErr) require.False(t, board.Is_template) require.Equal(t, "linked-channel", board.Channel_id) tErr := th.f.DB().Get(&template, "SELECT id, is_template, channel_id FROM focalboard_boards WHERE id = 'template-id'") require.NoError(t, tErr) require.True(t, template.Is_template) require.Equal(t, "linked-channel", template.Channel_id) // we apply the migration th.f.MigrateToStep(28) // then we reuse the structs to load again the data and check // that the changes were correctly applied bErr = th.f.DB().Get(&board, "SELECT id, is_template, channel_id FROM focalboard_boards WHERE id = 'board-id'") require.NoError(t, bErr) require.False(t, board.Is_template) require.Equal(t, "linked-channel", board.Channel_id) tErr = th.f.DB().Get(&template, "SELECT id, is_template, channel_id FROM focalboard_boards WHERE id = 'template-id'") require.NoError(t, tErr) require.True(t, template.Is_template) require.Empty(t, template.Channel_id) }) } ================================================ FILE: server/services/store/sqlstore/migrationstests/migration_33_test.go ================================================ package migrationstests import ( "testing" "github.com/stretchr/testify/require" ) func Test33RemoveDeletedCategoryBoards(t *testing.T) { t.Run("base case - no data in table", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(33) }) t.Run("existing data - 2 soft deleted records", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(32). ExecFile("./fixtures/test33_with_deleted_data.sql") // cound total records var count int err := th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards") require.NoError(t, err) require.Equal(t, 5, count) // now we run the migration th.f.MigrateToStep(33) // and verify record count again. // The soft deleted records should have been removed from the DB now err = th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards") require.NoError(t, err) require.Equal(t, 3, count) }) t.Run("existing data - no soft deleted records", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() th.f.MigrateToStep(32). ExecFile("./fixtures/test33_with_no_deleted_data.sql") // cound total records var count int err := th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards") require.NoError(t, err) require.Equal(t, 5, count) // now we run the migration th.f.MigrateToStep(33) // and verify record count again. // Since there were no soft-deleted records, nothing should have been // deleted from the database. err = th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards") require.NoError(t, err) require.Equal(t, 5, count) }) } ================================================ FILE: server/services/store/sqlstore/notificationhints.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package sqlstore import ( "database/sql" "fmt" "time" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var notificationHintFields = []string{ "block_type", "block_id", "modified_by_id", "create_at", "notify_at", } func valuesForNotificationHint(hint *model.NotificationHint) []interface{} { return []interface{}{ hint.BlockType, hint.BlockID, hint.ModifiedByID, hint.CreateAt, hint.NotifyAt, } } func (s *SQLStore) notificationHintFromRows(rows *sql.Rows) ([]*model.NotificationHint, error) { hints := []*model.NotificationHint{} for rows.Next() { var hint model.NotificationHint err := rows.Scan( &hint.BlockType, &hint.BlockID, &hint.ModifiedByID, &hint.CreateAt, &hint.NotifyAt, ) if err != nil { return nil, err } hints = append(hints, &hint) } return hints, nil } // upsertNotificationHint creates or updates a notification hint. When updating the `notify_at` is set // to the current time plus `notifyFreq`. func (s *SQLStore) upsertNotificationHint(db sq.BaseRunner, hint *model.NotificationHint, notifyFreq time.Duration) (*model.NotificationHint, error) { if err := hint.IsValid(); err != nil { return nil, err } hint.CreateAt = utils.GetMillis() notifyAt := utils.GetMillisForTime(time.Now().Add(notifyFreq)) hint.NotifyAt = notifyAt query := s.getQueryBuilder(db).Insert(s.tablePrefix + "notification_hints"). Columns(notificationHintFields...). Values(valuesForNotificationHint(hint)...) if s.dbType == model.MysqlDBType { query = query.Suffix("ON DUPLICATE KEY UPDATE notify_at = ?", notifyAt) } else { query = query.Suffix("ON CONFLICT (block_id) DO UPDATE SET notify_at = ?", notifyAt) } if _, err := query.Exec(); err != nil { s.logger.Error("Cannot upsert notification hint", mlog.String("block_id", hint.BlockID), mlog.Err(err), ) return nil, err } return hint, nil } // deleteNotificationHint deletes the notification hint for the specified block. func (s *SQLStore) deleteNotificationHint(db sq.BaseRunner, blockID string) error { query := s.getQueryBuilder(db). Delete(s.tablePrefix + "notification_hints"). Where(sq.Eq{"block_id": blockID}) result, err := query.Exec() if err != nil { return err } count, err := result.RowsAffected() if err != nil { return err } if count == 0 { return model.NewErrNotFound("notification hint BlockID=" + blockID) } return nil } // getNotificationHint fetches the notification hint for the specified block. func (s *SQLStore) getNotificationHint(db sq.BaseRunner, blockID string) (*model.NotificationHint, error) { query := s.getQueryBuilder(db). Select(notificationHintFields...). From(s.tablePrefix + "notification_hints"). Where(sq.Eq{"block_id": blockID}) rows, err := query.Query() if err != nil { s.logger.Error("Cannot fetch notification hint", mlog.String("block_id", blockID), mlog.Err(err), ) return nil, err } defer s.CloseRows(rows) hint, err := s.notificationHintFromRows(rows) if err != nil { s.logger.Error("Cannot get notification hint", mlog.String("block_id", blockID), mlog.Err(err), ) return nil, err } if len(hint) == 0 { return nil, model.NewErrNotFound("notification hint BlockID=" + blockID) } return hint[0], nil } // getNextNotificationHint fetches the next scheduled notification hint. If remove is true // then the hint is removed from the database as well, as if popping from a stack. func (s *SQLStore) getNextNotificationHint(db sq.BaseRunner, remove bool) (*model.NotificationHint, error) { selectQuery := s.getQueryBuilder(db). Select(notificationHintFields...). From(s.tablePrefix + "notification_hints"). OrderBy("notify_at"). Limit(1) rows, err := selectQuery.Query() if err != nil { s.logger.Error("Cannot fetch next notification hint", mlog.Err(err), ) return nil, err } defer s.CloseRows(rows) hints, err := s.notificationHintFromRows(rows) if err != nil { s.logger.Error("Cannot get next notification hint", mlog.Err(err), ) return nil, err } if len(hints) == 0 { return nil, model.NewErrNotFound("next notification hint") } hint := hints[0] if remove { deleteQuery := s.getQueryBuilder(db). Delete(s.tablePrefix + "notification_hints"). Where(sq.Eq{"block_id": hint.BlockID}) result, err := deleteQuery.Exec() if err != nil { return nil, fmt.Errorf("cannot delete while getting next notification hint: %w", err) } rows, err := result.RowsAffected() if err != nil { return nil, fmt.Errorf("cannot verify delete while getting next notification hint: %w", err) } if rows == 0 { // another node likely has grabbed this hint for processing concurrently; let that node handle it // and we'll return an error here so we try again. return nil, model.NewErrNotFound("notification hint") } } return hint, nil } ================================================ FILE: server/services/store/sqlstore/params.go ================================================ package sqlstore import ( "database/sql" "fmt" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // servicesAPI is the interface required my the Params to interact with the mattermost-server. // You can use plugin-api or product-api adapter implementations. type servicesAPI interface { GetChannelByID(string) (*mmModel.Channel, error) } type Params struct { DBType string ConnectionString string DBPingAttempts int TablePrefix string Logger mlog.LoggerIFace DB *sql.DB IsSingleUser bool NewMutexFn MutexFactory ServicesAPI servicesAPI SkipMigrations bool ConfigFn func() *mmModel.Config } type ErrStoreParam struct { name string issue string } func (e ErrStoreParam) Error() string { return fmt.Sprintf("invalid store params: %s %s", e.name, e.issue) } ================================================ FILE: server/services/store/sqlstore/public_methods.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. // Code generated by "make generate" from the Store interface // DO NOT EDIT // To add a public method, create an entry in the Store interface, // prefix it with a @withTransaction comment if you need it to be // transactional and then add a private method in the store itself // with db sq.BaseRunner as the first parameter before running `make // generate` package sqlstore import ( "context" "time" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (s *SQLStore) AddUpdateCategoryBoard(userID string, categoryID string, boardIDs []string) error { if s.dbType == model.SqliteDBType { return s.addUpdateCategoryBoard(s.db, userID, categoryID, boardIDs) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.addUpdateCategoryBoard(tx, userID, categoryID, boardIDs) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "AddUpdateCategoryBoard")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) CanSeeUser(seerID string, seenID string) (bool, error) { return s.canSeeUser(s.db, seerID, seenID) } func (s *SQLStore) CleanUpSessions(expireTime int64) error { return s.cleanUpSessions(s.db, expireTime) } func (s *SQLStore) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) { if s.dbType == model.SqliteDBType { return s.createBoardsAndBlocks(s.db, bab, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return nil, txErr } result, err := s.createBoardsAndBlocks(tx, bab, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "CreateBoardsAndBlocks")) } return nil, err } if err := tx.Commit(); err != nil { return nil, err } return result, nil } func (s *SQLStore) CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error) { if s.dbType == model.SqliteDBType { return s.createBoardsAndBlocksWithAdmin(s.db, bab, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return nil, nil, txErr } result, resultVar1, err := s.createBoardsAndBlocksWithAdmin(tx, bab, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "CreateBoardsAndBlocksWithAdmin")) } return nil, nil, err } if err := tx.Commit(); err != nil { return nil, nil, err } return result, resultVar1, nil } func (s *SQLStore) CreateCategory(category model.Category) error { if s.dbType == model.SqliteDBType { return s.createCategory(s.db, category) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.createCategory(tx, category) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "CreateCategory")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) CreateSession(session *model.Session) error { return s.createSession(s.db, session) } func (s *SQLStore) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) { return s.createSubscription(s.db, sub) } func (s *SQLStore) CreateUser(user *model.User) (*model.User, error) { return s.createUser(s.db, user) } func (s *SQLStore) DeleteBlock(blockID string, modifiedBy string) error { if s.dbType == model.SqliteDBType { return s.deleteBlock(s.db, blockID, modifiedBy) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.deleteBlock(tx, blockID, modifiedBy) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "DeleteBlock")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) DeleteBlockRecord(blockID string, modifiedBy string) error { return s.deleteBlockRecord(s.db, blockID, modifiedBy) } func (s *SQLStore) DeleteBoard(boardID string, userID string) error { if s.dbType == model.SqliteDBType { return s.deleteBoard(s.db, boardID, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.deleteBoard(tx, boardID, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "DeleteBoard")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) DeleteBoardRecord(boardID string, modifiedBy string) error { return s.deleteBoardRecord(s.db, boardID, modifiedBy) } func (s *SQLStore) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID string) error { if s.dbType == model.SqliteDBType { return s.deleteBoardsAndBlocks(s.db, dbab, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.deleteBoardsAndBlocks(tx, dbab, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "DeleteBoardsAndBlocks")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) DeleteCategory(categoryID string, userID string, teamID string) error { return s.deleteCategory(s.db, categoryID, userID, teamID) } func (s *SQLStore) DeleteMember(boardID string, userID string) error { return s.deleteMember(s.db, boardID, userID) } func (s *SQLStore) DeleteNotificationHint(blockID string) error { return s.deleteNotificationHint(s.db, blockID) } func (s *SQLStore) DeleteSession(sessionID string) error { return s.deleteSession(s.db, sessionID) } func (s *SQLStore) DeleteSubscription(blockID string, subscriberID string) error { return s.deleteSubscription(s.db, blockID, subscriberID) } func (s *SQLStore) DuplicateBlock(boardID string, blockID string, userID string, asTemplate bool) ([]*model.Block, error) { if s.dbType == model.SqliteDBType { return s.duplicateBlock(s.db, boardID, blockID, userID, asTemplate) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return nil, txErr } result, err := s.duplicateBlock(tx, boardID, blockID, userID, asTemplate) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "DuplicateBlock")) } return nil, err } if err := tx.Commit(); err != nil { return nil, err } return result, nil } func (s *SQLStore) DuplicateBoard(boardID string, userID string, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) { if s.dbType == model.SqliteDBType { return s.duplicateBoard(s.db, boardID, userID, toTeam, asTemplate) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return nil, nil, txErr } result, resultVar1, err := s.duplicateBoard(tx, boardID, userID, toTeam, asTemplate) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "DuplicateBoard")) } return nil, nil, err } if err := tx.Commit(); err != nil { return nil, nil, err } return result, resultVar1, nil } func (s *SQLStore) GetActiveUserCount(updatedSecondsAgo int64) (int, error) { return s.getActiveUserCount(s.db, updatedSecondsAgo) } func (s *SQLStore) GetAllTeams() ([]*model.Team, error) { return s.getAllTeams(s.db) } func (s *SQLStore) GetBlock(blockID string) (*model.Block, error) { return s.getBlock(s.db, blockID) } func (s *SQLStore) GetBlockCountsByType() (map[string]int64, error) { return s.getBlockCountsByType(s.db) } func (s *SQLStore) GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error) { return s.getBlockHistory(s.db, blockID, opts) } func (s *SQLStore) GetBlockHistoryDescendants(boardID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error) { return s.getBlockHistoryDescendants(s.db, boardID, opts) } func (s *SQLStore) GetBlockHistoryNewestChildren(parentID string, opts model.QueryBlockHistoryChildOptions) ([]*model.Block, bool, error) { return s.getBlockHistoryNewestChildren(s.db, parentID, opts) } func (s *SQLStore) GetBlocks(opts model.QueryBlocksOptions) ([]*model.Block, error) { return s.getBlocks(s.db, opts) } func (s *SQLStore) GetBlocksByIDs(ids []string) ([]*model.Block, error) { return s.getBlocksByIDs(s.db, ids) } func (s *SQLStore) GetBlocksComplianceHistory(opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) { return s.getBlocksComplianceHistory(s.db, opts) } func (s *SQLStore) GetBlocksForBoard(boardID string) ([]*model.Block, error) { return s.getBlocksForBoard(s.db, boardID) } func (s *SQLStore) GetBlocksWithParent(boardID string, parentID string) ([]*model.Block, error) { return s.getBlocksWithParent(s.db, boardID, parentID) } func (s *SQLStore) GetBlocksWithParentAndType(boardID string, parentID string, blockType string) ([]*model.Block, error) { return s.getBlocksWithParentAndType(s.db, boardID, parentID, blockType) } func (s *SQLStore) GetBlocksWithType(boardID string, blockType string) ([]*model.Block, error) { return s.getBlocksWithType(s.db, boardID, blockType) } func (s *SQLStore) GetBoard(id string) (*model.Board, error) { return s.getBoard(s.db, id) } func (s *SQLStore) GetBoardAndCard(block *model.Block) (*model.Board, *model.Block, error) { return s.getBoardAndCard(s.db, block) } func (s *SQLStore) GetBoardAndCardByID(blockID string) (*model.Board, *model.Block, error) { return s.getBoardAndCardByID(s.db, blockID) } func (s *SQLStore) GetBoardCount() (int64, error) { return s.getBoardCount(s.db) } func (s *SQLStore) GetBoardHistory(boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) { return s.getBoardHistory(s.db, boardID, opts) } func (s *SQLStore) GetBoardMemberHistory(boardID string, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) { return s.getBoardMemberHistory(s.db, boardID, userID, limit) } func (s *SQLStore) GetBoardsComplianceHistory(opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) { return s.getBoardsComplianceHistory(s.db, opts) } func (s *SQLStore) GetBoardsForCompliance(opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) { return s.getBoardsForCompliance(s.db, opts) } func (s *SQLStore) GetBoardsForUserAndTeam(userID string, teamID string, includePublicBoards bool) ([]*model.Board, error) { return s.getBoardsForUserAndTeam(s.db, userID, teamID, includePublicBoards) } func (s *SQLStore) GetBoardsInTeamByIds(boardIDs []string, teamID string) ([]*model.Board, error) { return s.getBoardsInTeamByIds(s.db, boardIDs, teamID) } func (s *SQLStore) GetCardLimitTimestamp() (int64, error) { return s.getCardLimitTimestamp(s.db) } func (s *SQLStore) GetCategory(id string) (*model.Category, error) { return s.getCategory(s.db, id) } func (s *SQLStore) GetChannel(teamID string, channelID string) (*mmModel.Channel, error) { return s.getChannel(s.db, teamID, channelID) } func (s *SQLStore) GetFileInfo(id string) (*mmModel.FileInfo, error) { return s.getFileInfo(s.db, id) } func (s *SQLStore) GetLicense() *mmModel.License { return s.getLicense(s.db) } func (s *SQLStore) GetMemberForBoard(boardID string, userID string) (*model.BoardMember, error) { return s.getMemberForBoard(s.db, boardID, userID) } func (s *SQLStore) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) { return s.getMembersForBoard(s.db, boardID) } func (s *SQLStore) GetMembersForUser(userID string) ([]*model.BoardMember, error) { return s.getMembersForUser(s.db, userID) } func (s *SQLStore) GetNextNotificationHint(remove bool) (*model.NotificationHint, error) { return s.getNextNotificationHint(s.db, remove) } func (s *SQLStore) GetNotificationHint(blockID string) (*model.NotificationHint, error) { return s.getNotificationHint(s.db, blockID) } func (s *SQLStore) GetRegisteredUserCount() (int, error) { return s.getRegisteredUserCount(s.db) } func (s *SQLStore) GetSession(token string, expireTime int64) (*model.Session, error) { return s.getSession(s.db, token, expireTime) } func (s *SQLStore) GetSharing(rootID string) (*model.Sharing, error) { return s.getSharing(s.db, rootID) } func (s *SQLStore) GetSubTree2(boardID string, blockID string, opts model.QuerySubtreeOptions) ([]*model.Block, error) { return s.getSubTree2(s.db, boardID, blockID, opts) } func (s *SQLStore) GetSubscribersCountForBlock(blockID string) (int, error) { return s.getSubscribersCountForBlock(s.db, blockID) } func (s *SQLStore) GetSubscribersForBlock(blockID string) ([]*model.Subscriber, error) { return s.getSubscribersForBlock(s.db, blockID) } func (s *SQLStore) GetSubscription(blockID string, subscriberID string) (*model.Subscription, error) { return s.getSubscription(s.db, blockID, subscriberID) } func (s *SQLStore) GetSubscriptions(subscriberID string) ([]*model.Subscription, error) { return s.getSubscriptions(s.db, subscriberID) } func (s *SQLStore) GetSystemSetting(key string) (string, error) { return s.getSystemSetting(s.db, key) } func (s *SQLStore) GetSystemSettings() (map[string]string, error) { return s.getSystemSettings(s.db) } func (s *SQLStore) GetTeam(ID string) (*model.Team, error) { return s.getTeam(s.db, ID) } func (s *SQLStore) GetTeamCount() (int64, error) { return s.getTeamCount(s.db) } func (s *SQLStore) GetTeamsForUser(userID string) ([]*model.Team, error) { return s.getTeamsForUser(s.db, userID) } func (s *SQLStore) GetTemplateBoards(teamID string, userID string) ([]*model.Board, error) { return s.getTemplateBoards(s.db, teamID, userID) } func (s *SQLStore) GetUsedCardsCount() (int, error) { return s.getUsedCardsCount(s.db) } func (s *SQLStore) GetUserByEmail(email string) (*model.User, error) { return s.getUserByEmail(s.db, email) } func (s *SQLStore) GetUserByID(userID string) (*model.User, error) { return s.getUserByID(s.db, userID) } func (s *SQLStore) GetUserByUsername(username string) (*model.User, error) { return s.getUserByUsername(s.db, username) } func (s *SQLStore) GetUserCategories(userID string, teamID string) ([]model.Category, error) { return s.getUserCategories(s.db, userID, teamID) } func (s *SQLStore) GetUserCategoryBoards(userID string, teamID string) ([]model.CategoryBoards, error) { return s.getUserCategoryBoards(s.db, userID, teamID) } func (s *SQLStore) GetUserPreferences(userID string) (mmModel.Preferences, error) { return s.getUserPreferences(s.db, userID) } func (s *SQLStore) GetUserTimezone(userID string) (string, error) { return s.getUserTimezone(s.db, userID) } func (s *SQLStore) GetUsersByTeam(teamID string, asGuestID string, showEmail bool, showName bool) ([]*model.User, error) { return s.getUsersByTeam(s.db, teamID, asGuestID, showEmail, showName) } func (s *SQLStore) GetUsersList(userIDs []string, showEmail bool, showName bool) ([]*model.User, error) { return s.getUsersList(s.db, userIDs, showEmail, showName) } func (s *SQLStore) InsertBlock(block *model.Block, userID string) error { if s.dbType == model.SqliteDBType { return s.insertBlock(s.db, block, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.insertBlock(tx, block, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "InsertBlock")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) InsertBlocks(blocks []*model.Block, userID string) error { if s.dbType == model.SqliteDBType { return s.insertBlocks(s.db, blocks, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.insertBlocks(tx, blocks, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "InsertBlocks")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) InsertBoard(board *model.Board, userID string) (*model.Board, error) { return s.insertBoard(s.db, board, userID) } func (s *SQLStore) InsertBoardWithAdmin(board *model.Board, userID string) (*model.Board, *model.BoardMember, error) { if s.dbType == model.SqliteDBType { return s.insertBoardWithAdmin(s.db, board, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return nil, nil, txErr } result, resultVar1, err := s.insertBoardWithAdmin(tx, board, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "InsertBoardWithAdmin")) } return nil, nil, err } if err := tx.Commit(); err != nil { return nil, nil, err } return result, resultVar1, nil } func (s *SQLStore) PatchBlock(blockID string, blockPatch *model.BlockPatch, userID string) error { if s.dbType == model.SqliteDBType { return s.patchBlock(s.db, blockID, blockPatch, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.patchBlock(tx, blockID, blockPatch, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "PatchBlock")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) PatchBlocks(blockPatches *model.BlockPatchBatch, userID string) error { if s.dbType == model.SqliteDBType { return s.patchBlocks(s.db, blockPatches, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.patchBlocks(tx, blockPatches, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "PatchBlocks")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) PatchBoard(boardID string, boardPatch *model.BoardPatch, userID string) (*model.Board, error) { if s.dbType == model.SqliteDBType { return s.patchBoard(s.db, boardID, boardPatch, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return nil, txErr } result, err := s.patchBoard(tx, boardID, boardPatch, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "PatchBoard")) } return nil, err } if err := tx.Commit(); err != nil { return nil, err } return result, nil } func (s *SQLStore) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) { if s.dbType == model.SqliteDBType { return s.patchBoardsAndBlocks(s.db, pbab, userID) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return nil, txErr } result, err := s.patchBoardsAndBlocks(tx, pbab, userID) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "PatchBoardsAndBlocks")) } return nil, err } if err := tx.Commit(); err != nil { return nil, err } return result, nil } func (s *SQLStore) PatchUserPreferences(userID string, patch model.UserPreferencesPatch) (mmModel.Preferences, error) { return s.patchUserPreferences(s.db, userID, patch) } func (s *SQLStore) PostMessage(message string, postType string, channelID string) error { return s.postMessage(s.db, message, postType, channelID) } func (s *SQLStore) RefreshSession(session *model.Session) error { return s.refreshSession(s.db, session) } func (s *SQLStore) RemoveDefaultTemplates(boards []*model.Board) error { return s.removeDefaultTemplates(s.db, boards) } func (s *SQLStore) ReorderCategories(userID string, teamID string, newCategoryOrder []string) ([]string, error) { return s.reorderCategories(s.db, userID, teamID, newCategoryOrder) } func (s *SQLStore) ReorderCategoryBoards(categoryID string, newBoardsOrder []string) ([]string, error) { return s.reorderCategoryBoards(s.db, categoryID, newBoardsOrder) } func (s *SQLStore) RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error) { if s.dbType == model.SqliteDBType { return s.runDataRetention(s.db, globalRetentionDate, batchSize) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return 0, txErr } result, err := s.runDataRetention(tx, globalRetentionDate, batchSize) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "RunDataRetention")) } return 0, err } if err := tx.Commit(); err != nil { return 0, err } return result, nil } func (s *SQLStore) SaveFileInfo(fileInfo *mmModel.FileInfo) error { return s.saveFileInfo(s.db, fileInfo) } func (s *SQLStore) SaveMember(bm *model.BoardMember) (*model.BoardMember, error) { return s.saveMember(s.db, bm) } func (s *SQLStore) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) { return s.searchBoardsForUser(s.db, term, searchField, userID, includePublicBoards) } func (s *SQLStore) SearchBoardsForUserInTeam(teamID string, term string, userID string) ([]*model.Board, error) { return s.searchBoardsForUserInTeam(s.db, teamID, term, userID) } func (s *SQLStore) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) { return s.searchUserChannels(s.db, teamID, userID, query) } func (s *SQLStore) SearchUsersByTeam(teamID string, searchQuery string, asGuestID string, excludeBots bool, showEmail bool, showName bool) ([]*model.User, error) { return s.searchUsersByTeam(s.db, teamID, searchQuery, asGuestID, excludeBots, showEmail, showName) } func (s *SQLStore) SendMessage(message string, postType string, receipts []string) error { return s.sendMessage(s.db, message, postType, receipts) } func (s *SQLStore) SetBoardVisibility(userID string, categoryID string, boardID string, visible bool) error { return s.setBoardVisibility(s.db, userID, categoryID, boardID, visible) } func (s *SQLStore) SetSystemSetting(key string, value string) error { return s.setSystemSetting(s.db, key, value) } func (s *SQLStore) UndeleteBlock(blockID string, modifiedBy string) error { if s.dbType == model.SqliteDBType { return s.undeleteBlock(s.db, blockID, modifiedBy) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.undeleteBlock(tx, blockID, modifiedBy) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "UndeleteBlock")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) UndeleteBoard(boardID string, modifiedBy string) error { if s.dbType == model.SqliteDBType { return s.undeleteBoard(s.db, boardID, modifiedBy) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } err := s.undeleteBoard(tx, boardID, modifiedBy) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "UndeleteBoard")) } return err } if err := tx.Commit(); err != nil { return err } return nil } func (s *SQLStore) UpdateCardLimitTimestamp(cardLimit int) (int64, error) { return s.updateCardLimitTimestamp(s.db, cardLimit) } func (s *SQLStore) UpdateCategory(category model.Category) error { return s.updateCategory(s.db, category) } func (s *SQLStore) UpdateSession(session *model.Session) error { return s.updateSession(s.db, session) } func (s *SQLStore) UpdateSubscribersNotifiedAt(blockID string, notifiedAt int64) error { return s.updateSubscribersNotifiedAt(s.db, blockID, notifiedAt) } func (s *SQLStore) UpdateUser(user *model.User) (*model.User, error) { return s.updateUser(s.db, user) } func (s *SQLStore) UpdateUserPassword(username string, password string) error { return s.updateUserPassword(s.db, username, password) } func (s *SQLStore) UpdateUserPasswordByID(userID string, password string) error { return s.updateUserPasswordByID(s.db, userID, password) } func (s *SQLStore) UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) { return s.upsertNotificationHint(s.db, hint, notificationFreq) } func (s *SQLStore) UpsertSharing(sharing model.Sharing) error { return s.upsertSharing(s.db, sharing) } func (s *SQLStore) UpsertTeamSettings(team model.Team) error { return s.upsertTeamSettings(s.db, team) } func (s *SQLStore) UpsertTeamSignupToken(team model.Team) error { return s.upsertTeamSignupToken(s.db, team) } ================================================ FILE: server/services/store/sqlstore/schema_table_migration.go ================================================ package sqlstore import ( "bytes" "fmt" "io" "strings" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/morph/models" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // EnsureSchemaMigrationFormat checks the schema migrations table // format and, if it's not using the new shape, it migrates the old // one's status before initializing the migrations engine. func (s *SQLStore) EnsureSchemaMigrationFormat() error { migrationNeeded, err := s.isSchemaMigrationNeeded() if err != nil { return err } if !migrationNeeded { s.logger.Info("Schema migration table is correct format") return nil } s.logger.Info("Migrating schema migration to new format") legacySchemaVersion, err := s.getLegacySchemaVersion() if err != nil { return err } migrations, err := getEmbeddedMigrations() if err != nil { return err } filteredMigrations := filterMigrations(migrations, legacySchemaVersion) if err := s.createTempSchemaTable(); err != nil { return err } s.logger.Info("Populating the temporal schema table", mlog.Uint("legacySchemaVersion", legacySchemaVersion), mlog.Int("migrations", len(filteredMigrations))) if err := s.populateTempSchemaTable(filteredMigrations); err != nil { return err } if err := s.useNewSchemaTable(); err != nil { return err } return nil } // getEmbeddedMigrations returns a list of the embedded migrations // using the morph migration format. The migrations do not have the // contents set, as the goal is to obtain a list of them. func getEmbeddedMigrations() ([]*models.Migration, error) { assetsList, err := Assets.ReadDir("migrations") if err != nil { return nil, err } migrations := []*models.Migration{} for _, f := range assetsList { m, err := models.NewMigration(io.NopCloser(&bytes.Buffer{}), f.Name()) if err != nil { return nil, err } if m.Direction != models.Up { continue } migrations = append(migrations, m) } return migrations, nil } // filterMigrations takes the whole list of migrations parsed from the // embedded directory and returns a filtered list that only contains // one migration per version and those migrations that have already // run based on the legacySchemaVersion. func filterMigrations(migrations []*models.Migration, legacySchemaVersion uint32) []*models.Migration { filteredMigrations := []*models.Migration{} for _, migration := range migrations { // we only take into account up migrations to avoid duplicates if migration.Direction != models.Up { continue } // we're only interested on registering migrations that // already run, so we skip those above the legacy version if migration.Version > legacySchemaVersion { continue } filteredMigrations = append(filteredMigrations, migration) } return filteredMigrations } func (s *SQLStore) isSchemaMigrationNeeded() (bool, error) { // Check if `name` column exists on schema version table. // This column exists only for the new schema version table. // SQLite needs a bit of a special handling if s.dbType == model.SqliteDBType { return s.isSchemaMigrationNeededSQLite() } query := s.getQueryBuilder(s.db). Select("COLUMN_NAME"). From("information_schema.COLUMNS"). Where(sq.Eq{ "TABLE_NAME": s.tablePrefix + "schema_migrations", }) switch s.dbType { case model.MysqlDBType: query = query.Where(sq.Eq{"TABLE_SCHEMA": s.schemaName}) case model.PostgresDBType: query = query.Where("table_schema = current_schema()") } rows, err := query.Query() if err != nil { s.logger.Error("failed to fetch columns in schema_migrations table", mlog.Err(err)) return false, err } defer s.CloseRows(rows) data := []string{} for rows.Next() { var columnName string err := rows.Scan(&columnName) if err != nil { s.logger.Error("error scanning rows from schema_migrations table definition", mlog.Err(err)) return false, err } data = append(data, columnName) } if len(data) == 0 { // if no data then table does not exist and therefore a schema migration is not needed. return false, nil } for _, columnName := range data { // look for a column named 'name', if found then no migration is needed if strings.ToLower(columnName) == "name" { return false, nil } } return true, nil } func (s *SQLStore) isSchemaMigrationNeededSQLite() (bool, error) { // the way to check presence of a column is different // for SQLite. Hence, the separate function query := fmt.Sprintf("PRAGMA table_info(\"%sschema_migrations\");", s.tablePrefix) rows, err := s.db.Query(query) if err != nil { s.logger.Error("SQLite - failed to check for columns in schema_migrations table", mlog.Err(err)) return false, err } defer s.CloseRows(rows) const ( idxCid = iota idxName idxType idxNotnull idxDfltValue idxPk ) data := [][]*string{} for rows.Next() { // PRAGMA returns 6 columns row := make([]*string, 6) err := rows.Scan( &row[idxCid], &row[idxName], &row[idxType], &row[idxNotnull], &row[idxDfltValue], &row[idxPk], ) if err != nil { s.logger.Error("error scanning rows from SQLite schema_migrations table definition", mlog.Err(err)) return false, err } data = append(data, row) } if len(data) == 0 { // if no data then table does not exist and therefore a schema migration is not needed. return false, nil } for _, row := range data { // look for a column named 'name', if found then no migration is needed if len(row) >= 2 && strings.ToLower(*row[idxName]) == "name" { return false, nil } } return true, nil } func (s *SQLStore) getLegacySchemaVersion() (uint32, error) { query := s.getQueryBuilder(s.db). Select("version"). From(s.tablePrefix + "schema_migrations") row := query.QueryRow() var version uint32 if err := row.Scan(&version); err != nil { s.logger.Error("error fetching legacy schema version", mlog.Err(err)) return version, err } return version, nil } func (s *SQLStore) createTempSchemaTable() error { // squirrel doesn't support DDL query in query builder // so, we need to use a plain old string query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (Version bigint NOT NULL, Name varchar(64) NOT NULL, PRIMARY KEY (Version))", s.tablePrefix+tempSchemaMigrationTableName) if _, err := s.db.Exec(query); err != nil { s.logger.Error("failed to create temporary schema migration table", mlog.Err(err)) s.logger.Error("createTempSchemaTable error " + err.Error()) return err } return nil } func (s *SQLStore) populateTempSchemaTable(migrations []*models.Migration) error { query := s.getQueryBuilder(s.db). Insert(s.tablePrefix+tempSchemaMigrationTableName). Columns("Version", "Name") for _, migration := range migrations { s.logger.Info("-- Registering migration", mlog.Uint("version", migration.Version), mlog.String("name", migration.Name)) query = query.Values(migration.Version, migration.Name) } if _, err := query.Exec(); err != nil { s.logger.Error("failed to insert migration records into temporary schema table", mlog.Err(err)) return err } return nil } func (s *SQLStore) useNewSchemaTable() error { // first delete the old table, then // rename the new table to old table's name // renaming old schema migration table. Will delete later once the migration is // complete, just in case. var query string if s.dbType == model.MysqlDBType { query = fmt.Sprintf("RENAME TABLE `%sschema_migrations` TO `%sschema_migrations_old_temp`", s.tablePrefix, s.tablePrefix) } else { query = fmt.Sprintf("ALTER TABLE %sschema_migrations RENAME TO %sschema_migrations_old_temp", s.tablePrefix, s.tablePrefix) } if _, err := s.db.Exec(query); err != nil { s.logger.Error("failed to rename old schema migration table", mlog.Err(err)) return err } // renaming new temp table to old table's name if s.dbType == model.MysqlDBType { query = fmt.Sprintf("RENAME TABLE `%s%s` TO `%sschema_migrations`", s.tablePrefix, tempSchemaMigrationTableName, s.tablePrefix) } else { query = fmt.Sprintf("ALTER TABLE %s%s RENAME TO %sschema_migrations", s.tablePrefix, tempSchemaMigrationTableName, s.tablePrefix) } if _, err := s.db.Exec(query); err != nil { s.logger.Error("failed to rename temp schema table", mlog.Err(err)) return err } return nil } func (s *SQLStore) deleteOldSchemaMigrationTable() error { query := "DROP TABLE IF EXISTS " + s.tablePrefix + "schema_migrations_old_temp" if _, err := s.db.Exec(query); err != nil { s.logger.Error("failed to delete old temp schema migrations table", mlog.Err(err)) return err } return nil } ================================================ FILE: server/services/store/sqlstore/schema_table_migration_test.go ================================================ package sqlstore import ( "testing" "github.com/mattermost/morph/models" "github.com/stretchr/testify/require" ) func TestGetEmbeddedMigrations(t *testing.T) { t.Run("should find migrations on the embedded assets", func(t *testing.T) { migrations, err := getEmbeddedMigrations() require.NoError(t, err) require.NotEmpty(t, migrations) }) } func TestFilterMigrations(t *testing.T) { migrations := []*models.Migration{ {Direction: models.Up, Version: 1}, {Direction: models.Down, Version: 1}, {Direction: models.Up, Version: 2}, {Direction: models.Down, Version: 2}, {Direction: models.Up, Version: 3}, {Direction: models.Down, Version: 3}, {Direction: models.Up, Version: 4}, {Direction: models.Down, Version: 4}, } t.Run("only up migrations should be included", func(t *testing.T) { filteredMigrations := filterMigrations(migrations, 4) require.Len(t, filteredMigrations, 4) for _, migration := range filteredMigrations { require.Equal(t, models.Up, migration.Direction) } }) t.Run("only migrations below or equal to the legacy schema version should be included", func(t *testing.T) { testCases := []struct { Name string LegacyVersion uint32 ExpectedVersions []uint32 }{ {"All should be included", 4, []uint32{1, 2, 3, 4}}, {"Only half should be included", 2, []uint32{1, 2}}, {"Three including the third should be included", 3, []uint32{1, 2, 3}}, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { filteredMigrations := filterMigrations(migrations, tc.LegacyVersion) require.Len(t, filteredMigrations, int(tc.LegacyVersion)) versions := make([]uint32, len(filteredMigrations)) for i, migration := range filteredMigrations { versions[i] = migration.Version } require.ElementsMatch(t, versions, tc.ExpectedVersions) }) } }) t.Run("migrations should be included even if they're not sorted", func(t *testing.T) { unsortedMigrations := []*models.Migration{ {Direction: models.Up, Version: 4}, {Direction: models.Down, Version: 4}, {Direction: models.Up, Version: 1}, {Direction: models.Down, Version: 2}, {Direction: models.Down, Version: 1}, {Direction: models.Up, Version: 3}, {Direction: models.Down, Version: 3}, {Direction: models.Up, Version: 2}, } testCases := []struct { Name string LegacyVersion uint32 ExpectedVersions []uint32 }{ {"All should be included", 4, []uint32{1, 2, 3, 4}}, {"Only half should be included", 2, []uint32{1, 2}}, {"Three including the third should be included", 3, []uint32{1, 2, 3}}, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { filteredMigrations := filterMigrations(unsortedMigrations, tc.LegacyVersion) require.Len(t, filteredMigrations, int(tc.LegacyVersion)) versions := make([]uint32, len(filteredMigrations)) for i, migration := range filteredMigrations { versions[i] = migration.Version } require.ElementsMatch(t, versions, tc.ExpectedVersions) }) } }) } ================================================ FILE: server/services/store/sqlstore/session.go ================================================ package sqlstore import ( "encoding/json" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" ) // GetActiveUserCount returns the number of users with active sessions within N seconds ago. func (s *SQLStore) getActiveUserCount(db sq.BaseRunner, updatedSecondsAgo int64) (int, error) { query := s.getQueryBuilder(db). Select("count(distinct user_id)"). From(s.tablePrefix + "sessions"). Where(sq.Gt{"update_at": utils.GetMillis() - utils.SecondsToMillis(updatedSecondsAgo)}) row := query.QueryRow() var count int err := row.Scan(&count) if err != nil { return 0, err } return count, nil } func (s *SQLStore) getSession(db sq.BaseRunner, token string, expireTimeSeconds int64) (*model.Session, error) { query := s.getQueryBuilder(db). Select("id", "token", "user_id", "auth_service", "props"). From(s.tablePrefix + "sessions"). Where(sq.Eq{"token": token}). Where(sq.Gt{"update_at": utils.GetMillis() - utils.SecondsToMillis(expireTimeSeconds)}) row := query.QueryRow() session := model.Session{} var propsBytes []byte err := row.Scan(&session.ID, &session.Token, &session.UserID, &session.AuthService, &propsBytes) if err != nil { return nil, err } err = json.Unmarshal(propsBytes, &session.Props) if err != nil { return nil, err } return &session, nil } func (s *SQLStore) createSession(db sq.BaseRunner, session *model.Session) error { now := utils.GetMillis() propsBytes, err := json.Marshal(session.Props) if err != nil { return err } query := s.getQueryBuilder(db).Insert(s.tablePrefix+"sessions"). Columns("id", "token", "user_id", "auth_service", "props", "create_at", "update_at"). Values(session.ID, session.Token, session.UserID, session.AuthService, propsBytes, now, now) _, err = query.Exec() return err } func (s *SQLStore) refreshSession(db sq.BaseRunner, session *model.Session) error { now := utils.GetMillis() query := s.getQueryBuilder(db).Update(s.tablePrefix+"sessions"). Where(sq.Eq{"token": session.Token}). Set("update_at", now) _, err := query.Exec() return err } func (s *SQLStore) updateSession(db sq.BaseRunner, session *model.Session) error { now := utils.GetMillis() propsBytes, err := json.Marshal(session.Props) if err != nil { return err } query := s.getQueryBuilder(db).Update(s.tablePrefix+"sessions"). Where(sq.Eq{"token": session.Token}). Set("update_at", now). Set("props", propsBytes) _, err = query.Exec() return err } func (s *SQLStore) deleteSession(db sq.BaseRunner, sessionID string) error { query := s.getQueryBuilder(db).Delete(s.tablePrefix + "sessions"). Where(sq.Eq{"id": sessionID}) _, err := query.Exec() return err } func (s *SQLStore) cleanUpSessions(db sq.BaseRunner, expireTimeSeconds int64) error { query := s.getQueryBuilder(db).Delete(s.tablePrefix + "sessions"). Where(sq.Lt{"update_at": utils.GetMillis() - utils.SecondsToMillis(expireTimeSeconds)}) _, err := query.Exec() return err } ================================================ FILE: server/services/store/sqlstore/sharing.go ================================================ package sqlstore import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" sq "github.com/Masterminds/squirrel" ) func (s *SQLStore) upsertSharing(db sq.BaseRunner, sharing model.Sharing) error { now := utils.GetMillis() query := s.getQueryBuilder(db). Insert(s.tablePrefix+"sharing"). Columns( "id", "enabled", "token", "modified_by", "update_at", ). Values( sharing.ID, sharing.Enabled, sharing.Token, sharing.ModifiedBy, now, ) if s.dbType == model.MysqlDBType { query = query.Suffix("ON DUPLICATE KEY UPDATE enabled = ?, token = ?, modified_by = ?, update_at = ?", sharing.Enabled, sharing.Token, sharing.ModifiedBy, now) } else { query = query.Suffix( `ON CONFLICT (id) DO UPDATE SET enabled = EXCLUDED.enabled, token = EXCLUDED.token, modified_by = EXCLUDED.modified_by, update_at = EXCLUDED.update_at`, ) } _, err := query.Exec() return err } func (s *SQLStore) getSharing(db sq.BaseRunner, boardID string) (*model.Sharing, error) { query := s.getQueryBuilder(db). Select( "id", "enabled", "token", "modified_by", "update_at", ). From(s.tablePrefix + "sharing"). Where(sq.Eq{"id": boardID}) row := query.QueryRow() sharing := model.Sharing{} err := row.Scan( &sharing.ID, &sharing.Enabled, &sharing.Token, &sharing.ModifiedBy, &sharing.UpdateAt, ) if err != nil { return nil, err } return &sharing, nil } ================================================ FILE: server/services/store/sqlstore/sqlite.go ================================================ //go:build sqlite3 package sqlstore import _ "github.com/mattn/go-sqlite3" // sqlite driver ================================================ FILE: server/services/store/sqlstore/sqlstore.go ================================================ package sqlstore import ( "database/sql" "fmt" "net/url" "strings" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/mattermost/server/public/pluginapi/cluster" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // SQLStore is a SQL database. type SQLStore struct { db *sql.DB dbType string tablePrefix string connectionString string dbPingAttempts int isSingleUser bool logger mlog.LoggerIFace NewMutexFn MutexFactory servicesAPI servicesAPI isBinaryParam bool schemaName string configFn func() *mmModel.Config } // MutexFactory is used by the store in plugin mode to generate // a cluster mutex. type MutexFactory func(name string) (*cluster.Mutex, error) // New creates a new SQL implementation of the store. func New(params Params) (*SQLStore, error) { params.Logger.Info("connectDatabase", mlog.String("dbType", params.DBType)) store := &SQLStore{ // TODO: add replica DB support too. db: params.DB, dbType: params.DBType, dbPingAttempts: params.DBPingAttempts, tablePrefix: params.TablePrefix, connectionString: params.ConnectionString, logger: params.Logger, isSingleUser: params.IsSingleUser, NewMutexFn: params.NewMutexFn, servicesAPI: params.ServicesAPI, configFn: params.ConfigFn, } var err error store.isBinaryParam, err = store.computeBinaryParam() if err != nil { params.Logger.Error(`Cannot compute binary parameter`, mlog.Err(err)) return nil, err } store.schemaName, err = store.GetSchemaName() if err != nil { params.Logger.Error(`Cannot get schema name`, mlog.Err(err)) return nil, err } if !params.SkipMigrations { if mErr := store.Migrate(); mErr != nil { params.Logger.Error(`Table creation / migration failed`, mlog.Err(mErr)) return nil, mErr } } return store, nil } func (s *SQLStore) IsMariaDB() bool { if s.dbType != model.MysqlDBType { return false } row := s.db.QueryRow("SELECT Version()") var version string if err := row.Scan(&version); err != nil { s.logger.Error("error checking database version", mlog.Err(err)) return false } return strings.Contains(strings.ToLower(version), "mariadb") } // computeBinaryParam returns whether the data source uses binary_parameters // when using Postgres. func (s *SQLStore) computeBinaryParam() (bool, error) { if s.dbType != model.PostgresDBType { return false, nil } url, err := url.Parse(s.connectionString) if err != nil { return false, err } return url.Query().Get("binary_parameters") == "yes", nil } // Shutdown close the connection with the store. func (s *SQLStore) Shutdown() error { return s.db.Close() } // DBHandle returns the raw sql.DB handle. // It is used by the mattermostauthlayer to run their own // raw SQL queries. func (s *SQLStore) DBHandle() *sql.DB { return s.db } // DBType returns the DB driver used for the store. func (s *SQLStore) DBType() string { return s.dbType } func (s *SQLStore) getQueryBuilder(db sq.BaseRunner) sq.StatementBuilderType { builder := sq.StatementBuilder if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType { builder = builder.PlaceholderFormat(sq.Dollar) } return builder.RunWith(db) } func (s *SQLStore) escapeField(fieldName string) string { //nolint:unparam if s.dbType == model.MysqlDBType { return "`" + fieldName + "`" } if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType { return "\"" + fieldName + "\"" } return fieldName } func (s *SQLStore) concatenationSelector(field string, delimiter string) string { if s.dbType == model.SqliteDBType { return fmt.Sprintf("group_concat(%s)", field) } if s.dbType == model.PostgresDBType { return fmt.Sprintf("string_agg(%s, '%s')", field, delimiter) } if s.dbType == model.MysqlDBType { return fmt.Sprintf("GROUP_CONCAT(%s SEPARATOR '%s')", field, delimiter) } return "" } func (s *SQLStore) elementInColumn(column string) string { if s.dbType == model.SqliteDBType || s.dbType == model.MysqlDBType { return fmt.Sprintf("instr(%s, ?) > 0", column) } if s.dbType == model.PostgresDBType { return fmt.Sprintf("position(? in %s) > 0", column) } return "" } func (s *SQLStore) getLicense(db sq.BaseRunner) *mmModel.License { return nil } func (s *SQLStore) searchUserChannels(db sq.BaseRunner, teamID, userID, query string) ([]*mmModel.Channel, error) { return nil, store.NewNotSupportedError("search user channels not supported on standalone mode") } func (s *SQLStore) getChannel(db sq.BaseRunner, teamID, channel string) (*mmModel.Channel, error) { return nil, store.NewNotSupportedError("get channel not supported on standalone mode") } func (s *SQLStore) DBVersion() string { var version string var row *sql.Row switch s.dbType { case model.MysqlDBType: row = s.db.QueryRow("SELECT VERSION()") case model.PostgresDBType: row = s.db.QueryRow("SHOW server_version") case model.SqliteDBType: row = s.db.QueryRow("SELECT sqlite_version()") default: return "" } if err := row.Scan(&version); err != nil { s.logger.Error("error checking database version", mlog.Err(err)) return "" } return version } ================================================ FILE: server/services/store/sqlstore/sqlstore_test.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package sqlstore import ( "testing" "github.com/mattermost/focalboard/server/services/store/storetests" "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/require" ) func TestSQLStore(t *testing.T) { t.Run("BlocksStore", func(t *testing.T) { storetests.StoreTestBlocksStore(t, SetupTests) }) t.Run("SharingStore", func(t *testing.T) { storetests.StoreTestSharingStore(t, SetupTests) }) t.Run("SystemStore", func(t *testing.T) { storetests.StoreTestSystemStore(t, SetupTests) }) t.Run("UserStore", func(t *testing.T) { storetests.StoreTestUserStore(t, SetupTests) }) t.Run("SessionStore", func(t *testing.T) { storetests.StoreTestSessionStore(t, SetupTests) }) t.Run("TeamStore", func(t *testing.T) { storetests.StoreTestTeamStore(t, SetupTests) }) t.Run("BoardStore", func(t *testing.T) { storetests.StoreTestBoardStore(t, SetupTests) }) t.Run("BoardsAndBlocksStore", func(t *testing.T) { storetests.StoreTestBoardsAndBlocksStore(t, SetupTests) }) t.Run("SubscriptionStore", func(t *testing.T) { storetests.StoreTestSubscriptionsStore(t, SetupTests) }) t.Run("NotificationHintStore", func(t *testing.T) { storetests.StoreTestNotificationHintsStore(t, SetupTests) }) t.Run("DataRetention", func(t *testing.T) { storetests.StoreTestDataRetention(t, SetupTests) }) t.Run("CloudStore", func(t *testing.T) { storetests.StoreTestCloudStore(t, SetupTests) }) t.Run("StoreTestFileStore", func(t *testing.T) { storetests.StoreTestFileStore(t, SetupTests) }) t.Run("StoreTestCategoryStore", func(t *testing.T) { storetests.StoreTestCategoryStore(t, SetupTests) }) t.Run("StoreTestCategoryBoardsStore", func(t *testing.T) { storetests.StoreTestCategoryBoardsStore(t, SetupTests) }) t.Run("ComplianceHistoryStore", func(t *testing.T) { storetests.StoreTestComplianceHistoryStore(t, SetupTests) }) } // tests for utility functions inside sqlstore.go func TestConcatenationSelector(t *testing.T) { store, tearDown := SetupTests(t) sqlStore := store.(*SQLStore) defer tearDown() concatenationString := sqlStore.concatenationSelector("a", ",") switch sqlStore.dbType { case model.SqliteDBType: require.Equal(t, concatenationString, "group_concat(a)") case model.MysqlDBType: require.Equal(t, concatenationString, "GROUP_CONCAT(a SEPARATOR ',')") case model.PostgresDBType: require.Equal(t, concatenationString, "string_agg(a, ',')") } } func TestElementInColumn(t *testing.T) { store, _ := SetupTests(t) sqlStore := store.(*SQLStore) inLiteral := sqlStore.elementInColumn("test_column") switch sqlStore.dbType { case model.SqliteDBType: require.Equal(t, inLiteral, "instr(test_column, ?) > 0") case model.MysqlDBType: require.Equal(t, inLiteral, "instr(test_column, ?) > 0") case model.PostgresDBType: require.Equal(t, inLiteral, "position(? in test_column) > 0") } } ================================================ FILE: server/services/store/sqlstore/subscriptions.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package sqlstore import ( "database/sql" "fmt" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var subscriptionFields = []string{ "block_type", "block_id", "subscriber_type", "subscriber_id", "notified_at", "create_at", "delete_at", } func valuesForSubscription(sub *model.Subscription) []interface{} { return []interface{}{ sub.BlockType, sub.BlockID, sub.SubscriberType, sub.SubscriberID, sub.NotifiedAt, sub.CreateAt, sub.DeleteAt, } } func (s *SQLStore) subscriptionsFromRows(rows *sql.Rows) ([]*model.Subscription, error) { subscriptions := []*model.Subscription{} for rows.Next() { var sub model.Subscription err := rows.Scan( &sub.BlockType, &sub.BlockID, &sub.SubscriberType, &sub.SubscriberID, &sub.NotifiedAt, &sub.CreateAt, &sub.DeleteAt, ) if err != nil { return nil, err } subscriptions = append(subscriptions, &sub) } return subscriptions, nil } // createSubscription creates a new subscription, or returns an existing subscription // for the block & subscriber. func (s *SQLStore) createSubscription(db sq.BaseRunner, sub *model.Subscription) (*model.Subscription, error) { if err := sub.IsValid(); err != nil { return nil, err } now := model.GetMillis() subAdd := *sub subAdd.NotifiedAt = now // notified_at set so first notification doesn't pick up all history subAdd.CreateAt = now subAdd.DeleteAt = 0 query := s.getQueryBuilder(db). Insert(s.tablePrefix + "subscriptions"). Columns(subscriptionFields...). Values(valuesForSubscription(&subAdd)...) if s.dbType == model.MysqlDBType { query = query.Suffix("ON DUPLICATE KEY UPDATE delete_at = 0, notified_at = ?", now) } else { query = query.Suffix("ON CONFLICT (block_id,subscriber_id) DO UPDATE SET delete_at = 0, notified_at = ?", now) } if _, err := query.Exec(); err != nil { s.logger.Error("Cannot create subscription", mlog.String("block_id", sub.BlockID), mlog.String("subscriber_id", sub.SubscriberID), mlog.Err(err), ) return nil, err } return &subAdd, nil } // deleteSubscription soft deletes the subscription for a specific block and subscriber. func (s *SQLStore) deleteSubscription(db sq.BaseRunner, blockID string, subscriberID string) error { now := model.GetMillis() query := s.getQueryBuilder(db). Update(s.tablePrefix+"subscriptions"). Set("delete_at", now). Where(sq.Eq{"block_id": blockID}). Where(sq.Eq{"subscriber_id": subscriberID}) result, err := query.Exec() if err != nil { return err } count, err := result.RowsAffected() if err != nil { return err } if count == 0 { message := fmt.Sprintf("subscription BlockID=%s SubscriberID=%s", blockID, subscriberID) return model.NewErrNotFound(message) } return nil } // getSubscription fetches the subscription for a specific block and subscriber. func (s *SQLStore) getSubscription(db sq.BaseRunner, blockID string, subscriberID string) (*model.Subscription, error) { query := s.getQueryBuilder(db). Select(subscriptionFields...). From(s.tablePrefix + "subscriptions"). Where(sq.Eq{"block_id": blockID}). Where(sq.Eq{"subscriber_id": subscriberID}). Where(sq.Eq{"delete_at": 0}) rows, err := query.Query() if err != nil { s.logger.Error("Cannot fetch subscription for block & subscriber", mlog.String("block_id", blockID), mlog.String("subscriber_id", subscriberID), mlog.Err(err), ) return nil, err } defer s.CloseRows(rows) subscriptions, err := s.subscriptionsFromRows(rows) if err != nil { s.logger.Error("Cannot get subscription for block & subscriber", mlog.String("block_id", blockID), mlog.String("subscriber_id", subscriberID), mlog.Err(err), ) return nil, err } if len(subscriptions) == 0 { message := fmt.Sprintf("subscription BlockID=%s SubscriberID=%s", blockID, subscriberID) return nil, model.NewErrNotFound(message) } return subscriptions[0], nil } // getSubscriptions fetches all subscriptions for a specific subscriber. func (s *SQLStore) getSubscriptions(db sq.BaseRunner, subscriberID string) ([]*model.Subscription, error) { query := s.getQueryBuilder(db). Select(subscriptionFields...). From(s.tablePrefix + "subscriptions"). Where(sq.Eq{"subscriber_id": subscriberID}). Where(sq.Eq{"delete_at": 0}) rows, err := query.Query() if err != nil { s.logger.Error("Cannot fetch subscriptions for subscriber", mlog.String("subscriber_id", subscriberID), mlog.Err(err), ) return nil, err } defer s.CloseRows(rows) return s.subscriptionsFromRows(rows) } // getSubscribersForBlock fetches all subscribers for a block. func (s *SQLStore) getSubscribersForBlock(db sq.BaseRunner, blockID string) ([]*model.Subscriber, error) { query := s.getQueryBuilder(db). Select( "subscriber_type", "subscriber_id", "notified_at", ). From(s.tablePrefix + "subscriptions"). Where(sq.Eq{"block_id": blockID}). Where(sq.Eq{"delete_at": 0}). OrderBy("notified_at") rows, err := query.Query() if err != nil { s.logger.Error("Cannot fetch subscribers for block", mlog.String("block_id", blockID), mlog.Err(err), ) return nil, err } defer s.CloseRows(rows) subscribers := []*model.Subscriber{} for rows.Next() { var sub model.Subscriber err := rows.Scan( &sub.SubscriberType, &sub.SubscriberID, &sub.NotifiedAt, ) if err != nil { return nil, err } subscribers = append(subscribers, &sub) } return subscribers, nil } // getSubscribersCountForBlock returns a count of all subscribers for a block. func (s *SQLStore) getSubscribersCountForBlock(db sq.BaseRunner, blockID string) (int, error) { query := s.getQueryBuilder(db). Select("count(subscriber_id)"). From(s.tablePrefix + "subscriptions"). Where(sq.Eq{"block_id": blockID}). Where(sq.Eq{"delete_at": 0}) row := query.QueryRow() var count int err := row.Scan(&count) if err != nil { s.logger.Error("Cannot count subscribers for block", mlog.String("block_id", blockID), mlog.Err(err), ) return 0, err } return count, nil } // updateSubscribersNotifiedAt updates the notified_at field of all subscribers for a block. func (s *SQLStore) updateSubscribersNotifiedAt(db sq.BaseRunner, blockID string, notifiedAt int64) error { query := s.getQueryBuilder(db). Update(s.tablePrefix+"subscriptions"). Set("notified_at", notifiedAt). Where(sq.Eq{"block_id": blockID}). Where(sq.Eq{"delete_at": 0}) if _, err := query.Exec(); err != nil { s.logger.Error("UpdateSubscribersNotifiedAt error occurred while updating subscriber(s)", mlog.String("blockID", blockID), mlog.Err(err), ) return err } return nil } ================================================ FILE: server/services/store/sqlstore/system.go ================================================ package sqlstore import ( sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" ) func (s *SQLStore) getSystemSetting(db sq.BaseRunner, key string) (string, error) { scanner := s.getQueryBuilder(db). Select("value"). From(s.tablePrefix + "system_settings"). Where(sq.Eq{"id": key}). QueryRow() var result string err := scanner.Scan(&result) if err != nil && !model.IsErrNotFound(err) { return "", err } return result, nil } func (s *SQLStore) getSystemSettings(db sq.BaseRunner) (map[string]string, error) { query := s.getQueryBuilder(db).Select("*").From(s.tablePrefix + "system_settings") rows, err := query.Query() if err != nil { return nil, err } defer s.CloseRows(rows) results := map[string]string{} for rows.Next() { var id string var value string err := rows.Scan(&id, &value) if err != nil { return nil, err } results[id] = value } return results, nil } func (s *SQLStore) setSystemSetting(db sq.BaseRunner, id, value string) error { query := s.getQueryBuilder(db).Insert(s.tablePrefix+"system_settings").Columns("id", "value").Values(id, value) if s.dbType == model.MysqlDBType { query = query.Suffix("ON DUPLICATE KEY UPDATE value = ?", value) } else { query = query.Suffix("ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value") } _, err := query.Exec() if err != nil { return err } return nil } ================================================ FILE: server/services/store/sqlstore/team.go ================================================ package sqlstore import ( "database/sql" "encoding/json" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" sq "github.com/Masterminds/squirrel" ) var ( teamFields = []string{ "id", "signup_token", "COALESCE(settings, '{}')", "modified_by", "update_at", } ) func (s *SQLStore) upsertTeamSignupToken(db sq.BaseRunner, team model.Team) error { now := utils.GetMillis() query := s.getQueryBuilder(db). Insert(s.tablePrefix+"teams"). Columns( "id", "signup_token", "modified_by", "update_at", ). Values( team.ID, team.SignupToken, team.ModifiedBy, now, ) if s.dbType == model.MysqlDBType { query = query.Suffix("ON DUPLICATE KEY UPDATE signup_token = ?, modified_by = ?, update_at = ?", team.SignupToken, team.ModifiedBy, now) } else { query = query.Suffix( `ON CONFLICT (id) DO UPDATE SET signup_token = EXCLUDED.signup_token, modified_by = EXCLUDED.modified_by, update_at = EXCLUDED.update_at`, ) } _, err := query.Exec() return err } func (s *SQLStore) upsertTeamSettings(db sq.BaseRunner, team model.Team) error { now := utils.GetMillis() signupToken := utils.NewID(utils.IDTypeToken) settingsJSON, err := json.Marshal(team.Settings) if err != nil { return err } query := s.getQueryBuilder(db). Insert(s.tablePrefix+"teams"). Columns( "id", "signup_token", "settings", "modified_by", "update_at", ). Values( team.ID, signupToken, settingsJSON, team.ModifiedBy, now, ) if s.dbType == model.MysqlDBType { query = query.Suffix("ON DUPLICATE KEY UPDATE settings = ?, modified_by = ?, update_at = ?", settingsJSON, team.ModifiedBy, now) } else { query = query.Suffix( `ON CONFLICT (id) DO UPDATE SET settings = EXCLUDED.settings, modified_by = EXCLUDED.modified_by, update_at = EXCLUDED.update_at`, ) } _, err = query.Exec() return err } func (s *SQLStore) getTeam(db sq.BaseRunner, id string) (*model.Team, error) { var settingsJSON string query := s.getQueryBuilder(db). Select( "id", "signup_token", "COALESCE(settings, '{}')", "modified_by", "update_at", ). From(s.tablePrefix + "teams"). Where(sq.Eq{"id": id}) row := query.QueryRow() team := model.Team{} err := row.Scan( &team.ID, &team.SignupToken, &settingsJSON, &team.ModifiedBy, &team.UpdateAt, ) if err != nil { return nil, err } err = json.Unmarshal([]byte(settingsJSON), &team.Settings) if err != nil { s.logger.Error(`ERROR GetTeam settings json.Unmarshal`, mlog.Err(err)) return nil, err } return &team, nil } func (s *SQLStore) getTeamsForUser(db sq.BaseRunner, _ string) ([]*model.Team, error) { return s.getAllTeams(db) } func (s *SQLStore) getTeamCount(db sq.BaseRunner) (int64, error) { query := s.getQueryBuilder(db). Select( "COUNT(*) AS count", ). From(s.tablePrefix + "teams") rows, err := query.Query() if err != nil { s.logger.Error("ERROR GetTeamCount", mlog.Err(err)) return 0, err } defer s.CloseRows(rows) var count int64 rows.Next() err = rows.Scan(&count) if err != nil { s.logger.Error("Failed to fetch team count", mlog.Err(err)) return 0, err } return count, nil } func (s *SQLStore) teamsFromRows(rows *sql.Rows) ([]*model.Team, error) { teams := []*model.Team{} for rows.Next() { var team model.Team var settingsBytes []byte err := rows.Scan( &team.ID, &team.SignupToken, &settingsBytes, &team.ModifiedBy, &team.UpdateAt, ) if err != nil { return nil, err } err = json.Unmarshal(settingsBytes, &team.Settings) if err != nil { return nil, err } teams = append(teams, &team) } return teams, nil } func (s *SQLStore) getAllTeams(db sq.BaseRunner) ([]*model.Team, error) { query := s.getQueryBuilder(db). Select(teamFields...). From(s.tablePrefix + "teams") rows, err := query.Query() if err != nil { s.logger.Error("ERROR GetAllTeams", mlog.Err(err)) return nil, err } defer s.CloseRows(rows) teams, err := s.teamsFromRows(rows) if err != nil { return nil, err } return teams, nil } ================================================ FILE: server/services/store/sqlstore/templates.go ================================================ package sqlstore import ( "errors" "fmt" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var ( ErrUnsupportedDatabaseType = errors.New("database type is unsupported") ) // removeDefaultTemplates deletes all the default templates and their children. func (s *SQLStore) removeDefaultTemplates(db sq.BaseRunner, boards []*model.Board) error { count := 0 for _, board := range boards { if board.CreatedBy != model.SystemUserID { continue } // default template deletion does not need to go to blocks_history deleteQuery := s.getQueryBuilder(db). Delete(s.tablePrefix + "boards"). Where(sq.Eq{"id": board.ID}). Where(sq.Eq{"is_template": true}) if _, err := deleteQuery.Exec(); err != nil { return fmt.Errorf("cannot delete default template %s: %w", board.ID, err) } deleteQuery = s.getQueryBuilder(db). Delete(s.tablePrefix + "blocks"). Where(sq.Or{ sq.Eq{"parent_id": board.ID}, sq.Eq{"root_id": board.ID}, sq.Eq{"board_id": board.ID}, }) if _, err := deleteQuery.Exec(); err != nil { return fmt.Errorf("cannot delete default template %s: %w", board.ID, err) } s.logger.Trace("removed default template block", mlog.String("board_id", board.ID), ) count++ } s.logger.Debug("Removed default templates", mlog.Int("count", count)) return nil } // getTemplateBoards fetches all template boards . func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID, userID string) ([]*model.Board, error) { query := s.getQueryBuilder(db). Select(boardFields("")...). From(s.tablePrefix+"boards as b"). LeftJoin(s.tablePrefix+"board_members as bm on b.id = bm.board_id and bm.user_id = ?", userID). Where(sq.Eq{"is_template": true}). Where(sq.Eq{"b.team_id": teamID}). Where(sq.Or{ // this is to include public templates even if there is not board_member entry sq.And{ sq.Eq{"bm.board_id": nil}, sq.Eq{"b.type": model.BoardTypeOpen}, }, sq.And{ sq.NotEq{"bm.board_id": nil}, }, }) rows, err := query.Query() if err != nil { s.logger.Error(`getTemplateBoards ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) userTemplates, err := s.boardsFromRows(rows) if err != nil { return nil, err } return userTemplates, nil } ================================================ FILE: server/services/store/sqlstore/user.go ================================================ package sqlstore import ( "database/sql" "errors" "fmt" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/v8/channels/store" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) var ( errUnsupportedOperation = errors.New("unsupported operation") ) type UserNotFoundError struct { id string } func (unf UserNotFoundError) Error() string { return fmt.Sprintf("user not found (%s)", unf.id) } func (s *SQLStore) getRegisteredUserCount(db sq.BaseRunner) (int, error) { query := s.getQueryBuilder(db). Select("count(*)"). From(s.tablePrefix + "users"). Where(sq.Eq{"delete_at": 0}) row := query.QueryRow() var count int err := row.Scan(&count) if err != nil { return 0, err } return count, nil } func (s *SQLStore) getUserByCondition(db sq.BaseRunner, condition sq.Eq) (*model.User, error) { users, err := s.getUsersByCondition(db, condition, 0) if err != nil { return nil, err } if len(users) == 0 { return nil, model.NewErrNotFound("user") } return users[0], nil } func (s *SQLStore) getUsersByCondition(db sq.BaseRunner, condition interface{}, limit uint64) ([]*model.User, error) { query := s.getQueryBuilder(db). Select( "id", "username", "email", "password", "mfa_secret", "auth_service", "auth_data", "create_at", "update_at", "delete_at", ). From(s.tablePrefix + "users"). Where(sq.Eq{"delete_at": 0}). Where(condition) if limit != 0 { query = query.Limit(limit) } rows, err := query.Query() if err != nil { s.logger.Error(`getUsersByCondition ERROR`, mlog.Err(err)) return nil, err } defer s.CloseRows(rows) users, err := s.usersFromRows(rows) if err != nil { return nil, err } if len(users) == 0 { return nil, model.NewErrNotFound("user") } return users, nil } func (s *SQLStore) getUserByID(db sq.BaseRunner, userID string) (*model.User, error) { return s.getUserByCondition(db, sq.Eq{"id": userID}) } func (s *SQLStore) getUsersList(db sq.BaseRunner, userIDs []string, _, _ bool) ([]*model.User, error) { users, err := s.getUsersByCondition(db, sq.Eq{"id": userIDs}, 0) if err != nil { return nil, err } if len(users) != len(userIDs) { return users, model.NewErrNotAllFound("user", userIDs) } return users, nil } func (s *SQLStore) getUserByEmail(db sq.BaseRunner, email string) (*model.User, error) { return s.getUserByCondition(db, sq.Eq{"email": email}) } func (s *SQLStore) getUserByUsername(db sq.BaseRunner, username string) (*model.User, error) { return s.getUserByCondition(db, sq.Eq{"username": username}) } func (s *SQLStore) createUser(db sq.BaseRunner, user *model.User) (*model.User, error) { now := utils.GetMillis() user.CreateAt = now user.UpdateAt = now user.DeleteAt = 0 query := s.getQueryBuilder(db).Insert(s.tablePrefix+"users"). Columns("id", "username", "email", "password", "mfa_secret", "auth_service", "auth_data", "create_at", "update_at", "delete_at"). Values(user.ID, user.Username, user.Email, user.Password, user.MfaSecret, user.AuthService, user.AuthData, user.CreateAt, user.UpdateAt, user.DeleteAt) _, err := query.Exec() return user, err } func (s *SQLStore) updateUser(db sq.BaseRunner, user *model.User) (*model.User, error) { now := utils.GetMillis() user.UpdateAt = now query := s.getQueryBuilder(db).Update(s.tablePrefix+"users"). Set("username", user.Username). Set("email", user.Email). Set("update_at", user.UpdateAt). Where(sq.Eq{"id": user.ID}) result, err := query.Exec() if err != nil { return nil, err } rowCount, err := result.RowsAffected() if err != nil { return nil, err } if rowCount < 1 { return nil, UserNotFoundError{user.ID} } return user, nil } func (s *SQLStore) updateUserPassword(db sq.BaseRunner, username, password string) error { now := utils.GetMillis() query := s.getQueryBuilder(db).Update(s.tablePrefix+"users"). Set("password", password). Set("update_at", now). Where(sq.Eq{"username": username}) result, err := query.Exec() if err != nil { return err } rowCount, err := result.RowsAffected() if err != nil { return err } if rowCount < 1 { return UserNotFoundError{username} } return nil } func (s *SQLStore) updateUserPasswordByID(db sq.BaseRunner, userID, password string) error { now := utils.GetMillis() query := s.getQueryBuilder(db).Update(s.tablePrefix+"users"). Set("password", password). Set("update_at", now). Where(sq.Eq{"id": userID}) result, err := query.Exec() if err != nil { return err } rowCount, err := result.RowsAffected() if err != nil { return err } if rowCount < 1 { return UserNotFoundError{userID} } return nil } func (s *SQLStore) getUsersByTeam(db sq.BaseRunner, _ string, _ string, _, _ bool) ([]*model.User, error) { users, err := s.getUsersByCondition(db, nil, 0) if model.IsErrNotFound(err) { return []*model.User{}, nil } return users, err } func (s *SQLStore) searchUsersByTeam(db sq.BaseRunner, _ string, searchQuery string, _ string, _, _, _ bool) ([]*model.User, error) { users, err := s.getUsersByCondition(db, &sq.Like{"username": "%" + searchQuery + "%"}, 10) if model.IsErrNotFound(err) { return []*model.User{}, nil } return users, err } func (s *SQLStore) usersFromRows(rows *sql.Rows) ([]*model.User, error) { users := []*model.User{} for rows.Next() { var user model.User err := rows.Scan( &user.ID, &user.Username, &user.Email, &user.Password, &user.MfaSecret, &user.AuthService, &user.AuthData, &user.CreateAt, &user.UpdateAt, &user.DeleteAt, ) if err != nil { return nil, err } users = append(users, &user) } return users, nil } func (s *SQLStore) patchUserPreferences(db sq.BaseRunner, userID string, patch model.UserPreferencesPatch) (mmModel.Preferences, error) { preferences, err := s.getUserPreferences(db, userID) if err != nil { return nil, err } if len(patch.UpdatedFields) > 0 { for key, value := range patch.UpdatedFields { preference := mmModel.Preference{ UserId: userID, Category: model.PreferencesCategoryFocalboard, Name: key, Value: value, } if err := s.updateUserPreference(db, preference); err != nil { return nil, err } newPreferences := mmModel.Preferences{} for _, existingPreference := range preferences { if preference.Name != existingPreference.Name { newPreferences = append(newPreferences, existingPreference) } } newPreferences = append(newPreferences, preference) preferences = newPreferences } } if len(patch.DeletedFields) > 0 { for _, key := range patch.DeletedFields { preference := mmModel.Preference{ UserId: userID, Category: model.PreferencesCategoryFocalboard, Name: key, } if err := s.deleteUserPreference(db, preference); err != nil { return nil, err } newPreferences := mmModel.Preferences{} for _, existingPreference := range preferences { if preference.Name != existingPreference.Name { newPreferences = append(newPreferences, existingPreference) } } preferences = newPreferences } } return preferences, nil } func (s *SQLStore) updateUserPreference(db sq.BaseRunner, preference mmModel.Preference) error { query := s.getQueryBuilder(db). Insert(s.tablePrefix+"preferences"). Columns("UserId", "Category", "Name", "Value"). Values(preference.UserId, preference.Category, preference.Name, preference.Value) switch s.dbType { case model.MysqlDBType: query = query.SuffixExpr(sq.Expr("ON DUPLICATE KEY UPDATE Value = ?", preference.Value)) case model.PostgresDBType: query = query.SuffixExpr(sq.Expr("ON CONFLICT (userid, category, name) DO UPDATE SET Value = ?", preference.Value)) case model.SqliteDBType: query = query.SuffixExpr(sq.Expr(" on conflict(userid, category, name) do update set value = excluded.value")) default: return store.NewErrNotImplemented("failed to update preference because of missing driver") } if _, err := query.Exec(); err != nil { return fmt.Errorf("failed to upsert user preference in database: userID: %s name: %s value: %s error: %w", preference.UserId, preference.Name, preference.Value, err) } return nil } func (s *SQLStore) deleteUserPreference(db sq.BaseRunner, preference mmModel.Preference) error { query := s.getQueryBuilder(db). Delete(s.tablePrefix + "preferences"). Where(sq.Eq{"UserId": preference.UserId}). Where(sq.Eq{"Category": preference.Category}). Where(sq.Eq{"Name": preference.Name}) if _, err := query.Exec(); err != nil { return fmt.Errorf("failed to delete user preference from database: %w", err) } return nil } func (s *SQLStore) canSeeUser(db sq.BaseRunner, seerID string, seenID string) (bool, error) { return true, nil } func (s *SQLStore) sendMessage(db sq.BaseRunner, message, postType string, receipts []string) error { return errUnsupportedOperation } func (s *SQLStore) postMessage(db sq.BaseRunner, message, postType string, channel string) error { return errUnsupportedOperation } func (s *SQLStore) getUserTimezone(_ sq.BaseRunner, _ string) (string, error) { return "", errUnsupportedOperation } func (s *SQLStore) getUserPreferences(db sq.BaseRunner, userID string) (mmModel.Preferences, error) { query := s.getQueryBuilder(db). Select("userid", "category", "name", "value"). From(s.tablePrefix + "preferences"). Where(sq.Eq{ "userid": userID, "category": model.PreferencesCategoryFocalboard, }) rows, err := query.Query() if err != nil { s.logger.Error("failed to fetch user preferences", mlog.String("user_id", userID), mlog.Err(err)) return nil, err } defer rows.Close() preferences, err := s.preferencesFromRows(rows) if err != nil { return nil, err } return preferences, nil } func (s *SQLStore) preferencesFromRows(rows *sql.Rows) ([]mmModel.Preference, error) { preferences := []mmModel.Preference{} for rows.Next() { var preference mmModel.Preference err := rows.Scan( &preference.UserId, &preference.Category, &preference.Name, &preference.Value, ) if err != nil { s.logger.Error("failed to scan row for user preference", mlog.Err(err)) return nil, err } preferences = append(preferences, preference) } return preferences, nil } ================================================ FILE: server/services/store/sqlstore/util.go ================================================ package sqlstore import ( "database/sql" "encoding/json" "fmt" "os" "strings" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (s *SQLStore) CloseRows(rows *sql.Rows) { if err := rows.Close(); err != nil { s.logger.Error("error closing MattermostAuthLayer row set", mlog.Err(err)) } } func (s *SQLStore) IsErrNotFound(err error) bool { return model.IsErrNotFound(err) } func (s *SQLStore) MarshalJSONB(data interface{}) ([]byte, error) { b, err := json.Marshal(data) if err != nil { return nil, err } if s.isBinaryParam { b = append([]byte{0x01}, b...) } return b, nil } func PrepareNewTestDatabase() (dbType string, connectionString string, err error) { dbType = strings.TrimSpace(os.Getenv("FOCALBOARD_STORE_TEST_DB_TYPE")) if dbType == "" { dbType = model.SqliteDBType } if dbType == "mariadb" { dbType = model.MysqlDBType } var dbName string var rootUser string if dbType == model.SqliteDBType { file, err := os.CreateTemp("", "fbtest_*.db") if err != nil { return "", "", err } connectionString = file.Name() + "?_busy_timeout=5000" _ = file.Close() } else if port := strings.TrimSpace(os.Getenv("FOCALBOARD_STORE_TEST_DOCKER_PORT")); port != "" { // docker unit tests take priority over any DSN env vars var template string switch dbType { case model.MysqlDBType: template = "%s:mostest@tcp(localhost:%s)/%s?charset=utf8mb4,utf8&writeTimeout=30s" rootUser = "root" case model.PostgresDBType: template = "postgres://%s:mostest@localhost:%s/%s?sslmode=disable\u0026connect_timeout=10" rootUser = "mmuser" default: return "", "", newErrInvalidDBType(dbType) } connectionString = fmt.Sprintf(template, rootUser, port, "") // create a new database each run sqlDB, err := sql.Open(dbType, connectionString) if err != nil { return "", "", fmt.Errorf("cannot connect to %s database: %w", dbType, err) } defer sqlDB.Close() err = sqlDB.Ping() if err != nil { return "", "", fmt.Errorf("cannot ping %s database: %w", dbType, err) } dbName = "testdb_" + utils.NewID(utils.IDTypeNone)[:8] _, err = sqlDB.Exec(fmt.Sprintf("CREATE DATABASE %s;", dbName)) if err != nil { return "", "", fmt.Errorf("cannot create %s database %s: %w", dbType, dbName, err) } if dbType != model.PostgresDBType { _, err = sqlDB.Exec(fmt.Sprintf("GRANT ALL PRIVILEGES ON %s.* TO mmuser;", dbName)) if err != nil { return "", "", fmt.Errorf("cannot grant permissions on %s database %s: %w", dbType, dbName, err) } } connectionString = fmt.Sprintf(template, "mmuser", port, dbName) } else { // mysql or postgres need a DSN (connection string) connectionString = strings.TrimSpace(os.Getenv("FOCALBOARD_STORE_TEST_CONN_STRING")) } return dbType, connectionString, nil } type ErrInvalidDBType struct { dbType string } func newErrInvalidDBType(dbType string) error { return ErrInvalidDBType{ dbType: dbType, } } func (e ErrInvalidDBType) Error() string { return "unsupported database type: " + e.dbType } // deleteBoardRecord deletes a boards record without deleting any child records in the blocks table. // FOR UNIT TESTING ONLY. func (s *SQLStore) deleteBoardRecord(db sq.BaseRunner, boardID string, modifiedBy string) error { return s.deleteBoardAndChildren(db, boardID, modifiedBy, true) } // deleteBlockRecord deletes a blocks record without deleting any child records in the blocks table. // FOR UNIT TESTING ONLY. func (s *SQLStore) deleteBlockRecord(db sq.BaseRunner, blockID, modifiedBy string) error { return s.deleteBlockAndChildren(db, blockID, modifiedBy, true) } func (s *SQLStore) castInt(val int64, as string) string { if s.dbType == model.MysqlDBType { return fmt.Sprintf("cast(%d as unsigned) AS %s", val, as) } return fmt.Sprintf("cast(%d as bigint) AS %s", val, as) } func (s *SQLStore) GetSchemaName() (string, error) { var query sq.SelectBuilder switch s.dbType { case model.MysqlDBType: query = s.getQueryBuilder(s.db).Select("DATABASE()") case model.PostgresDBType: query = s.getQueryBuilder(s.db).Select("current_schema()") case model.SqliteDBType: return "", nil default: return "", ErrUnsupportedDatabaseType } scanner := query.QueryRow() var result string err := scanner.Scan(&result) if err != nil && !model.IsErrNotFound(err) { return "", err } return result, nil } ================================================ FILE: server/services/store/store.go ================================================ //go:generate mockgen -destination=mockstore/mockstore.go -package mockstore . Store //go:generate go run ./generators/main.go package store import ( "time" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" ) const CardLimitTimestampSystemKey = "card_limit_timestamp" // Store represents the abstraction of the data storage. type Store interface { GetBlocks(opts model.QueryBlocksOptions) ([]*model.Block, error) GetBlocksWithParentAndType(boardID, parentID string, blockType string) ([]*model.Block, error) GetBlocksWithParent(boardID, parentID string) ([]*model.Block, error) GetBlocksByIDs(ids []string) ([]*model.Block, error) GetBlocksWithType(boardID, blockType string) ([]*model.Block, error) GetSubTree2(boardID, blockID string, opts model.QuerySubtreeOptions) ([]*model.Block, error) GetBlocksForBoard(boardID string) ([]*model.Block, error) // @withTransaction InsertBlock(block *model.Block, userID string) error // @withTransaction DeleteBlock(blockID string, modifiedBy string) error // @withTransaction InsertBlocks(blocks []*model.Block, userID string) error // @withTransaction UndeleteBlock(blockID string, modifiedBy string) error // @withTransaction UndeleteBoard(boardID string, modifiedBy string) error GetBlockCountsByType() (map[string]int64, error) GetBoardCount() (int64, error) GetBlock(blockID string) (*model.Block, error) // @withTransaction PatchBlock(blockID string, blockPatch *model.BlockPatch, userID string) error GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error) GetBlockHistoryDescendants(boardID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error) GetBlockHistoryNewestChildren(parentID string, opts model.QueryBlockHistoryChildOptions) ([]*model.Block, bool, error) GetBoardHistory(boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) GetBoardAndCardByID(blockID string) (board *model.Board, card *model.Block, err error) GetBoardAndCard(block *model.Block) (board *model.Board, card *model.Block, err error) // @withTransaction DuplicateBoard(boardID string, userID string, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) // @withTransaction DuplicateBlock(boardID string, blockID string, userID string, asTemplate bool) ([]*model.Block, error) // @withTransaction PatchBlocks(blockPatches *model.BlockPatchBatch, userID string) error Shutdown() error GetSystemSetting(key string) (string, error) GetSystemSettings() (map[string]string, error) SetSystemSetting(key, value string) error GetRegisteredUserCount() (int, error) GetUserByID(userID string) (*model.User, error) GetUsersList(userIDs []string, showEmail, showName bool) ([]*model.User, error) GetUserByEmail(email string) (*model.User, error) GetUserByUsername(username string) (*model.User, error) CreateUser(user *model.User) (*model.User, error) UpdateUser(user *model.User) (*model.User, error) UpdateUserPassword(username, password string) error UpdateUserPasswordByID(userID, password string) error GetUsersByTeam(teamID string, asGuestID string, showEmail, showName bool) ([]*model.User, error) SearchUsersByTeam(teamID string, searchQuery string, asGuestID string, excludeBots bool, showEmail, showName bool) ([]*model.User, error) PatchUserPreferences(userID string, patch model.UserPreferencesPatch) (mmModel.Preferences, error) GetUserPreferences(userID string) (mmModel.Preferences, error) GetActiveUserCount(updatedSecondsAgo int64) (int, error) GetSession(token string, expireTime int64) (*model.Session, error) CreateSession(session *model.Session) error RefreshSession(session *model.Session) error UpdateSession(session *model.Session) error DeleteSession(sessionID string) error CleanUpSessions(expireTime int64) error UpsertSharing(sharing model.Sharing) error GetSharing(rootID string) (*model.Sharing, error) UpsertTeamSignupToken(team model.Team) error UpsertTeamSettings(team model.Team) error GetTeam(ID string) (*model.Team, error) GetTeamsForUser(userID string) ([]*model.Team, error) GetAllTeams() ([]*model.Team, error) GetTeamCount() (int64, error) InsertBoard(board *model.Board, userID string) (*model.Board, error) // @withTransaction InsertBoardWithAdmin(board *model.Board, userID string) (*model.Board, *model.BoardMember, error) // @withTransaction PatchBoard(boardID string, boardPatch *model.BoardPatch, userID string) (*model.Board, error) GetBoard(id string) (*model.Board, error) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) GetBoardsInTeamByIds(boardIDs []string, teamID string) ([]*model.Board, error) // @withTransaction DeleteBoard(boardID, userID string) error SaveMember(bm *model.BoardMember) (*model.BoardMember, error) DeleteMember(boardID, userID string) error GetMemberForBoard(boardID, userID string) (*model.BoardMember, error) GetBoardMemberHistory(boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) GetMembersForUser(userID string) ([]*model.BoardMember, error) CanSeeUser(seerID string, seenID string) (bool, error) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) // @withTransaction CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error) // @withTransaction CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) // @withTransaction PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) // @withTransaction DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID string) error GetCategory(id string) (*model.Category, error) GetUserCategories(userID, teamID string) ([]model.Category, error) // @withTransaction CreateCategory(category model.Category) error UpdateCategory(category model.Category) error DeleteCategory(categoryID, userID, teamID string) error ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) GetFileInfo(id string) (*mmModel.FileInfo, error) SaveFileInfo(fileInfo *mmModel.FileInfo) error // @withTransaction AddUpdateCategoryBoard(userID, categoryID string, boardIDs []string) error ReorderCategoryBoards(categoryID string, newBoardsOrder []string) ([]string, error) SetBoardVisibility(userID, categoryID, boardID string, visible bool) error CreateSubscription(sub *model.Subscription) (*model.Subscription, error) DeleteSubscription(blockID string, subscriberID string) error GetSubscription(blockID string, subscriberID string) (*model.Subscription, error) GetSubscriptions(subscriberID string) ([]*model.Subscription, error) GetSubscribersForBlock(blockID string) ([]*model.Subscriber, error) GetSubscribersCountForBlock(blockID string) (int, error) UpdateSubscribersNotifiedAt(blockID string, notifiedAt int64) error UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) DeleteNotificationHint(blockID string) error GetNotificationHint(blockID string) (*model.NotificationHint, error) GetNextNotificationHint(remove bool) (*model.NotificationHint, error) RemoveDefaultTemplates(boards []*model.Board) error GetTemplateBoards(teamID, userID string) ([]*model.Board, error) // @withTransaction RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error) GetUsedCardsCount() (int, error) GetCardLimitTimestamp() (int64, error) UpdateCardLimitTimestamp(cardLimit int) (int64, error) DBType() string DBVersion() string GetLicense() *mmModel.License SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error) GetChannel(teamID, channelID string) (*mmModel.Channel, error) PostMessage(message, postType, channelID string) error SendMessage(message, postType string, receipts []string) error GetUserTimezone(userID string) (string, error) // Compliance GetBoardsForCompliance(opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) GetBoardsComplianceHistory(opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) GetBlocksComplianceHistory(opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) // For unit testing only DeleteBoardRecord(boardID, modifiedBy string) error DeleteBlockRecord(blockID, modifiedBy string) error } type NotSupportedError struct { msg string } func NewNotSupportedError(msg string) NotSupportedError { return NotSupportedError{msg: msg} } func (pe NotSupportedError) Error() string { return pe.msg } ================================================ FILE: server/services/store/storetests/blocks.go ================================================ package storetests import ( "math" "strconv" "strings" "testing" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testUserID = "user-id" testTeamID = "team-id" testBoardID = "board-id" ) func StoreTestBlocksStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("InsertBlock", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testInsertBlock(t, store) }) t.Run("InsertBlocks", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testInsertBlocks(t, store) }) t.Run("PatchBlock", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testPatchBlock(t, store) }) t.Run("PatchBlocks", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testPatchBlocks(t, store) }) t.Run("DeleteBlock", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDeleteBlock(t, store) }) t.Run("UndeleteBlock", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUndeleteBlock(t, store) }) t.Run("GetSubTree2", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetSubTree2(t, store) }) t.Run("GetBlocks", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBlocks(t, store) }) t.Run("GetBlock", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBlock(t, store) }) t.Run("DuplicateBlock", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDuplicateBlock(t, store) }) t.Run("GetBlockMetadata", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBlockMetadata(t, store) }) t.Run("UndeleteBlockChildren", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUndeleteBlockChildren(t, store) }) t.Run("GetBlockHistoryNewestChildren", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBlockHistoryNewestChildren(t, store) }) } func testInsertBlock(t *testing.T, store store.Store) { userID := testUserID boardID := testBoardID blocks, errBlocks := store.GetBlocksForBoard(boardID) require.NoError(t, errBlocks) initialCount := len(blocks) t.Run("valid block", func(t *testing.T) { block := &model.Block{ ID: "id-test", BoardID: boardID, ModifiedBy: userID, } err := store.InsertBlock(block, "user-id-1") require.NoError(t, err) blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount+1) }) t.Run("invalid rootid", func(t *testing.T) { block := &model.Block{ ID: "id-test", BoardID: "", ModifiedBy: userID, } err := store.InsertBlock(block, "user-id-1") require.Error(t, err) blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount+1) }) t.Run("invalid fields data", func(t *testing.T) { block := &model.Block{ ID: "id-test", BoardID: "id-test", ModifiedBy: userID, Fields: map[string]interface{}{"no-serialiable-value": t.Run}, } err := store.InsertBlock(block, "user-id-1") require.Error(t, err) blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount+1) }) t.Run("block with title too large", func(t *testing.T) { block := &model.Block{ ID: "id-test", BoardID: boardID, ModifiedBy: userID, Title: strings.Repeat("A", model.BlockTitleMaxRunes+1), } err := store.InsertBlock(block, "user-id-1") require.ErrorIs(t, err, model.ErrBlockTitleSizeLimitExceeded) }) t.Run("block with aggregated fields size too large", func(t *testing.T) { block := &model.Block{ ID: "id-test", BoardID: boardID, ModifiedBy: userID, Fields: map[string]any{ "one": strings.Repeat("1", model.BlockFieldsMaxRunes/4), "two": strings.Repeat("2", model.BlockFieldsMaxRunes/4), "three": strings.Repeat("3", model.BlockFieldsMaxRunes/4), "four": strings.Repeat("4", model.BlockFieldsMaxRunes/4), }, } err := store.InsertBlock(block, "user-id-2") require.ErrorIs(t, err, model.ErrBlockFieldsSizeLimitExceeded) }) t.Run("insert new block", func(t *testing.T) { block := &model.Block{ BoardID: testBoardID, } err := store.InsertBlock(block, "user-id-2") require.NoError(t, err) require.Equal(t, "user-id-2", block.CreatedBy) }) t.Run("update existing block", func(t *testing.T) { block := &model.Block{ ID: "id-2", BoardID: "board-id-1", Title: "Old Title", } // inserting err := store.InsertBlock(block, "user-id-2") require.NoError(t, err) // created by populated from user id for new blocks require.Equal(t, "user-id-2", block.CreatedBy) // hack to avoid multiple, quick updates to a card // violating block_history composite primary key constraint time.Sleep(1 * time.Millisecond) // updating newBlock := &model.Block{ ID: "id-2", BoardID: "board-id-1", CreatedBy: "user-id-3", Title: "New Title", } err = store.InsertBlock(newBlock, "user-id-4") require.NoError(t, err) // created by is not altered for existing blocks require.Equal(t, "user-id-3", newBlock.CreatedBy) require.Equal(t, "New Title", newBlock.Title) }) t.Run("update existing block with title too large", func(t *testing.T) { block := &model.Block{ ID: "id-3", BoardID: "board-id-1", CreatedBy: "user-id-3", Title: "New Title", } // inserting err := store.InsertBlock(block, "user-id-3") require.NoError(t, err) // created by populated from user id for new blocks require.Equal(t, "user-id-3", block.CreatedBy) // hack to avoid multiple, quick updates to a card // violating block_history composite primary key constraint time.Sleep(1 * time.Millisecond) // updating newBlock := &model.Block{ ID: "id-3", BoardID: "board-id-1", CreatedBy: "user-id-3", Title: strings.Repeat("A", model.BlockTitleMaxRunes+1), } err = store.InsertBlock(newBlock, "user-id-3") require.ErrorIs(t, err, model.ErrBlockTitleSizeLimitExceeded) }) t.Run("update existing block with aggregated fields size too large", func(t *testing.T) { block := &model.Block{ ID: "id-3", BoardID: "board-id-1", CreatedBy: "user-id-3", Title: "New Title", } // inserting err := store.InsertBlock(block, "user-id-3") require.NoError(t, err) // created by populated from user id for new blocks require.Equal(t, "user-id-3", block.CreatedBy) // hack to avoid multiple, quick updates to a card // violating block_history composite primary key constraint time.Sleep(1 * time.Millisecond) // updating newBlock := &model.Block{ ID: "id-3", BoardID: "board-id-1", CreatedBy: "user-id-3", Fields: map[string]any{ "one": strings.Repeat("1", model.BlockFieldsMaxRunes/4), "two": strings.Repeat("2", model.BlockFieldsMaxRunes/4), "three": strings.Repeat("3", model.BlockFieldsMaxRunes/4), "four": strings.Repeat("4", model.BlockFieldsMaxRunes/4), }, } err = store.InsertBlock(newBlock, "user-id-3") require.ErrorIs(t, err, model.ErrBlockFieldsSizeLimitExceeded) }) createdAt, err := time.Parse(time.RFC822, "01 Jan 90 01:00 IST") assert.NoError(t, err) updateAt, err := time.Parse(time.RFC822, "02 Jan 90 01:00 IST") assert.NoError(t, err) t.Run("data tamper attempt", func(t *testing.T) { block := &model.Block{ ID: "id-10", BoardID: "board-id-1", Title: "Old Title", CreateAt: utils.GetMillisForTime(createdAt), UpdateAt: utils.GetMillisForTime(updateAt), CreatedBy: "user-id-5", ModifiedBy: "user-id-6", } // inserting err := store.InsertBlock(block, "user-id-1") require.NoError(t, err) expectedTime := time.Now() retrievedBlock, err := store.GetBlock("id-10") assert.NoError(t, err) assert.NotNil(t, retrievedBlock) assert.Equal(t, "board-id-1", retrievedBlock.BoardID) assert.Equal(t, "user-id-1", retrievedBlock.CreatedBy) assert.Equal(t, "user-id-1", retrievedBlock.ModifiedBy) assert.WithinDurationf(t, expectedTime, utils.GetTimeForMillis(retrievedBlock.CreateAt), 1*time.Second, "create time should be current time") assert.WithinDurationf(t, expectedTime, utils.GetTimeForMillis(retrievedBlock.UpdateAt), 1*time.Second, "update time should be current time") }) } func testInsertBlocks(t *testing.T, store store.Store) { userID := testUserID blocks, errBlocks := store.GetBlocksForBoard("id-test") require.NoError(t, errBlocks) initialCount := len(blocks) t.Run("invalid block", func(t *testing.T) { validBlock := &model.Block{ ID: "id-test", BoardID: "id-test", ModifiedBy: userID, } invalidBlock := &model.Block{ ID: "id-test", BoardID: "", ModifiedBy: userID, } newBlocks := []*model.Block{validBlock, invalidBlock} time.Sleep(1 * time.Millisecond) err := store.InsertBlocks(newBlocks, "user-id-1") require.Error(t, err) blocks, err := store.GetBlocksForBoard("id-test") require.NoError(t, err) // no blocks should have been inserted require.Len(t, blocks, initialCount) }) } func testPatchBlock(t *testing.T, store store.Store) { userID := testUserID boardID := "board-id-1" block := &model.Block{ ID: "id-test", BoardID: boardID, Title: "oldTitle", ModifiedBy: userID, Fields: map[string]interface{}{"test": "test value", "test2": "test value 2"}, } err := store.InsertBlock(block, "user-id-1") require.NoError(t, err) blocks, errBlocks := store.GetBlocksForBoard(boardID) require.NoError(t, errBlocks) initialCount := len(blocks) t.Run("not existing block id", func(t *testing.T) { err := store.PatchBlock("invalid-block-id", &model.BlockPatch{}, "user-id-1") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.True(t, model.IsErrNotFound(err)) blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount) }) t.Run("invalid fields data", func(t *testing.T) { blockPatch := &model.BlockPatch{ UpdatedFields: map[string]interface{}{"no-serialiable-value": t.Run}, } err := store.PatchBlock("id-test", blockPatch, "user-id-1") require.Error(t, err) blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount) }) t.Run("update block fields", func(t *testing.T) { newTitle := "New title" blockPatch := model.BlockPatch{ Title: &newTitle, } // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) // inserting err := store.PatchBlock("id-test", &blockPatch, "user-id-2") require.NoError(t, err) retrievedBlock, err := store.GetBlock("id-test") require.NoError(t, err) // created by populated from user id for new blocks require.Equal(t, "user-id-2", retrievedBlock.ModifiedBy) require.Equal(t, "New title", retrievedBlock.Title) }) t.Run("update block custom fields", func(t *testing.T) { blockPatch := &model.BlockPatch{ UpdatedFields: map[string]interface{}{"test": "new test value", "test3": "new value"}, } // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) // inserting err := store.PatchBlock("id-test", blockPatch, "user-id-2") require.NoError(t, err) retrievedBlock, err := store.GetBlock("id-test") require.NoError(t, err) // created by populated from user id for new blocks require.Equal(t, "user-id-2", retrievedBlock.ModifiedBy) require.Equal(t, "new test value", retrievedBlock.Fields["test"]) require.Equal(t, "test value 2", retrievedBlock.Fields["test2"]) require.Equal(t, "new value", retrievedBlock.Fields["test3"]) }) t.Run("remove block custom fields", func(t *testing.T) { blockPatch := &model.BlockPatch{ DeletedFields: []string{"test", "test3", "test100"}, } // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) // inserting err := store.PatchBlock("id-test", blockPatch, "user-id-2") require.NoError(t, err) retrievedBlock, err := store.GetBlock("id-test") require.NoError(t, err) // created by populated from user id for new blocks require.Equal(t, "user-id-2", retrievedBlock.ModifiedBy) require.Equal(t, nil, retrievedBlock.Fields["test"]) require.Equal(t, "test value 2", retrievedBlock.Fields["test2"]) require.Equal(t, nil, retrievedBlock.Fields["test3"]) }) } func testPatchBlocks(t *testing.T, store store.Store) { block := &model.Block{ ID: "id-test", BoardID: "id-test", Title: "oldTitle", } block2 := &model.Block{ ID: "id-test2", BoardID: "id-test2", Title: "oldTitle2", } insertBlocks := []*model.Block{block, block2} err := store.InsertBlocks(insertBlocks, "user-id-1") require.NoError(t, err) t.Run("successful updated existing blocks", func(t *testing.T) { title := "updatedTitle" blockPatch := model.BlockPatch{ Title: &title, } blockPatch2 := model.BlockPatch{ Title: &title, } blockIds := []string{"id-test", "id-test2"} blockPatches := []model.BlockPatch{blockPatch, blockPatch2} time.Sleep(1 * time.Millisecond) err := store.PatchBlocks(&model.BlockPatchBatch{BlockIDs: blockIds, BlockPatches: blockPatches}, "user-id-1") require.NoError(t, err) retrievedBlock, err := store.GetBlock("id-test") require.NoError(t, err) require.Equal(t, title, retrievedBlock.Title) retrievedBlock2, err := store.GetBlock("id-test2") require.NoError(t, err) require.Equal(t, title, retrievedBlock2.Title) }) t.Run("invalid block id, nothing updated existing blocks", func(t *testing.T) { if store.DBType() == model.SqliteDBType { t.Skip("No transactions support int sqlite") } title := "Another Title" blockPatch := model.BlockPatch{ Title: &title, } blockPatch2 := model.BlockPatch{ Title: &title, } blockIds := []string{"id-test", "invalid id"} blockPatches := []model.BlockPatch{blockPatch, blockPatch2} time.Sleep(1 * time.Millisecond) err := store.PatchBlocks(&model.BlockPatchBatch{BlockIDs: blockIds, BlockPatches: blockPatches}, "user-id-1") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) retrievedBlock, err := store.GetBlock("id-test") require.NoError(t, err) require.NotEqual(t, title, retrievedBlock.Title) }) } var ( subtreeSampleBlocks = []*model.Block{ { ID: "parent", BoardID: testBoardID, ModifiedBy: testUserID, }, { ID: "child1", BoardID: testBoardID, ParentID: "parent", ModifiedBy: testUserID, }, { ID: "child2", BoardID: testBoardID, ParentID: "parent", ModifiedBy: testUserID, }, { ID: "grandchild1", BoardID: testBoardID, ParentID: "child1", ModifiedBy: testUserID, }, { ID: "grandchild2", BoardID: testBoardID, ParentID: "child2", ModifiedBy: testUserID, }, { ID: "greatgrandchild1", BoardID: testBoardID, ParentID: "grandchild1", ModifiedBy: testUserID, }, } ) func testGetSubTree2(t *testing.T, store store.Store) { boardID := testBoardID blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) initialCount := len(blocks) InsertBlocks(t, store, subtreeSampleBlocks, "user-id-1") time.Sleep(1 * time.Millisecond) defer DeleteBlocks(t, store, subtreeSampleBlocks, "test") blocks, err = store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount+6) t.Run("from root id", func(t *testing.T) { blocks, err = store.GetSubTree2(boardID, "parent", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 3) require.True(t, ContainsBlockWithID(blocks, "parent")) require.True(t, ContainsBlockWithID(blocks, "child1")) require.True(t, ContainsBlockWithID(blocks, "child2")) }) t.Run("from child id", func(t *testing.T) { blocks, err = store.GetSubTree2(boardID, "child1", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 2) require.True(t, ContainsBlockWithID(blocks, "child1")) require.True(t, ContainsBlockWithID(blocks, "grandchild1")) }) t.Run("from not existing id", func(t *testing.T) { blocks, err = store.GetSubTree2(boardID, "not-exists", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Empty(t, blocks) }) } func testDeleteBlock(t *testing.T, store store.Store) { userID := testUserID boardID := testBoardID blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) initialCount := len(blocks) blocksToInsert := []*model.Block{ { ID: "block1", BoardID: boardID, ModifiedBy: userID, }, { ID: "block2", BoardID: boardID, ModifiedBy: userID, }, { ID: "block3", BoardID: boardID, ModifiedBy: userID, }, } InsertBlocks(t, store, blocksToInsert, "user-id-1") defer DeleteBlocks(t, store, blocksToInsert, "test") blocks, err = store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount+3) t.Run("existing id", func(t *testing.T) { // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err := store.DeleteBlock("block1", userID) require.NoError(t, err) }) t.Run("existing id multiple times", func(t *testing.T) { // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err := store.DeleteBlock("block1", userID) require.NoError(t, err) // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err = store.DeleteBlock("block1", userID) require.NoError(t, err) }) t.Run("from not existing id", func(t *testing.T) { // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err := store.DeleteBlock("not-exists", userID) require.NoError(t, err) }) } func testUndeleteBlock(t *testing.T, store store.Store) { boardID := testBoardID userID := testUserID blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) initialCount := len(blocks) blocksToInsert := []*model.Block{ { ID: "block1", BoardID: boardID, ModifiedBy: userID, }, { ID: "block2", BoardID: boardID, ModifiedBy: userID, }, { ID: "block3", BoardID: boardID, ModifiedBy: userID, }, } InsertBlocks(t, store, blocksToInsert, "user-id-1") defer DeleteBlocks(t, store, blocksToInsert, "test") blocks, err = store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, initialCount+3) t.Run("existing id", func(t *testing.T) { // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err := store.DeleteBlock("block1", userID) require.NoError(t, err) block, err := store.GetBlock("block1") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, block) time.Sleep(1 * time.Millisecond) err = store.UndeleteBlock("block1", userID) require.NoError(t, err) block, err = store.GetBlock("block1") require.NoError(t, err) require.NotNil(t, block) }) t.Run("existing id multiple times", func(t *testing.T) { // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err := store.DeleteBlock("block1", userID) require.NoError(t, err) block, err := store.GetBlock("block1") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, block) // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err = store.UndeleteBlock("block1", userID) require.NoError(t, err) block, err = store.GetBlock("block1") require.NoError(t, err) require.NotNil(t, block) // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err = store.UndeleteBlock("block1", userID) require.NoError(t, err) block, err = store.GetBlock("block1") require.NoError(t, err) require.NotNil(t, block) }) t.Run("from not existing id", func(t *testing.T) { // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err := store.UndeleteBlock("not-exists", userID) require.NoError(t, err) block, err := store.GetBlock("not-exists") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, block) }) } func testGetBlocks(t *testing.T, store store.Store) { boardID := testBoardID blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) blocksToInsert := []*model.Block{ { ID: "block1", BoardID: boardID, ParentID: "", ModifiedBy: testUserID, Type: "test", }, { ID: "block2", BoardID: boardID, ParentID: "block1", ModifiedBy: testUserID, Type: "test", }, { ID: "block3", BoardID: boardID, ParentID: "block1", ModifiedBy: testUserID, Type: "test", }, { ID: "block4", BoardID: boardID, ParentID: "block1", ModifiedBy: testUserID, Type: "test2", }, { ID: "block5", BoardID: boardID, ParentID: "block2", ModifiedBy: testUserID, Type: "test", }, } InsertBlocks(t, store, blocksToInsert, "user-id-1") defer DeleteBlocks(t, store, blocksToInsert, "test") t.Run("not existing parent", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksWithParentAndType(boardID, "not-exists", "test") require.NoError(t, err) require.Empty(t, blocks) }) t.Run("not existing type", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksWithParentAndType(boardID, "block1", "not-existing") require.NoError(t, err) require.Empty(t, blocks) }) t.Run("valid parent and type", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksWithParentAndType(boardID, "block1", "test") require.NoError(t, err) require.Len(t, blocks, 2) }) t.Run("not existing parent", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksWithParent(boardID, "not-exists") require.NoError(t, err) require.Empty(t, blocks) }) t.Run("valid parent", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksWithParent(boardID, "block1") require.NoError(t, err) require.Len(t, blocks, 3) }) t.Run("not existing type", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksWithType(boardID, "not-exists") require.NoError(t, err) require.Empty(t, blocks) }) t.Run("valid type", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksWithType(boardID, "test") require.NoError(t, err) require.Len(t, blocks, 4) }) t.Run("not existing board", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksForBoard("not-exists") require.NoError(t, err) require.Empty(t, blocks) }) t.Run("all blocks of the a board", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, 5) }) t.Run("several blocks by ids", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksByIDs([]string{"block2", "block4"}) require.NoError(t, err) require.Len(t, blocks, 2) }) t.Run("blocks by ids where some are not found", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksByIDs([]string{"block2", "blockNonexistent"}) var naf *model.ErrNotAllFound require.ErrorAs(t, err, &naf) require.True(t, model.IsErrNotFound(err)) require.Len(t, blocks, 1) }) t.Run("blocks by ids where none are found", func(t *testing.T) { time.Sleep(1 * time.Millisecond) blocks, err = store.GetBlocksByIDs([]string{"blockNonexistent1", "blockNonexistent2"}) var naf *model.ErrNotAllFound require.ErrorAs(t, err, &naf) require.True(t, model.IsErrNotFound(err)) require.Empty(t, blocks) }) } func testGetBlock(t *testing.T, store store.Store) { t.Run("get a block", func(t *testing.T) { block := &model.Block{ ID: "block-id-10", BoardID: "board-id-1", ModifiedBy: "user-id-1", } err := store.InsertBlock(block, "user-id-1") require.NoError(t, err) fetchedBlock, err := store.GetBlock("block-id-10") require.NoError(t, err) require.NotNil(t, fetchedBlock) require.Equal(t, "block-id-10", fetchedBlock.ID) require.Equal(t, "board-id-1", fetchedBlock.BoardID) require.Equal(t, "user-id-1", fetchedBlock.CreatedBy) require.Equal(t, "user-id-1", fetchedBlock.ModifiedBy) assert.WithinDurationf(t, time.Now(), utils.GetTimeForMillis(fetchedBlock.CreateAt), 1*time.Second, "create time should be current time") assert.WithinDurationf(t, time.Now(), utils.GetTimeForMillis(fetchedBlock.UpdateAt), 1*time.Second, "update time should be current time") }) t.Run("get a non-existing block", func(t *testing.T) { fetchedBlock, err := store.GetBlock("non-existing-id") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, fetchedBlock) }) } func testDuplicateBlock(t *testing.T, store store.Store) { blocksToInsert := subtreeSampleBlocks blocksToInsert = append(blocksToInsert, &model.Block{ ID: "grandchild1a", BoardID: testBoardID, ParentID: "child1", ModifiedBy: testUserID, Type: model.TypeComment, }, &model.Block{ ID: "grandchild2a", BoardID: testBoardID, ParentID: "child2", ModifiedBy: testUserID, Type: model.TypeComment, }, ) InsertBlocks(t, store, blocksToInsert, "user-id-1") time.Sleep(1 * time.Millisecond) defer DeleteBlocks(t, store, subtreeSampleBlocks, "test") t.Run("duplicate existing block as no template", func(t *testing.T) { blocks, err := store.DuplicateBlock(testBoardID, "child1", testUserID, false) require.NoError(t, err) require.Len(t, blocks, 2) require.Equal(t, false, blocks[0].Fields["isTemplate"]) }) t.Run("duplicate existing block as template", func(t *testing.T) { blocks, err := store.DuplicateBlock(testBoardID, "child1", testUserID, true) require.NoError(t, err) require.Len(t, blocks, 2) require.Equal(t, true, blocks[0].Fields["isTemplate"]) }) t.Run("duplicate not existing block", func(t *testing.T) { blocks, err := store.DuplicateBlock(testBoardID, "not-existing-id", testUserID, false) require.Error(t, err) require.Nil(t, blocks) }) t.Run("duplicate not existing board", func(t *testing.T) { blocks, err := store.DuplicateBlock("not-existing-board", "not-existing-id", testUserID, false) require.Error(t, err) require.Nil(t, blocks) }) t.Run("not matching board/block", func(t *testing.T) { blocks, err := store.DuplicateBlock("other-id", "child1", testUserID, false) require.Error(t, err) require.Nil(t, blocks) }) } func testGetBlockMetadata(t *testing.T, store store.Store) { boardID := testBoardID blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) blocksToInsert := []*model.Block{ { ID: "block1", BoardID: boardID, ParentID: "", ModifiedBy: testUserID, Type: "test", }, { ID: "block2", BoardID: boardID, ParentID: "block1", ModifiedBy: testUserID, Type: "test", }, { ID: "block3", BoardID: boardID, ParentID: "block1", ModifiedBy: testUserID, Type: "test", }, { ID: "block4", BoardID: boardID, ParentID: "block1", ModifiedBy: testUserID, Type: "test2", }, { ID: "block5", BoardID: boardID, ParentID: "block2", ModifiedBy: testUserID, Type: "test", }, } for _, v := range blocksToInsert { time.Sleep(20 * time.Millisecond) subBlocks := []*model.Block{v} InsertBlocks(t, store, subBlocks, testUserID) } defer DeleteBlocks(t, store, blocksToInsert, "test") t.Run("get full block history", func(t *testing.T) { opts := model.QueryBlockHistoryOptions{ Descending: false, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) require.Len(t, blocks, 5) expectedBlock := blocksToInsert[0] block := blocks[0] require.Equal(t, expectedBlock.ID, block.ID) }) t.Run("get full block history descending", func(t *testing.T) { opts := model.QueryBlockHistoryOptions{ Descending: true, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) require.Len(t, blocks, 5) expectedBlock := blocksToInsert[len(blocksToInsert)-1] block := blocks[0] require.Equal(t, expectedBlock.ID, block.ID) }) t.Run("get limited block history", func(t *testing.T) { opts := model.QueryBlockHistoryOptions{ Limit: 3, Descending: false, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) require.Len(t, blocks, 3) }) t.Run("get first block history", func(t *testing.T) { opts := model.QueryBlockHistoryOptions{ Limit: 1, Descending: false, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) require.Len(t, blocks, 1) expectedBlock := blocksToInsert[0] block := blocks[0] require.Equal(t, expectedBlock.ID, block.ID) }) t.Run("get last block history", func(t *testing.T) { opts := model.QueryBlockHistoryOptions{ Limit: 1, Descending: true, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) require.Len(t, blocks, 1) expectedBlock := blocksToInsert[len(blocksToInsert)-1] block := blocks[0] require.Equal(t, expectedBlock.ID, block.ID) }) t.Run("get block history after updateAt", func(t *testing.T) { rBlock, err2 := store.GetBlock("block3") require.NoError(t, err2) require.NotZero(t, rBlock.UpdateAt) opts := model.QueryBlockHistoryOptions{ AfterUpdateAt: rBlock.UpdateAt, Descending: false, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) require.Len(t, blocks, 2) expectedBlock := blocksToInsert[3] block := blocks[0] require.Equal(t, expectedBlock.ID, block.ID) }) t.Run("get block history before updateAt", func(t *testing.T) { rBlock, err2 := store.GetBlock("block3") require.NoError(t, err2) require.NotZero(t, rBlock.UpdateAt) opts := model.QueryBlockHistoryOptions{ BeforeUpdateAt: rBlock.UpdateAt, Descending: true, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) require.Len(t, blocks, 2) expectedBlock := blocksToInsert[1] block := blocks[0] require.Equal(t, expectedBlock.ID, block.ID) }) t.Run("get full block history after delete", func(t *testing.T) { time.Sleep(20 * time.Millisecond) // this will delete `block1` and any other blocks with `block1` as parent. err = store.DeleteBlock(blocksToInsert[0].ID, testUserID) require.NoError(t, err) opts := model.QueryBlockHistoryOptions{ Descending: true, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) // all 5 blocks get a history record for insert, then `block1` gets a record for delete, // and all 3 `block1` children get a record for delete. Thus total is 9. require.Len(t, blocks, 9) }) t.Run("get full block history after undelete", func(t *testing.T) { time.Sleep(20 * time.Millisecond) // this will undelete `block1` and its children err = store.UndeleteBlock(blocksToInsert[0].ID, testUserID) require.NoError(t, err) opts := model.QueryBlockHistoryOptions{ Descending: true, } blocks, err = store.GetBlockHistoryDescendants(boardID, opts) require.NoError(t, err) // previous test put 9 records in history table. In this test 1 record was added for undeleting // `block1` and another 3 for undeleting the children for a total of 13. require.Len(t, blocks, 13) }) t.Run("get block history of a board with no history", func(t *testing.T) { opts := model.QueryBlockHistoryOptions{} blocks, err = store.GetBlockHistoryDescendants("nonexistent-board-id", opts) require.NoError(t, err) require.Empty(t, blocks) }) } func testUndeleteBlockChildren(t *testing.T, store store.Store) { boards := createTestBoards(t, store, testTeamID, testUserID, 2) boardDelete := boards[0] boardKeep := boards[1] userID := testUserID // create some blocks to be deleted cardsDelete := createTestCards(t, store, userID, boardDelete.ID, 3) blocksDelete := createTestBlocksForCard(t, store, cardsDelete[0].ID, 5) require.Len(t, blocksDelete, 5) // create some blocks to keep cardsKeep := createTestCards(t, store, userID, boardKeep.ID, 3) blocksKeep := createTestBlocksForCard(t, store, cardsKeep[0].ID, 4) require.Len(t, blocksKeep, 4) t.Run("undelete block children for card", func(t *testing.T) { cardDelete := cardsDelete[0] cardKeep := cardsKeep[0] // delete a card err := store.DeleteBlock(cardDelete.ID, testUserID) require.NoError(t, err) // ensure the card was deleted block, err := store.GetBlock(cardDelete.ID) require.Error(t, err) require.Nil(t, block) // ensure the card children were deleted blocks, err := store.GetBlocksWithParentAndType(cardDelete.BoardID, cardDelete.ID, model.TypeText) require.NoError(t, err) assert.Empty(t, blocks) // ensure the other card children remain. blocks, err = store.GetBlocksWithParentAndType(cardKeep.BoardID, cardKeep.ID, model.TypeText) require.NoError(t, err) assert.Len(t, blocks, len(blocksKeep)) // undelete the card err = store.UndeleteBlock(cardDelete.ID, testUserID) require.NoError(t, err) // ensure the card was restored block, err = store.GetBlock(cardDelete.ID) require.NoError(t, err) require.NotNil(t, block) // ensure the card children were restored blocks, err = store.GetBlocksWithParentAndType(cardDelete.BoardID, cardDelete.ID, model.TypeText) require.NoError(t, err) assert.Len(t, blocks, len(blocksDelete)) }) t.Run("undelete block children for board", func(t *testing.T) { // delete the board err := store.DeleteBoard(boardDelete.ID, testUserID) require.NoError(t, err) // ensure the board was deleted board, err := store.GetBoard(boardDelete.ID) require.Error(t, err) require.Nil(t, board) // ensure all cards and blocks for the board were deleted blocks, err := store.GetBlocksForBoard(boardDelete.ID) require.NoError(t, err) assert.Empty(t, blocks) // ensure the other board's cards and blocks remain. blocks, err = store.GetBlocksForBoard(boardKeep.ID) require.NoError(t, err) assert.Len(t, blocks, len(blocksKeep)+len(cardsKeep)) // undelete the board err = store.UndeleteBoard(boardDelete.ID, testUserID) require.NoError(t, err) // ensure the board was restored board, err = store.GetBoard(boardDelete.ID) require.NoError(t, err) require.NotNil(t, board) // ensure the board's cards and blocks were restored. blocks, err = store.GetBlocksForBoard(boardDelete.ID) require.NoError(t, err) assert.Len(t, blocks, len(blocksDelete)+len(cardsDelete)) }) } func testGetBlockHistoryNewestChildren(t *testing.T, store store.Store) { boards := createTestBoards(t, store, testTeamID, testUserID, 2) board := boards[0] const cardCount = 10 const patchCount = 5 // create a card and some content blocks cards := createTestCards(t, store, testUserID, board.ID, 1) card := cards[0] content := createTestBlocksForCard(t, store, card.ID, cardCount) // patch the content blocks to create some history records for i := 1; i <= patchCount; i++ { for _, block := range content { title := strconv.FormatInt(int64(i), 10) patch := &model.BlockPatch{ Title: &title, } err := store.PatchBlock(block.ID, patch, testUserID) require.NoError(t, err, "error patching content blocks") } } // delete some of the content blocks err := store.DeleteBlock(content[0].ID, testUserID) require.NoError(t, err, "error deleting content block") err = store.DeleteBlock(content[3].ID, testUserID) require.NoError(t, err, "error deleting content block") err = store.DeleteBlock(content[7].ID, testUserID) require.NoError(t, err, "error deleting content block") t.Run("invalid card", func(t *testing.T) { opts := model.QueryBlockHistoryChildOptions{} blocks, hasMore, err := store.GetBlockHistoryNewestChildren(utils.NewID(utils.IDTypeCard), opts) require.NoError(t, err) require.False(t, hasMore) require.Empty(t, blocks) }) t.Run("valid card with no children", func(t *testing.T) { opts := model.QueryBlockHistoryChildOptions{} emptyCard := createTestCards(t, store, testUserID, board.ID, 1)[0] blocks, hasMore, err := store.GetBlockHistoryNewestChildren(emptyCard.ID, opts) require.NoError(t, err) require.False(t, hasMore) require.Empty(t, blocks) }) t.Run("valid card with children", func(t *testing.T) { opts := model.QueryBlockHistoryChildOptions{} blocks, hasMore, err := store.GetBlockHistoryNewestChildren(card.ID, opts) require.NoError(t, err) require.False(t, hasMore) require.Len(t, blocks, cardCount) require.ElementsMatch(t, extractIDs(t, blocks), extractIDs(t, content)) expected := strconv.FormatInt(patchCount, 10) for _, b := range blocks { require.Equal(t, expected, b.Title) } }) t.Run("pagination", func(t *testing.T) { opts := model.QueryBlockHistoryChildOptions{ PerPage: 3, } collected := make([]*model.Block, 0) reps := 0 for { reps++ blocks, hasMore, err := store.GetBlockHistoryNewestChildren(card.ID, opts) require.NoError(t, err) collected = append(collected, blocks...) if !hasMore { break } opts.Page++ } assert.Len(t, collected, cardCount) assert.Equal(t, math.Floor(float64(cardCount/opts.PerPage)+1), float64(reps)) expected := strconv.FormatInt(patchCount, 10) for _, b := range collected { require.Equal(t, expected, b.Title) } }) } ================================================ FILE: server/services/store/storetests/boards.go ================================================ package storetests import ( "testing" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func StoreTestBoardStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("GetBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBoard(t, store) }) t.Run("GetBoardsForUserAndTeam", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBoardsForUserAndTeam(t, store) }) t.Run("GetBoardsInTeamByIds", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBoardsInTeamByIds(t, store) }) t.Run("InsertBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testInsertBoard(t, store) }) t.Run("PatchBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testPatchBoard(t, store) }) t.Run("DeleteBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDeleteBoard(t, store) }) t.Run("UndeleteBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUndeleteBoard(t, store) }) t.Run("InsertBoardWithAdmin", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testInsertBoardWithAdmin(t, store) }) t.Run("SaveMember", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testSaveMember(t, store) }) t.Run("GetMemberForBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetMemberForBoard(t, store) }) t.Run("GetMembersForBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetMembersForBoard(t, store) }) t.Run("GetMembersForUser", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetMembersForUser(t, store) }) t.Run("DeleteMember", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDeleteMember(t, store) }) t.Run("SearchBoardsForUser", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testSearchBoardsForUser(t, store) }) t.Run("SearchBoardsForUserInTeam", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testSearchBoardsForUserInTeam(t, store) }) t.Run("GetBoardHistory", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBoardHistory(t, store) }) t.Run("GetBoardCount", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBoardCount(t, store) }) } func testGetBoard(t *testing.T, store store.Store) { userID := testUserID t.Run("existing board", func(t *testing.T) { board := &model.Board{ ID: "id-1", TeamID: testTeamID, Type: model.BoardTypeOpen, } _, err := store.InsertBoard(board, userID) require.NoError(t, err) rBoard, err := store.GetBoard(board.ID) require.NoError(t, err) require.Equal(t, board.ID, rBoard.ID) require.Equal(t, board.TeamID, rBoard.TeamID) require.Equal(t, userID, rBoard.CreatedBy) require.Equal(t, userID, rBoard.ModifiedBy) require.Equal(t, board.Type, rBoard.Type) require.NotZero(t, rBoard.CreateAt) require.NotZero(t, rBoard.UpdateAt) }) t.Run("nonexisting board", func(t *testing.T) { rBoard, err := store.GetBoard("nonexistent-id") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") require.Nil(t, rBoard) }) } func testGetBoardsForUserAndTeam(t *testing.T, store store.Store) { userID := "user-id-1" t.Run("should return empty list if no results are found", func(t *testing.T) { boards, err := store.GetBoardsForUserAndTeam(testUserID, testTeamID, true) require.NoError(t, err) require.Empty(t, boards) }) t.Run("should return only the boards of the team that the user is a member of", func(t *testing.T) { teamID1 := "team-id-1" teamID2 := "team-id-2" // team 1 boards board1 := &model.Board{ ID: "board-id-1", TeamID: teamID1, Type: model.BoardTypeOpen, } rBoard1, _, err := store.InsertBoardWithAdmin(board1, userID) require.NoError(t, err) board2 := &model.Board{ ID: "board-id-2", TeamID: teamID1, Type: model.BoardTypePrivate, } rBoard2, _, err := store.InsertBoardWithAdmin(board2, userID) require.NoError(t, err) board3 := &model.Board{ ID: "board-id-3", TeamID: teamID1, Type: model.BoardTypeOpen, } rBoard3, err := store.InsertBoard(board3, "other-user") require.NoError(t, err) board4 := &model.Board{ ID: "board-id-4", TeamID: teamID1, Type: model.BoardTypePrivate, } _, err = store.InsertBoard(board4, "other-user") require.NoError(t, err) // team 2 boards board5 := &model.Board{ ID: "board-id-5", TeamID: teamID2, Type: model.BoardTypeOpen, } _, _, err = store.InsertBoardWithAdmin(board5, userID) require.NoError(t, err) board6 := &model.Board{ ID: "board-id-6", TeamID: teamID1, Type: model.BoardTypePrivate, } _, err = store.InsertBoard(board6, "other-user") require.NoError(t, err) t.Run("should only find the two boards that the user is a member of for team 1 plus the one open board", func(t *testing.T) { boards, err := store.GetBoardsForUserAndTeam(userID, teamID1, true) require.NoError(t, err) require.ElementsMatch(t, []*model.Board{ rBoard1, rBoard2, rBoard3, }, boards) }) t.Run("should only find the two boards that the user is a member of for team 1", func(t *testing.T) { boards, err := store.GetBoardsForUserAndTeam(userID, teamID1, false) require.NoError(t, err) require.ElementsMatch(t, []*model.Board{ rBoard1, rBoard2, }, boards) }) t.Run("should only find the board that the user is a member of for team 2", func(t *testing.T) { boards, err := store.GetBoardsForUserAndTeam(userID, teamID2, true) require.NoError(t, err) require.Len(t, boards, 1) require.Equal(t, board5.ID, boards[0].ID) }) }) } func testGetBoardsInTeamByIds(t *testing.T, store store.Store) { t.Run("should return err not all found if one or more of the ids are not found", func(t *testing.T) { for _, boardID := range []string{"board-id-1", "board-id-2"} { board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, } rBoard, _, err := store.InsertBoardWithAdmin(board, testUserID) require.NoError(t, err) require.NotNil(t, rBoard) } testCases := []struct { Name string BoardIDs []string ExpectedError bool ExpectedLen int }{ { Name: "if none of the IDs are found", BoardIDs: []string{"nonexistent-1", "nonexistent-2"}, ExpectedError: true, ExpectedLen: 0, }, { Name: "if not all of the IDs are found", BoardIDs: []string{"nonexistent-1", "board-id-1"}, ExpectedError: true, ExpectedLen: 1, }, { Name: "if all of the IDs are found", BoardIDs: []string{"board-id-1", "board-id-2"}, ExpectedError: false, ExpectedLen: 2, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { boards, err := store.GetBoardsInTeamByIds(tc.BoardIDs, testTeamID) if tc.ExpectedError { var naf *model.ErrNotAllFound require.ErrorAs(t, err, &naf) require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") } else { require.NoError(t, err) } require.Len(t, boards, tc.ExpectedLen) }) } }) } func testInsertBoard(t *testing.T, store store.Store) { userID := testUserID t.Run("valid public board", func(t *testing.T) { board := &model.Board{ ID: "id-test-public", TeamID: testTeamID, Type: model.BoardTypeOpen, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.Equal(t, board.ID, newBoard.ID) require.Equal(t, newBoard.Type, model.BoardTypeOpen) require.NotZero(t, newBoard.CreateAt) require.NotZero(t, newBoard.UpdateAt) require.Zero(t, newBoard.DeleteAt) require.Equal(t, userID, newBoard.CreatedBy) require.Equal(t, newBoard.CreatedBy, newBoard.ModifiedBy) }) t.Run("valid private board", func(t *testing.T) { board := &model.Board{ ID: "id-test-private", TeamID: testTeamID, Type: model.BoardTypePrivate, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.Equal(t, board.ID, newBoard.ID) require.Equal(t, newBoard.Type, model.BoardTypePrivate) require.NotZero(t, newBoard.CreateAt) require.NotZero(t, newBoard.UpdateAt) require.Zero(t, newBoard.DeleteAt) require.Equal(t, userID, newBoard.CreatedBy) require.Equal(t, newBoard.CreatedBy, newBoard.ModifiedBy) }) t.Run("invalid properties field board", func(t *testing.T) { board := &model.Board{ ID: "id-test-props", TeamID: testTeamID, Properties: map[string]interface{}{"no-serializable-value": t.Run}, } _, err := store.InsertBoard(board, userID) require.Error(t, err) rBoard, err := store.GetBoard(board.ID) require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") require.Nil(t, rBoard) }) t.Run("update board", func(t *testing.T) { board := &model.Board{ ID: "id-test-public", TeamID: testTeamID, Title: "New title", } // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) newBoard, err := store.InsertBoard(board, "user2") require.NoError(t, err) require.Equal(t, "New title", newBoard.Title) require.Equal(t, "user2", newBoard.ModifiedBy) }) t.Run("test update board type", func(t *testing.T) { board := &model.Board{ ID: "id-test-type-board", Title: "Public board", Type: model.BoardTypeOpen, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.Equal(t, model.BoardTypeOpen, newBoard.Type) boardUpdate := &model.Board{ ID: "id-test-type-board", Type: model.BoardTypePrivate, } // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) modifiedBoard, err := store.InsertBoard(boardUpdate, userID) require.NoError(t, err) require.Equal(t, model.BoardTypePrivate, modifiedBoard.Type) }) } func testPatchBoard(t *testing.T, store store.Store) { userID := testUserID t.Run("should return error if the board doesn't exist", func(t *testing.T) { newTitle := "A new title" patch := &model.BoardPatch{Title: &newTitle} board, err := store.PatchBoard("nonexistent-board-id", patch, userID) require.Error(t, err) require.Nil(t, board) }) t.Run("should correctly apply a simple patch", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) userID2 := "user-id-2" board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, Title: "A simple title", Description: "A simple description", } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) require.Equal(t, userID, newBoard.CreatedBy) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) newTitle := "A new title" newDescription := "A new description" patch := &model.BoardPatch{Title: &newTitle, Description: &newDescription} patchedBoard, err := store.PatchBoard(boardID, patch, userID2) require.NoError(t, err) require.Equal(t, newTitle, patchedBoard.Title) require.Equal(t, newDescription, patchedBoard.Description) require.Equal(t, userID, patchedBoard.CreatedBy) require.Equal(t, userID2, patchedBoard.ModifiedBy) }) t.Run("should correctly update the board properties", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, Properties: map[string]interface{}{ "one": "1", "two": "2", }, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) require.Equal(t, "1", newBoard.Properties["one"].(string)) require.Equal(t, "2", newBoard.Properties["two"].(string)) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) patch := &model.BoardPatch{ UpdatedProperties: map[string]interface{}{"three": "3"}, DeletedProperties: []string{"one"}, } patchedBoard, err := store.PatchBoard(boardID, patch, userID) require.NoError(t, err) require.NotContains(t, patchedBoard.Properties, "one") require.Equal(t, "2", patchedBoard.Properties["two"].(string)) require.Equal(t, "3", patchedBoard.Properties["three"].(string)) }) t.Run("should correctly modify the board's type", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) require.Equal(t, newBoard.Type, model.BoardTypeOpen) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) newType := model.BoardTypePrivate patch := &model.BoardPatch{Type: &newType} patchedBoard, err := store.PatchBoard(boardID, patch, userID) require.NoError(t, err) require.Equal(t, model.BoardTypePrivate, patchedBoard.Type) }) t.Run("a patch that doesn't include any of the properties should not modify them", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) properties := map[string]interface{}{"prop1": "val1"} cardProperties := []map[string]interface{}{{"prop2": "val2"}} board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, Properties: properties, CardProperties: cardProperties, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) require.Equal(t, newBoard.Type, model.BoardTypeOpen) require.Equal(t, properties, newBoard.Properties) require.Equal(t, cardProperties, newBoard.CardProperties) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) newType := model.BoardTypePrivate patch := &model.BoardPatch{Type: &newType} patchedBoard, err := store.PatchBoard(boardID, patch, userID) require.NoError(t, err) require.Equal(t, model.BoardTypePrivate, patchedBoard.Type) require.Equal(t, properties, patchedBoard.Properties) require.Equal(t, cardProperties, patchedBoard.CardProperties) }) t.Run("a patch that removes a card property and updates another should work correctly", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) prop1 := map[string]interface{}{"id": "prop1", "value": "val1"} prop2 := map[string]interface{}{"id": "prop2", "value": "val2"} prop3 := map[string]interface{}{"id": "prop3", "value": "val3"} cardProperties := []map[string]interface{}{prop1, prop2, prop3} board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, CardProperties: cardProperties, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) require.Equal(t, newBoard.Type, model.BoardTypeOpen) require.Equal(t, cardProperties, newBoard.CardProperties) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) newProp1 := map[string]interface{}{"id": "prop1", "value": "newval1"} expectedCardProperties := []map[string]interface{}{newProp1, prop3} patch := &model.BoardPatch{ UpdatedCardProperties: []map[string]interface{}{newProp1}, DeletedCardProperties: []string{"prop2"}, } patchedBoard, err := store.PatchBoard(boardID, patch, userID) require.NoError(t, err) require.ElementsMatch(t, expectedCardProperties, patchedBoard.CardProperties) }) } func testDeleteBoard(t *testing.T, store store.Store) { userID := testUserID t.Run("should return an error if the board doesn't exist", func(t *testing.T) { require.Error(t, store.DeleteBoard("nonexistent-board-id", userID)) }) t.Run("should correctly delete the board", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) rBoard, err := store.GetBoard(boardID) require.NoError(t, err) require.NotNil(t, rBoard) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) require.NoError(t, store.DeleteBoard(boardID, userID)) r2Board, err := store.GetBoard(boardID) require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") require.Nil(t, r2Board) }) } func testInsertBoardWithAdmin(t *testing.T, store store.Store) { userID := testUserID t.Run("should correctly create a board and the admin membership with the creator", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, } newBoard, newMember, err := store.InsertBoardWithAdmin(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) require.Equal(t, userID, newBoard.CreatedBy) require.Equal(t, userID, newBoard.ModifiedBy) require.NotNil(t, newMember) require.Equal(t, userID, newMember.UserID) require.Equal(t, boardID, newMember.BoardID) require.True(t, newMember.SchemeAdmin) require.True(t, newMember.SchemeEditor) }) } func testSaveMember(t *testing.T, store store.Store) { userID := testUserID boardID := testBoardID t.Run("should correctly create a member", func(t *testing.T) { bm := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeAdmin: true, } memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) initialMemberHistory := len(memberHistory) nbm, err := store.SaveMember(bm) require.NoError(t, err) require.Equal(t, userID, nbm.UserID) require.Equal(t, boardID, nbm.BoardID) require.True(t, nbm.SchemeAdmin) memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) require.Len(t, memberHistory, initialMemberHistory+1) }) t.Run("should correctly update a member", func(t *testing.T) { bm := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeEditor: true, SchemeViewer: true, } memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) initialMemberHistory := len(memberHistory) nbm, err := store.SaveMember(bm) require.NoError(t, err) require.Equal(t, userID, nbm.UserID) require.Equal(t, boardID, nbm.BoardID) require.False(t, nbm.SchemeAdmin) require.True(t, nbm.SchemeEditor) require.True(t, nbm.SchemeViewer) memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) require.Len(t, memberHistory, initialMemberHistory) }) t.Run("should return empty list if no results are found", func(t *testing.T) { memberHistory, err := store.GetBoardMemberHistory(boardID, "nonexistent-user", 0) require.NoError(t, err) require.Empty(t, memberHistory) }) } func testGetMemberForBoard(t *testing.T, store store.Store) { userID := testUserID boardID := testBoardID t.Run("should return an error not found for nonexisting membership", func(t *testing.T) { bm, err := store.GetMemberForBoard(boardID, userID) var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") require.Nil(t, bm) }) t.Run("should return the membership if exists", func(t *testing.T) { bm := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeAdmin: true, } nbm, err := store.SaveMember(bm) require.NoError(t, err) require.NotNil(t, nbm) rbm, err := store.GetMemberForBoard(boardID, userID) require.NoError(t, err) require.NotNil(t, rbm) require.Equal(t, userID, rbm.UserID) require.Equal(t, boardID, rbm.BoardID) require.True(t, rbm.SchemeAdmin) }) } func testGetMembersForBoard(t *testing.T, store store.Store) { t.Run("should return empty list if there are no members on a board", func(t *testing.T) { members, err := store.GetMembersForBoard(testBoardID) require.NoError(t, err) require.Empty(t, members) }) t.Run("should return the members of the board", func(t *testing.T) { boardID1 := "board-id-1" boardID2 := "board-id-2" userID1 := "user-id-11" userID2 := "user-id-12" userID3 := "user-id-13" bm1 := &model.BoardMember{BoardID: boardID1, UserID: userID1, SchemeAdmin: true} _, err1 := store.SaveMember(bm1) require.NoError(t, err1) bm2 := &model.BoardMember{BoardID: boardID1, UserID: userID2, SchemeEditor: true} _, err2 := store.SaveMember(bm2) require.NoError(t, err2) bm3 := &model.BoardMember{BoardID: boardID2, UserID: userID3, SchemeAdmin: true} _, err3 := store.SaveMember(bm3) require.NoError(t, err3) getMemberIDs := func(members []*model.BoardMember) []string { ids := make([]string, len(members)) for i, member := range members { ids[i] = member.UserID } return ids } board1Members, err := store.GetMembersForBoard(boardID1) require.NoError(t, err) require.Len(t, board1Members, 2) require.ElementsMatch(t, []string{userID1, userID2}, getMemberIDs(board1Members)) board2Members, err := store.GetMembersForBoard(boardID2) require.NoError(t, err) require.Len(t, board2Members, 1) require.ElementsMatch(t, []string{userID3}, getMemberIDs(board2Members)) }) } func testGetMembersForUser(t *testing.T, store store.Store) { t.Run("should return empty list if there are no memberships for a user", func(t *testing.T) { members, err := store.GetMembersForUser(testUserID) require.NoError(t, err) require.Empty(t, members) }) } func testDeleteMember(t *testing.T, store store.Store) { userID := testUserID boardID := testBoardID t.Run("should return nil if deleting a nonexistent member", func(t *testing.T) { memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) initialMemberHistory := len(memberHistory) require.NoError(t, store.DeleteMember(boardID, userID)) memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) require.Len(t, memberHistory, initialMemberHistory) }) t.Run("should correctly delete a member", func(t *testing.T) { bm := &model.BoardMember{ UserID: userID, BoardID: boardID, SchemeAdmin: true, } nbm, err := store.SaveMember(bm) require.NoError(t, err) require.NotNil(t, nbm) memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) initialMemberHistory := len(memberHistory) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(1 * time.Millisecond) require.NoError(t, store.DeleteMember(boardID, userID)) rbm, err := store.GetMemberForBoard(boardID, userID) require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") require.Nil(t, rbm) memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0) require.NoError(t, err) require.Len(t, memberHistory, initialMemberHistory+1) }) } func testSearchBoardsForUser(t *testing.T, store store.Store) { teamID1 := "team-id-1" teamID2 := "team-id-2" userID := "user-id-1" t.Run("should return empty if user is not a member of any board and there are no public boards on the team", func(t *testing.T) { boards, err := store.SearchBoardsForUser("", model.BoardSearchFieldTitle, userID, true) require.NoError(t, err) require.Empty(t, boards) }) board1 := &model.Board{ ID: "board-id-1", TeamID: teamID1, Type: model.BoardTypeOpen, Title: "Public Board with admin", Properties: map[string]any{"foo": "bar1"}, } _, _, err := store.InsertBoardWithAdmin(board1, userID) require.NoError(t, err) board2 := &model.Board{ ID: "board-id-2", TeamID: teamID1, Type: model.BoardTypeOpen, Title: "Public Board", Properties: map[string]any{"foo": "bar2"}, } _, err = store.InsertBoard(board2, userID) require.NoError(t, err) board3 := &model.Board{ ID: "board-id-3", TeamID: teamID1, Type: model.BoardTypePrivate, Title: "Private Board with admin", } _, _, err = store.InsertBoardWithAdmin(board3, userID) require.NoError(t, err) board4 := &model.Board{ ID: "board-id-4", TeamID: teamID1, Type: model.BoardTypePrivate, Title: "Private Board", } _, err = store.InsertBoard(board4, userID) require.NoError(t, err) board5 := &model.Board{ ID: "board-id-5", TeamID: teamID2, Type: model.BoardTypeOpen, Title: "Public Board with admin in team 2", } _, _, err = store.InsertBoardWithAdmin(board5, userID) require.NoError(t, err) testCases := []struct { Name string TeamID string UserID string Term string SearchField model.BoardSearchField IncludePublic bool ExpectedBoardIDs []string }{ { Name: "should find all private boards that the user is a member of and public boards with an empty term", TeamID: teamID1, UserID: userID, Term: "", SearchField: model.BoardSearchFieldTitle, IncludePublic: true, ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID}, }, { Name: "should find all with term board", TeamID: teamID1, UserID: userID, Term: "board", SearchField: model.BoardSearchFieldTitle, IncludePublic: true, ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID}, }, { Name: "should find all with term board where the user is member of", TeamID: teamID1, UserID: userID, Term: "board", SearchField: model.BoardSearchFieldTitle, IncludePublic: false, ExpectedBoardIDs: []string{board1.ID, board3.ID, board5.ID}, }, { Name: "should find only public as per the term, wether user is a member or not", TeamID: teamID1, UserID: userID, Term: "public", SearchField: model.BoardSearchFieldTitle, IncludePublic: true, ExpectedBoardIDs: []string{board1.ID, board2.ID, board5.ID}, }, { Name: "should find only private as per the term, wether user is a member or not", TeamID: teamID1, UserID: userID, Term: "priv", SearchField: model.BoardSearchFieldTitle, IncludePublic: true, ExpectedBoardIDs: []string{board3.ID}, }, { Name: "should find no board in team 2 with a non matching term", TeamID: teamID2, UserID: userID, Term: "non-matching-term", SearchField: model.BoardSearchFieldTitle, IncludePublic: true, ExpectedBoardIDs: []string{}, }, { Name: "should find all boards with a named property", TeamID: teamID1, UserID: userID, Term: "foo", SearchField: model.BoardSearchFieldPropertyName, IncludePublic: true, ExpectedBoardIDs: []string{board1.ID, board2.ID}, }, { Name: "should find no boards with a non-existing named property", TeamID: teamID1, UserID: userID, Term: "bogus", SearchField: model.BoardSearchFieldPropertyName, IncludePublic: true, ExpectedBoardIDs: []string{}, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { boards, err := store.SearchBoardsForUser(tc.Term, tc.SearchField, tc.UserID, tc.IncludePublic) require.NoError(t, err) boardIDs := []string{} for _, board := range boards { boardIDs = append(boardIDs, board.ID) } require.ElementsMatch(t, tc.ExpectedBoardIDs, boardIDs) }) } } func testSearchBoardsForUserInTeam(t *testing.T, store store.Store) { t.Run("should return empty list if there are no resutls", func(t *testing.T) { boards, err := store.SearchBoardsForUserInTeam("nonexistent-team-id", "", testUserID) require.NoError(t, err) require.Empty(t, boards) }) } func testUndeleteBoard(t *testing.T, store store.Store) { userID := testUserID t.Run("existing id", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, Title: "Dunder Mifflin Scranton", MinimumRole: model.BoardRoleCommenter, Description: "Bears, beets, Battlestar Gallectica", Icon: "🐻", ShowDescription: true, IsTemplate: false, Properties: map[string]interface{}{ "prop_1": "value_1", }, CardProperties: []map[string]interface{}{ { "prop_1": "value_1", }, }, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err = store.DeleteBoard(boardID, userID) require.NoError(t, err) board, err = store.GetBoard(boardID) require.Error(t, err) require.Nil(t, board) time.Sleep(1 * time.Millisecond) err = store.UndeleteBoard(boardID, userID) require.NoError(t, err) board, err = store.GetBoard(boardID) require.NoError(t, err) require.NotNil(t, board) // verifying the data after un-delete require.Equal(t, "Dunder Mifflin Scranton", board.Title) require.Equal(t, "user-id", board.CreatedBy) require.Equal(t, "user-id", board.ModifiedBy) require.Equal(t, model.BoardRoleCommenter, board.MinimumRole) require.Equal(t, "Bears, beets, Battlestar Gallectica", board.Description) require.Equal(t, "🐻", board.Icon) require.True(t, board.ShowDescription) require.False(t, board.IsTemplate) require.Equal(t, board.Properties["prop_1"].(string), "value_1") require.Equal(t, 1, len(board.CardProperties)) require.Equal(t, board.CardProperties[0]["prop_1"], "value_1") require.Equal(t, board.CardProperties[0]["prop_1"], "value_1") }) t.Run("existing id multiple times", func(t *testing.T) { boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: model.BoardTypeOpen, } newBoard, err := store.InsertBoard(board, userID) require.NoError(t, err) require.NotNil(t, newBoard) // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err = store.DeleteBoard(boardID, userID) require.NoError(t, err) board, err = store.GetBoard(boardID) require.Error(t, err) require.Nil(t, board) // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err = store.UndeleteBoard(boardID, userID) require.NoError(t, err) board, err = store.GetBoard(boardID) require.NoError(t, err) require.NotNil(t, board) // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err = store.UndeleteBoard(boardID, userID) require.NoError(t, err) board, err = store.GetBoard(boardID) require.NoError(t, err) require.NotNil(t, board) }) t.Run("from not existing id", func(t *testing.T) { // Wait for not colliding the ID+insert_at key time.Sleep(1 * time.Millisecond) err := store.UndeleteBoard("not-exists", userID) require.NoError(t, err) block, err := store.GetBoard("not-exists") require.Error(t, err) require.Nil(t, block) }) } func testGetBoardHistory(t *testing.T, store store.Store) { userID := testUserID t.Run("testGetBoardHistory: create board", func(t *testing.T) { originalTitle := "Board: original title" boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, Title: originalTitle, TeamID: testTeamID, Type: model.BoardTypeOpen, } rBoard1, err := store.InsertBoard(board, userID) require.NoError(t, err) opts := model.QueryBoardHistoryOptions{ Limit: 0, Descending: false, } boards, err := store.GetBoardHistory(board.ID, opts) require.NoError(t, err) require.Len(t, boards, 1) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) userID2 := "user-id-2" newTitle := "Board: A new title" newDescription := "A new description" patch := &model.BoardPatch{Title: &newTitle, Description: &newDescription} patchedBoard, err := store.PatchBoard(boardID, patch, userID2) require.NoError(t, err) // Updated history boards, err = store.GetBoardHistory(board.ID, opts) require.NoError(t, err) require.Len(t, boards, 2) require.Equal(t, boards[0].Title, originalTitle) require.Equal(t, boards[1].Title, newTitle) require.Equal(t, boards[1].Description, newDescription) // Check history against latest board rBoard2, err := store.GetBoard(board.ID) require.NoError(t, err) require.Equal(t, rBoard2.Title, newTitle) require.Equal(t, rBoard2.Title, boards[1].Title) require.NotZero(t, rBoard2.UpdateAt) require.Equal(t, rBoard1.UpdateAt, boards[0].UpdateAt) require.Equal(t, rBoard2.UpdateAt, patchedBoard.UpdateAt) require.Equal(t, rBoard2.UpdateAt, boards[1].UpdateAt) require.Equal(t, rBoard1, boards[0]) require.Equal(t, rBoard2, boards[1]) // wait to avoid hitting pk uniqueness constraint in history time.Sleep(10 * time.Millisecond) newTitle2 := "Board: A new title 2" patch2 := &model.BoardPatch{Title: &newTitle2} patchBoard2, err := store.PatchBoard(boardID, patch2, userID2) require.NoError(t, err) // Updated history opts = model.QueryBoardHistoryOptions{ Limit: 1, Descending: true, } boards, err = store.GetBoardHistory(board.ID, opts) require.NoError(t, err) require.Len(t, boards, 1) require.Equal(t, boards[0].Title, newTitle2) require.Equal(t, boards[0], patchBoard2) // Delete board time.Sleep(10 * time.Millisecond) err = store.DeleteBoard(boardID, userID) require.NoError(t, err) // Updated history after delete opts = model.QueryBoardHistoryOptions{ Limit: 0, Descending: true, } boards, err = store.GetBoardHistory(board.ID, opts) require.NoError(t, err) require.Len(t, boards, 4) require.NotZero(t, boards[0].UpdateAt) require.Greater(t, boards[0].UpdateAt, patchBoard2.UpdateAt) require.NotZero(t, boards[0].DeleteAt) require.Greater(t, boards[0].DeleteAt, patchBoard2.UpdateAt) }) t.Run("testGetBoardHistory: nonexisting board", func(t *testing.T) { opts := model.QueryBoardHistoryOptions{ Limit: 0, Descending: false, } boards, err := store.GetBoardHistory("nonexistent-id", opts) require.NoError(t, err) require.Len(t, boards, 0) }) } func testGetBoardCount(t *testing.T, store store.Store) { userID := testUserID t.Run("test GetBoardCount", func(t *testing.T) { originalCount, err := store.GetBoardCount() require.NoError(t, err) title := "Board: original title" boardID := utils.NewID(utils.IDTypeBoard) board := &model.Board{ ID: boardID, Title: title, TeamID: testTeamID, Type: model.BoardTypeOpen, } _, err = store.InsertBoard(board, userID) require.NoError(t, err) newCount, err := store.GetBoardCount() require.NoError(t, err) require.Equal(t, originalCount+1, newCount) }) } ================================================ FILE: server/services/store/storetests/boards_and_blocks.go ================================================ package storetests import ( "fmt" "strings" "testing" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) func StoreTestBoardsAndBlocksStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("createBoardsAndBlocks", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testCreateBoardsAndBlocks(t, store) }) t.Run("patchBoardsAndBlocks", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testPatchBoardsAndBlocks(t, store) }) t.Run("deleteBoardsAndBlocks", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDeleteBoardsAndBlocks(t, store) }) t.Run("duplicateBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDuplicateBoard(t, store) }) } func testCreateBoardsAndBlocks(t *testing.T, store store.Store) { teamID := testTeamID userID := testUserID boards, err := store.GetBoardsForUserAndTeam(userID, teamID, true) require.Nil(t, err) require.Empty(t, boards) t.Run("create boards and blocks", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-1", TeamID: teamID, Type: model.BoardTypeOpen}, {ID: "board-id-2", TeamID: teamID, Type: model.BoardTypePrivate}, {ID: "board-id-3", TeamID: teamID, Type: model.BoardTypeOpen}, }, Blocks: []*model.Block{ {ID: "block-id-1", BoardID: "board-id-1", Type: model.TypeCard}, {ID: "block-id-2", BoardID: "board-id-2", Type: model.TypeCard}, }, } bab, err := store.CreateBoardsAndBlocks(newBab, userID) require.Nil(t, err) require.NotNil(t, bab) require.Len(t, bab.Boards, 3) require.Len(t, bab.Blocks, 2) boardIDs := []string{} for _, board := range bab.Boards { boardIDs = append(boardIDs, board.ID) } blockIDs := []string{} for _, block := range bab.Blocks { blockIDs = append(blockIDs, block.ID) } require.ElementsMatch(t, []string{"board-id-1", "board-id-2", "board-id-3"}, boardIDs) require.ElementsMatch(t, []string{"block-id-1", "block-id-2"}, blockIDs) }) t.Run("create boards and blocks with admin", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-4", TeamID: teamID, Type: model.BoardTypeOpen}, {ID: "board-id-5", TeamID: teamID, Type: model.BoardTypePrivate}, {ID: "board-id-6", TeamID: teamID, Type: model.BoardTypeOpen}, }, Blocks: []*model.Block{ {ID: "block-id-3", BoardID: "board-id-4", Type: model.TypeCard}, {ID: "block-id-4", BoardID: "board-id-5", Type: model.TypeCard}, }, } bab, members, err := store.CreateBoardsAndBlocksWithAdmin(newBab, userID) require.Nil(t, err) require.NotNil(t, bab) require.Len(t, bab.Boards, 3) require.Len(t, bab.Blocks, 2) require.Len(t, members, 3) boardIDs := []string{} for _, board := range bab.Boards { boardIDs = append(boardIDs, board.ID) } blockIDs := []string{} for _, block := range bab.Blocks { blockIDs = append(blockIDs, block.ID) } require.ElementsMatch(t, []string{"board-id-4", "board-id-5", "board-id-6"}, boardIDs) require.ElementsMatch(t, []string{"block-id-3", "block-id-4"}, blockIDs) memberBoardIDs := []string{} for _, member := range members { require.Equal(t, userID, member.UserID) memberBoardIDs = append(memberBoardIDs, member.BoardID) } require.ElementsMatch(t, []string{"board-id-4", "board-id-5", "board-id-6"}, memberBoardIDs) }) t.Run("on failure, nothing should be saved", func(t *testing.T) { // one of the blocks is invalid as it doesn't have BoardID newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-7", TeamID: teamID, Type: model.BoardTypeOpen}, {ID: "board-id-8", TeamID: teamID, Type: model.BoardTypePrivate}, {ID: "board-id-9", TeamID: teamID, Type: model.BoardTypeOpen}, }, Blocks: []*model.Block{ {ID: "block-id-5", BoardID: "board-id-7", Type: model.TypeCard}, {ID: "block-id-6", BoardID: "", Type: model.TypeCard}, }, } bab, err := store.CreateBoardsAndBlocks(newBab, userID) require.Error(t, err) require.Nil(t, bab) bab, members, err := store.CreateBoardsAndBlocksWithAdmin(newBab, userID) require.Error(t, err) require.Empty(t, bab) require.Empty(t, members) }) t.Run("should apply block size limits", func(t *testing.T) { // one of the blocks is invalid as it has a title too large newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-7", TeamID: teamID, Type: model.BoardTypeOpen}, {ID: "board-id-8", TeamID: teamID, Type: model.BoardTypePrivate}, {ID: "board-id-9", TeamID: teamID, Type: model.BoardTypeOpen}, }, Blocks: []*model.Block{ {ID: "block-id-5", BoardID: "board-id-7", Type: model.TypeCard}, {ID: "block-id-6", BoardID: "board-id-8", Type: model.TypeCard, Title: strings.Repeat("A", model.BlockTitleMaxRunes+1)}, }, } bab, err := store.CreateBoardsAndBlocks(newBab, userID) require.ErrorIs(t, err, model.ErrBlockTitleSizeLimitExceeded) require.Nil(t, bab) bab, members, err := store.CreateBoardsAndBlocksWithAdmin(newBab, userID) require.ErrorIs(t, err, model.ErrBlockTitleSizeLimitExceeded) require.Empty(t, bab) require.Empty(t, members) }) } func testPatchBoardsAndBlocks(t *testing.T, store store.Store) { teamID := testTeamID userID := testUserID t.Run("on failure, nothing should be saved", func(t *testing.T) { if store.DBType() == model.SqliteDBType { t.Skip("No transactions support int sqlite") } initialTitle := "initial title" newTitle := "new title" board := &model.Board{ ID: "board-id-1", Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } _, err := store.InsertBoard(board, userID) require.NoError(t, err) block := &model.Block{ ID: "block-id-1", BoardID: "board-id-1", Title: initialTitle, } require.NoError(t, store.InsertBlock(block, userID)) // apply the patches pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{"board-id-1"}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle}, }, BlockIDs: []string{"block-id-1", "block-id-2"}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Title: &newTitle}, }, } time.Sleep(10 * time.Millisecond) bab, err := store.PatchBoardsAndBlocks(pbab, userID) require.Error(t, err) require.Nil(t, bab) // check that things have not changed rBoard, err := store.GetBoard("board-id-1") require.NoError(t, err) require.Equal(t, initialTitle, rBoard.Title) rBlock, err := store.GetBlock("block-id-1") require.NoError(t, err) require.Equal(t, initialTitle, rBlock.Title) }) t.Run("should apply block size limits", func(t *testing.T) { if store.DBType() == model.SqliteDBType { t.Skip("No transactions support int sqlite") } initialTitle := "initial title" newTitle := strings.Repeat("A", model.BlockTitleMaxRunes+1) board := &model.Board{ ID: "board-id-1", Title: initialTitle, TeamID: teamID, Type: model.BoardTypeOpen, } _, err := store.InsertBoard(board, userID) require.NoError(t, err) block := &model.Block{ ID: "block-id-1", BoardID: "board-id-1", Title: initialTitle, } require.NoError(t, store.InsertBlock(block, userID)) // apply the patches pbab := &model.PatchBoardsAndBlocks{ BlockIDs: []string{"block-id-1"}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, }, } time.Sleep(10 * time.Millisecond) bab, err := store.PatchBoardsAndBlocks(pbab, userID) require.ErrorIs(t, err, model.ErrBlockTitleSizeLimitExceeded) require.Nil(t, bab) // check that things have not changed rBlock, err := store.GetBlock("block-id-1") require.NoError(t, err) require.Equal(t, initialTitle, rBlock.Title) }) t.Run("patch boards and blocks", func(t *testing.T) { newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-1", Description: "initial description", TeamID: teamID, Type: model.BoardTypeOpen}, {ID: "board-id-2", TeamID: teamID, Type: model.BoardTypePrivate}, {ID: "board-id-3", Title: "initial title", TeamID: teamID, Type: model.BoardTypeOpen}, }, Blocks: []*model.Block{ {ID: "block-id-1", Title: "initial title", BoardID: "board-id-1", Type: model.TypeCard}, {ID: "block-id-2", Schema: 1, BoardID: "board-id-2", Type: model.TypeCard}, }, } rBab, err := store.CreateBoardsAndBlocks(newBab, userID) require.Nil(t, err) require.NotNil(t, rBab) require.Len(t, rBab.Boards, 3) require.Len(t, rBab.Blocks, 2) // apply the patches newTitle := "new title" newDescription := "new description" var newSchema int64 = 2 pbab := &model.PatchBoardsAndBlocks{ BoardIDs: []string{"board-id-3", "board-id-1"}, BoardPatches: []*model.BoardPatch{ {Title: &newTitle, Description: &newDescription}, {Description: &newDescription}, }, BlockIDs: []string{"block-id-1", "block-id-2"}, BlockPatches: []*model.BlockPatch{ {Title: &newTitle}, {Schema: &newSchema}, }, } time.Sleep(10 * time.Millisecond) bab, err := store.PatchBoardsAndBlocks(pbab, userID) require.NoError(t, err) require.NotNil(t, bab) require.Len(t, bab.Boards, 2) require.Len(t, bab.Blocks, 2) // check that things have changed board1, err := store.GetBoard("board-id-1") require.NoError(t, err) require.Equal(t, newDescription, board1.Description) board3, err := store.GetBoard("board-id-3") require.NoError(t, err) require.Equal(t, newTitle, board3.Title) require.Equal(t, newDescription, board3.Description) block1, err := store.GetBlock("block-id-1") require.NoError(t, err) require.Equal(t, newTitle, block1.Title) block2, err := store.GetBlock("block-id-2") require.NoError(t, err) require.Equal(t, newSchema, block2.Schema) }) } func testDeleteBoardsAndBlocks(t *testing.T, store store.Store) { teamID := testTeamID userID := testUserID t.Run("should not delete anything if a block doesn't belong to any of the boards", func(t *testing.T) { if store.DBType() == model.SqliteDBType { t.Skip("No transactions support int sqlite") } newBoard1 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := store.InsertBoard(newBoard1, userID) require.NoError(t, err) block1 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block1, userID)) block2 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block2, userID)) newBoard2 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := store.InsertBoard(newBoard2, userID) require.NoError(t, err) block3 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board2.ID, } require.NoError(t, store.InsertBlock(block3, userID)) block4 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: "different-board-id", } require.NoError(t, store.InsertBlock(block4, userID)) dbab := &model.DeleteBoardsAndBlocks{ Boards: []string{board1.ID, board2.ID}, Blocks: []string{block1.ID, block2.ID, block3.ID, block4.ID}, } time.Sleep(10 * time.Millisecond) expectedErrorMsg := fmt.Sprintf("block %s doesn't belong to any of the boards in the delete request", block4.ID) require.EqualError(t, store.DeleteBoardsAndBlocks(dbab, userID), expectedErrorMsg) // all the entities should still exist rBoard1, err := store.GetBoard(board1.ID) require.NoError(t, err) require.NotNil(t, rBoard1) rBlock1, err := store.GetBlock(block1.ID) require.NoError(t, err) require.NotNil(t, rBlock1) rBlock2, err := store.GetBlock(block2.ID) require.NoError(t, err) require.NotNil(t, rBlock2) rBoard2, err := store.GetBoard(board2.ID) require.NoError(t, err) require.NotNil(t, rBoard2) rBlock3, err := store.GetBlock(block3.ID) require.NoError(t, err) require.NotNil(t, rBlock3) rBlock4, err := store.GetBlock(block4.ID) require.NoError(t, err) require.NotNil(t, rBlock4) }) t.Run("should not delete anything if a board doesn't exist", func(t *testing.T) { if store.DBType() == model.SqliteDBType { t.Skip("No transactions support int sqlite") } newBoard1 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := store.InsertBoard(newBoard1, userID) require.NoError(t, err) block1 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block1, userID)) block2 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block2, userID)) newBoard2 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := store.InsertBoard(newBoard2, userID) require.NoError(t, err) block3 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board2.ID, } require.NoError(t, store.InsertBlock(block3, userID)) block4 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board2.ID, } require.NoError(t, store.InsertBlock(block4, userID)) dbab := &model.DeleteBoardsAndBlocks{ Boards: []string{board1.ID, board2.ID, "a nonexistent board ID"}, Blocks: []string{block1.ID, block2.ID, block3.ID, block4.ID}, } time.Sleep(10 * time.Millisecond) require.True(t, model.IsErrNotFound(store.DeleteBoardsAndBlocks(dbab, userID))) // all the entities should still exist rBoard1, err := store.GetBoard(board1.ID) require.NoError(t, err) require.NotNil(t, rBoard1) rBlock1, err := store.GetBlock(block1.ID) require.NoError(t, err) require.NotNil(t, rBlock1) rBlock2, err := store.GetBlock(block2.ID) require.NoError(t, err) require.NotNil(t, rBlock2) rBoard2, err := store.GetBoard(board2.ID) require.NoError(t, err) require.NotNil(t, rBoard2) rBlock3, err := store.GetBlock(block3.ID) require.NoError(t, err) require.NotNil(t, rBlock3) rBlock4, err := store.GetBlock(block4.ID) require.NoError(t, err) require.NotNil(t, rBlock4) }) t.Run("should not delete anything if a block doesn't exist", func(t *testing.T) { if store.DBType() == model.SqliteDBType { t.Skip("No transactions support int sqlite") } newBoard1 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := store.InsertBoard(newBoard1, userID) require.NoError(t, err) block1 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block1, userID)) block2 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block2, userID)) newBoard2 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := store.InsertBoard(newBoard2, userID) require.NoError(t, err) block3 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board2.ID, } require.NoError(t, store.InsertBlock(block3, userID)) block4 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board2.ID, } require.NoError(t, store.InsertBlock(block4, userID)) dbab := &model.DeleteBoardsAndBlocks{ Boards: []string{board1.ID, board2.ID}, Blocks: []string{block1.ID, block2.ID, block3.ID, block4.ID, "a nonexistent block ID"}, } time.Sleep(10 * time.Millisecond) require.True(t, model.IsErrNotFound(store.DeleteBoardsAndBlocks(dbab, userID))) // all the entities should still exist rBoard1, err := store.GetBoard(board1.ID) require.NoError(t, err) require.NotNil(t, rBoard1) rBlock1, err := store.GetBlock(block1.ID) require.NoError(t, err) require.NotNil(t, rBlock1) rBlock2, err := store.GetBlock(block2.ID) require.NoError(t, err) require.NotNil(t, rBlock2) rBoard2, err := store.GetBoard(board2.ID) require.NoError(t, err) require.NotNil(t, rBoard2) rBlock3, err := store.GetBlock(block3.ID) require.NoError(t, err) require.NotNil(t, rBlock3) rBlock4, err := store.GetBlock(block4.ID) require.NoError(t, err) require.NotNil(t, rBlock4) }) t.Run("should work properly if all the entities are related", func(t *testing.T) { newBoard1 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board1, err := store.InsertBoard(newBoard1, userID) require.NoError(t, err) block1 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block1, userID)) block2 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board1.ID, } require.NoError(t, store.InsertBlock(block2, userID)) newBoard2 := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: model.BoardTypeOpen, } board2, err := store.InsertBoard(newBoard2, userID) require.NoError(t, err) block3 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board2.ID, } require.NoError(t, store.InsertBlock(block3, userID)) block4 := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: board2.ID, } require.NoError(t, store.InsertBlock(block4, userID)) dbab := &model.DeleteBoardsAndBlocks{ Boards: []string{board1.ID, board2.ID}, Blocks: []string{block1.ID, block2.ID, block3.ID, block4.ID}, } time.Sleep(10 * time.Millisecond) require.NoError(t, store.DeleteBoardsAndBlocks(dbab, userID)) rBoard1, err := store.GetBoard(board1.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, rBoard1) rBlock1, err := store.GetBlock(block1.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, rBlock1) rBlock2, err := store.GetBlock(block2.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, rBlock2) rBoard2, err := store.GetBoard(board2.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, rBoard2) rBlock3, err := store.GetBlock(block3.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, rBlock3) rBlock4, err := store.GetBlock(block4.ID) require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, rBlock4) }) } func testDuplicateBoard(t *testing.T, store store.Store) { teamID := testTeamID userID := testUserID newBab := &model.BoardsAndBlocks{ Boards: []*model.Board{ {ID: "board-id-1", TeamID: teamID, Type: model.BoardTypeOpen, ChannelID: "test-channel"}, {ID: "board-id-2", TeamID: teamID, Type: model.BoardTypePrivate}, {ID: "board-id-3", TeamID: teamID, Type: model.BoardTypeOpen}, }, Blocks: []*model.Block{ {ID: "block-id-1", BoardID: "board-id-1", Type: model.TypeCard}, {ID: "block-id-1a", BoardID: "board-id-1", Type: model.TypeComment}, {ID: "block-id-2", BoardID: "board-id-2", Type: model.TypeCard}, }, } bab, err := store.CreateBoardsAndBlocks(newBab, userID) require.Nil(t, err) require.NotNil(t, bab) require.Len(t, bab.Boards, 3) require.Len(t, bab.Blocks, 3) t.Run("duplicate existing board as no template", func(t *testing.T) { bab, members, err := store.DuplicateBoard("board-id-1", userID, teamID, false) require.NoError(t, err) require.Len(t, members, 1) require.Len(t, bab.Boards, 1) require.Len(t, bab.Blocks, 1) require.Equal(t, bab.Boards[0].IsTemplate, false) require.Equal(t, "", bab.Boards[0].ChannelID) }) t.Run("duplicate existing board as template", func(t *testing.T) { bab, members, err := store.DuplicateBoard("board-id-1", userID, teamID, true) require.NoError(t, err) require.Len(t, members, 1) require.Len(t, bab.Boards, 1) require.Len(t, bab.Blocks, 1) require.Equal(t, bab.Boards[0].IsTemplate, true) require.Equal(t, "", bab.Boards[0].ChannelID) }) t.Run("duplicate not existing board", func(t *testing.T) { bab, members, err := store.DuplicateBoard("not-existing-id", userID, teamID, false) require.Error(t, err) require.Nil(t, members) require.Nil(t, bab) }) } ================================================ FILE: server/services/store/storetests/category.go ================================================ package storetests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" ) type testFunc func(t *testing.T, store store.Store) func StoreTestCategoryStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { tests := map[string]testFunc{ "CreateCategory": testGetCreateCategory, "UpdateCategory": testUpdateCategory, "DeleteCategory": testDeleteCategory, "GetUserCategories": testGetUserCategories, "ReorderCategories": testReorderCategories, "ReorderCategoriesBoards": testReorderCategoryBoards, } for name, f := range tests { t.Run(name, func(t *testing.T) { store, tearDown := setup(t) defer tearDown() f(t, store) }) } } func testGetCreateCategory(t *testing.T, store store.Store) { t.Run("save uncollapsed category", func(t *testing.T) { now := utils.GetMillis() category := model.Category{ ID: "category_id_1", Name: "Category", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err := store.CreateCategory(category) assert.NoError(t, err) createdCategory, err := store.GetCategory("category_id_1") assert.NoError(t, err) assert.Equal(t, "Category", createdCategory.Name) assert.Equal(t, "user_id_1", createdCategory.UserID) assert.Equal(t, "team_id_1", createdCategory.TeamID) assert.Equal(t, false, createdCategory.Collapsed) }) t.Run("save collapsed category", func(t *testing.T) { now := utils.GetMillis() category := model.Category{ ID: "category_id_2", Name: "Category", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: true, } err := store.CreateCategory(category) assert.NoError(t, err) createdCategory, err := store.GetCategory("category_id_2") assert.NoError(t, err) assert.Equal(t, "Category", createdCategory.Name) assert.Equal(t, "user_id_1", createdCategory.UserID) assert.Equal(t, "team_id_1", createdCategory.TeamID) assert.Equal(t, true, createdCategory.Collapsed) }) t.Run("get nonexistent category", func(t *testing.T) { category, err := store.GetCategory("nonexistent") assert.Error(t, err) var nf *model.ErrNotFound assert.ErrorAs(t, err, &nf) assert.Nil(t, category) }) } func testUpdateCategory(t *testing.T, store store.Store) { now := utils.GetMillis() category := model.Category{ ID: "category_id_1", Name: "Category 1", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err := store.CreateCategory(category) assert.NoError(t, err) updateNow := utils.GetMillis() updatedCategory := model.Category{ ID: "category_id_1", Name: "Category 1 New", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: updateNow, DeleteAt: 0, Collapsed: true, } err = store.UpdateCategory(updatedCategory) assert.NoError(t, err) fetchedCategory, err := store.GetCategory("category_id_1") assert.NoError(t, err) assert.Equal(t, "category_id_1", fetchedCategory.ID) assert.Equal(t, "Category 1 New", fetchedCategory.Name) assert.Equal(t, true, fetchedCategory.Collapsed) // now lets try to un-collapse the same category updatedCategory.Collapsed = false err = store.UpdateCategory(updatedCategory) assert.NoError(t, err) fetchedCategory, err = store.GetCategory("category_id_1") assert.NoError(t, err) assert.Equal(t, "category_id_1", fetchedCategory.ID) assert.Equal(t, "Category 1 New", fetchedCategory.Name) assert.Equal(t, false, fetchedCategory.Collapsed) } func testDeleteCategory(t *testing.T, store store.Store) { now := utils.GetMillis() category := model.Category{ ID: "category_id_1", Name: "Category 1", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err := store.CreateCategory(category) assert.NoError(t, err) err = store.DeleteCategory("category_id_1", "user_id_1", "team_id_1") assert.NoError(t, err) deletedCategory, err := store.GetCategory("category_id_1") assert.NoError(t, err) assert.Equal(t, "category_id_1", deletedCategory.ID) assert.Equal(t, "Category 1", deletedCategory.Name) assert.Equal(t, false, deletedCategory.Collapsed) assert.Greater(t, deletedCategory.DeleteAt, int64(0)) } func testGetUserCategories(t *testing.T, store store.Store) { now := utils.GetMillis() category1 := model.Category{ ID: "category_id_1", Name: "Category 1", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err := store.CreateCategory(category1) assert.NoError(t, err) category2 := model.Category{ ID: "category_id_2", Name: "Category 2", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err = store.CreateCategory(category2) assert.NoError(t, err) category3 := model.Category{ ID: "category_id_3", Name: "Category 2", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err = store.CreateCategory(category3) assert.NoError(t, err) userCategories, err := store.GetUserCategoryBoards("user_id_1", "team_id_1") assert.NoError(t, err) assert.Equal(t, 3, len(userCategories)) } func testReorderCategories(t *testing.T, store store.Store) { // setup err := store.CreateCategory(model.Category{ ID: "category_id_1", Name: "Category 1", Type: "custom", UserID: "user_id", TeamID: "team_id", }) assert.NoError(t, err) err = store.CreateCategory(model.Category{ ID: "category_id_2", Name: "Category 2", Type: "custom", UserID: "user_id", TeamID: "team_id", }) assert.NoError(t, err) err = store.CreateCategory(model.Category{ ID: "category_id_3", Name: "Category 3", Type: "custom", UserID: "user_id", TeamID: "team_id", }) assert.NoError(t, err) // verify the current order categories, err := store.GetUserCategories("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 3, len(categories)) // the categories should show up in reverse insertion order (latest one first) assert.Equal(t, "category_id_3", categories[0].ID) assert.Equal(t, "category_id_2", categories[1].ID) assert.Equal(t, "category_id_1", categories[2].ID) // re-ordering categories normally _, err = store.ReorderCategories("user_id", "team_id", []string{ "category_id_2", "category_id_3", "category_id_1", }) assert.NoError(t, err) // verify the board order categories, err = store.GetUserCategories("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 3, len(categories)) assert.Equal(t, "category_id_2", categories[0].ID) assert.Equal(t, "category_id_3", categories[1].ID) assert.Equal(t, "category_id_1", categories[2].ID) // lets try specifying a non existing category ID. // It shouldn't cause any problem _, err = store.ReorderCategories("user_id", "team_id", []string{ "category_id_1", "category_id_2", "category_id_3", "non-existing-category-id", }) assert.NoError(t, err) categories, err = store.GetUserCategories("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 3, len(categories)) assert.Equal(t, "category_id_1", categories[0].ID) assert.Equal(t, "category_id_2", categories[1].ID) assert.Equal(t, "category_id_3", categories[2].ID) } func testReorderCategoryBoards(t *testing.T, store store.Store) { // setup err := store.CreateCategory(model.Category{ ID: "category_id_1", Name: "Category 1", Type: "custom", UserID: "user_id", TeamID: "team_id", }) assert.NoError(t, err) err = store.AddUpdateCategoryBoard("user_id", "category_id_1", []string{ "board_id_1", "board_id_2", "board_id_3", "board_id_4", }) assert.NoError(t, err) // verify current order categoryBoards, err := store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata)) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false}) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false}) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false}) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_4", Hidden: false}) // reordering newOrder, err := store.ReorderCategoryBoards("category_id_1", []string{ "board_id_3", "board_id_1", "board_id_2", "board_id_4", }) assert.NoError(t, err) assert.Equal(t, "board_id_3", newOrder[0]) assert.Equal(t, "board_id_1", newOrder[1]) assert.Equal(t, "board_id_2", newOrder[2]) assert.Equal(t, "board_id_4", newOrder[3]) // verify new order categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata)) assert.Equal(t, "board_id_3", categoryBoards[0].BoardMetadata[0].BoardID) assert.Equal(t, "board_id_1", categoryBoards[0].BoardMetadata[1].BoardID) assert.Equal(t, "board_id_2", categoryBoards[0].BoardMetadata[2].BoardID) assert.Equal(t, "board_id_4", categoryBoards[0].BoardMetadata[3].BoardID) } ================================================ FILE: server/services/store/storetests/categoryBoards.go ================================================ package storetests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" ) func StoreTestCategoryBoardsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("GetUserCategoryBoards", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetUserCategoryBoards(t, store) }) t.Run("AddUpdateCategoryBoard", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testAddUpdateCategoryBoard(t, store) }) t.Run("SetBoardVisibility", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testSetBoardVisibility(t, store) }) } func testGetUserCategoryBoards(t *testing.T, store store.Store) { now := utils.GetMillis() category1 := model.Category{ ID: "category_id_1", Name: "Category 1", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err := store.CreateCategory(category1) assert.NoError(t, err) category2 := model.Category{ ID: "category_id_2", Name: "Category 2", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err = store.CreateCategory(category2) assert.NoError(t, err) category3 := model.Category{ ID: "category_id_3", Name: "Category 3", UserID: "user_id_1", TeamID: "team_id_1", CreateAt: now, UpdateAt: now, DeleteAt: 0, Collapsed: false, } err = store.CreateCategory(category3) assert.NoError(t, err) // Adding Board 1 and Board 2 to Category 1 // The boards don't need to exists in DB for this test err = store.AddUpdateCategoryBoard("user_id_1", "category_id_1", []string{"board_1"}) assert.NoError(t, err) err = store.AddUpdateCategoryBoard("user_id_1", "category_id_1", []string{"board_2"}) assert.NoError(t, err) // Adding Board 3 to Category 2 err = store.AddUpdateCategoryBoard("user_id_1", "category_id_2", []string{"board_3"}) assert.NoError(t, err) // we'll leave category 3 empty userCategoryBoards, err := store.GetUserCategoryBoards("user_id_1", "team_id_1") assert.NoError(t, err) // we created 3 categories for the user assert.Equal(t, 3, len(userCategoryBoards)) var category1BoardCategory model.CategoryBoards var category2BoardCategory model.CategoryBoards var category3BoardCategory model.CategoryBoards for i := range userCategoryBoards { switch userCategoryBoards[i].ID { case "category_id_1": category1BoardCategory = userCategoryBoards[i] case "category_id_2": category2BoardCategory = userCategoryBoards[i] case "category_id_3": category3BoardCategory = userCategoryBoards[i] } } assert.NotEmpty(t, category1BoardCategory) assert.Equal(t, 2, len(category1BoardCategory.BoardMetadata)) assert.NotEmpty(t, category1BoardCategory) assert.Equal(t, 1, len(category2BoardCategory.BoardMetadata)) assert.NotEmpty(t, category1BoardCategory) assert.Equal(t, 0, len(category3BoardCategory.BoardMetadata)) t.Run("get empty category boards", func(t *testing.T) { userCategoryBoards, err := store.GetUserCategoryBoards("nonexistent-user-id", "nonexistent-team-id") assert.NoError(t, err) assert.Empty(t, userCategoryBoards) }) } func testAddUpdateCategoryBoard(t *testing.T, store store.Store) { // creating few boards and categories to later associoate with the category _, _, err := store.CreateBoardsAndBlocksWithAdmin(&model.BoardsAndBlocks{ Boards: []*model.Board{ { ID: "board_id_1", TeamID: "team_id", }, { ID: "board_id_2", TeamID: "team_id", }, }, }, "user_id") assert.NoError(t, err) err = store.CreateCategory(model.Category{ ID: "category_id", Name: "Category", UserID: "user_id", TeamID: "team_id", }) assert.NoError(t, err) // adding a few boards to the category err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1", "board_id_2"}) assert.NoError(t, err) // verify inserted data categoryBoards, err := store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "category_id", categoryBoards[0].ID) assert.Equal(t, 2, len(categoryBoards[0].BoardMetadata)) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false}) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false}) // adding new boards to the same category err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_3"}) assert.NoError(t, err) // verify inserted data categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "category_id", categoryBoards[0].ID) assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata)) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false}) // passing empty array err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{}) assert.NoError(t, err) // verify inserted data categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "category_id", categoryBoards[0].ID) assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata)) // passing duplicate data in input err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_4", "board_id_4"}) assert.NoError(t, err) // verify inserted data categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "category_id", categoryBoards[0].ID) assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata)) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_4", Hidden: false}) // adding already added board err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1", "board_id_2"}) assert.NoError(t, err) // verify inserted data categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "category_id", categoryBoards[0].ID) assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata)) // passing already added board along with a new board err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1", "board_id_5"}) assert.NoError(t, err) // verify inserted data categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "category_id", categoryBoards[0].ID) assert.Equal(t, 5, len(categoryBoards[0].BoardMetadata)) assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_5", Hidden: false}) } func testSetBoardVisibility(t *testing.T, store store.Store) { _, _, err := store.CreateBoardsAndBlocksWithAdmin(&model.BoardsAndBlocks{ Boards: []*model.Board{ { ID: "board_id_1", TeamID: "team_id", }, }, }, "user_id") assert.NoError(t, err) err = store.CreateCategory(model.Category{ ID: "category_id", Name: "Category", UserID: "user_id", TeamID: "team_id", }) assert.NoError(t, err) // adding a few boards to the category err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1"}) assert.NoError(t, err) err = store.SetBoardVisibility("user_id", "category_id", "board_id_1", true) assert.NoError(t, err) // verify set visibility categoryBoards, err := store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.Equal(t, 1, len(categoryBoards)) assert.Equal(t, "category_id", categoryBoards[0].ID) assert.Equal(t, 1, len(categoryBoards[0].BoardMetadata)) assert.False(t, categoryBoards[0].BoardMetadata[0].Hidden) err = store.SetBoardVisibility("user_id", "category_id", "board_id_1", false) assert.NoError(t, err) // verify set visibility categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) assert.True(t, categoryBoards[0].BoardMetadata[0].Hidden) } ================================================ FILE: server/services/store/storetests/cloud.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package storetests import ( "testing" "time" "github.com/stretchr/testify/require" "github.com/mattermost/focalboard/server/model" storeservice "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" ) func StoreTestCloudStore(t *testing.T, setup func(t *testing.T) (storeservice.Store, func())) { t.Run("GetUsedCardsCount", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetUsedCardsCount(t, store) }) t.Run("TestGetCardLimitTimestamp", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetCardLimitTimestamp(t, store) }) t.Run("TestUpdateCardLimitTimestamp", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUpdateCardLimitTimestamp(t, store) }) } func testGetUsedCardsCount(t *testing.T, store storeservice.Store) { userID := "user-id" t.Run("should return zero when no cards have been created", func(t *testing.T) { count, err := store.GetUsedCardsCount() require.NoError(t, err) require.Zero(t, count) }) t.Run("should correctly return the cards of all boards", func(t *testing.T) { // two boards for _, boardID := range []string{"board1", "board2"} { boardType := model.BoardTypeOpen if boardID == "board2" { boardType = model.BoardTypePrivate } board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: boardType, } _, err := store.InsertBoard(board, userID) require.NoError(t, err) } // board 1 has three cards for _, cardID := range []string{"card1", "card2", "card3"} { card := &model.Block{ ID: cardID, ParentID: "board1", BoardID: "board1", Type: model.TypeCard, } require.NoError(t, store.InsertBlock(card, userID)) } // board 2 has two cards for _, cardID := range []string{"card4", "card5"} { card := &model.Block{ ID: cardID, ParentID: "board2", BoardID: "board2", Type: model.TypeCard, } require.NoError(t, store.InsertBlock(card, userID)) } count, err := store.GetUsedCardsCount() require.NoError(t, err) require.Equal(t, 5, count) }) t.Run("should not take into account content blocks", func(t *testing.T) { // we add a couple of content blocks text := &model.Block{ ID: "text-id", ParentID: "card1", BoardID: "board1", Type: model.TypeText, } require.NoError(t, store.InsertBlock(text, userID)) view := &model.Block{ ID: "view-id", ParentID: "board1", BoardID: "board1", Type: model.TypeView, } require.NoError(t, store.InsertBlock(view, userID)) // and count should not change count, err := store.GetUsedCardsCount() require.NoError(t, err) require.Equal(t, 5, count) }) t.Run("should not take into account cards belonging to templates", func(t *testing.T) { // we add a template with cards templateID := "template-id" boardTemplate := &model.Block{ ID: templateID, BoardID: templateID, Type: model.TypeBoard, Fields: map[string]interface{}{ "isTemplate": true, }, } require.NoError(t, store.InsertBlock(boardTemplate, userID)) for _, cardID := range []string{"card6", "card7", "card8"} { card := &model.Block{ ID: cardID, ParentID: templateID, BoardID: templateID, Type: model.TypeCard, } require.NoError(t, store.InsertBlock(card, userID)) } // and count should still be the same count, err := store.GetUsedCardsCount() require.NoError(t, err) require.Equal(t, 5, count) }) t.Run("should not take into account deleted cards", func(t *testing.T) { // we create a ninth card on the first board card9 := &model.Block{ ID: "card9", ParentID: "board1", BoardID: "board1", Type: model.TypeCard, DeleteAt: utils.GetMillis(), } require.NoError(t, store.InsertBlock(card9, userID)) // and count should still be the same count, err := store.GetUsedCardsCount() require.NoError(t, err) require.Equal(t, 5, count) }) t.Run("should not take into account cards from deleted boards", func(t *testing.T) { require.NoError(t, store.DeleteBoard("board2", "user-id")) count, err := store.GetUsedCardsCount() require.NoError(t, err) require.Equal(t, 3, count) }) } func testGetCardLimitTimestamp(t *testing.T, store storeservice.Store) { t.Run("should return 0 if there is no entry in the database", func(t *testing.T) { rawValue, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) require.NoError(t, err) require.Equal(t, "", rawValue) cardLimitTimestamp, err := store.GetCardLimitTimestamp() require.NoError(t, err) require.Zero(t, cardLimitTimestamp) }) t.Run("should return an int64 representation of the value", func(t *testing.T) { require.NoError(t, store.SetSystemSetting(storeservice.CardLimitTimestampSystemKey, "1234")) cardLimitTimestamp, err := store.GetCardLimitTimestamp() require.NoError(t, err) require.Equal(t, int64(1234), cardLimitTimestamp) }) t.Run("should return an invalid value error if the value is not a number", func(t *testing.T) { require.NoError(t, store.SetSystemSetting(storeservice.CardLimitTimestampSystemKey, "abc")) cardLimitTimestamp, err := store.GetCardLimitTimestamp() require.ErrorContains(t, err, "card limit value is invalid") require.Zero(t, cardLimitTimestamp) }) } func testUpdateCardLimitTimestamp(t *testing.T, store storeservice.Store) { userID := "user-id" // two boards for _, boardID := range []string{"board1", "board2"} { boardType := model.BoardTypeOpen if boardID == "board2" { boardType = model.BoardTypePrivate } board := &model.Board{ ID: boardID, TeamID: testTeamID, Type: boardType, } _, err := store.InsertBoard(board, userID) require.NoError(t, err) } // board 1 has five cards for _, cardID := range []string{"card1", "card2", "card3", "card4", "card5"} { card := &model.Block{ ID: cardID, ParentID: "board1", BoardID: "board1", Type: model.TypeCard, } require.NoError(t, store.InsertBlock(card, userID)) time.Sleep(10 * time.Millisecond) } // board 2 has five cards for _, cardID := range []string{"card6", "card7", "card8", "card9", "card10"} { card := &model.Block{ ID: cardID, ParentID: "board2", BoardID: "board2", Type: model.TypeCard, } require.NoError(t, store.InsertBlock(card, userID)) time.Sleep(10 * time.Millisecond) } t.Run("should set the timestamp to zero if the card limit is zero", func(t *testing.T) { cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(0) require.NoError(t, err) require.Zero(t, cardLimitTimestamp) cardLimitTimestampStr, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) require.NoError(t, err) require.Equal(t, "0", cardLimitTimestampStr) }) t.Run("should correctly modify the limit several times in a row", func(t *testing.T) { cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(0) require.NoError(t, err) require.Zero(t, cardLimitTimestamp) cardLimitTimestamp, err = store.UpdateCardLimitTimestamp(10) require.NoError(t, err) require.NotZero(t, cardLimitTimestamp) cardLimitTimestampStr, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) require.NoError(t, err) require.NotEqual(t, "0", cardLimitTimestampStr) cardLimitTimestamp, err = store.UpdateCardLimitTimestamp(0) require.NoError(t, err) require.Zero(t, cardLimitTimestamp) cardLimitTimestampStr, err = store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) require.NoError(t, err) require.Equal(t, "0", cardLimitTimestampStr) }) t.Run("should set the correct timestamp", func(t *testing.T) { t.Run("limit 10", func(t *testing.T) { // we fetch the first block card1, err := store.GetBlock("card1") require.NoError(t, err) // and assert that if the limit is 10, the stored // timestamp corresponds to the card's update_at cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(10) require.NoError(t, err) require.Equal(t, card1.UpdateAt, cardLimitTimestamp) }) t.Run("limit 5", func(t *testing.T) { // if the limit is 5, the timestamp should be the one from // the sixth card (the first five are older and out of the card6, err := store.GetBlock("card6") require.NoError(t, err) cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(5) require.NoError(t, err) require.Equal(t, card6.UpdateAt, cardLimitTimestamp) }) t.Run("limit should be zero if we have less cards than the limit", func(t *testing.T) { cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(100) require.NoError(t, err) require.Zero(t, cardLimitTimestamp) }) t.Run("we update the first inserted card and assert that with limit 1 that's the limit that is set", func(t *testing.T) { time.Sleep(10 * time.Millisecond) card1, err := store.GetBlock("card1") require.NoError(t, err) card1.Title = "New title" require.NoError(t, store.InsertBlock(card1, userID)) newCard1, err := store.GetBlock("card1") require.NoError(t, err) cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(1) require.NoError(t, err) require.Equal(t, newCard1.UpdateAt, cardLimitTimestamp) }) t.Run("limit should stop applying if we remove the last card", func(t *testing.T) { initialCardLimitTimestamp, err := store.GetCardLimitTimestamp() require.NoError(t, err) require.NotZero(t, initialCardLimitTimestamp) time.Sleep(10 * time.Millisecond) require.NoError(t, store.DeleteBlock("card1", userID)) cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(10) require.NoError(t, err) require.Zero(t, cardLimitTimestamp) }) }) } ================================================ FILE: server/services/store/storetests/compliance.go ================================================ package storetests import ( "math" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func StoreTestComplianceHistoryStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("GetBoardsForCompliance", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBoardsForCompliance(t, store) }) t.Run("GetBoardsComplianceHistory", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBoardsComplianceHistory(t, store) }) t.Run("GetBlocksComplianceHistory", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetBlocksComplianceHistory(t, store) }) } func testGetBoardsForCompliance(t *testing.T, store store.Store) { team1 := testTeamID team2 := utils.NewID(utils.IDTypeTeam) boardsAdded1 := createTestBoards(t, store, team1, testUserID, 10) boardsAdded2 := createTestBoards(t, store, team2, testUserID, 7) deleteTestBoard(t, store, boardsAdded1[0].ID, testUserID) deleteTestBoard(t, store, boardsAdded1[1].ID, testUserID) boardsAdded1 = boardsAdded1[2:] t.Run("Invalid teamID", func(t *testing.T) { opts := model.QueryBoardsForComplianceOptions{ TeamID: utils.NewID(utils.IDTypeTeam), } boards, hasMore, err := store.GetBoardsForCompliance(opts) assert.Empty(t, boards) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("All teams", func(t *testing.T) { opts := model.QueryBoardsForComplianceOptions{} boards, hasMore, err := store.GetBoardsForCompliance(opts) assert.ElementsMatch(t, extractIDs(t, boards), extractIDs(t, boardsAdded1, boardsAdded2)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("Specific team", func(t *testing.T) { opts := model.QueryBoardsForComplianceOptions{ TeamID: team1, } boards, hasMore, err := store.GetBoardsForCompliance(opts) assert.ElementsMatch(t, extractIDs(t, boards), extractIDs(t, boardsAdded1)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("Pagination", func(t *testing.T) { opts := model.QueryBoardsForComplianceOptions{ Page: 0, PerPage: 3, } reps := 0 allBoards := make([]*model.Board, 0, 20) for { boards, hasMore, err := store.GetBoardsForCompliance(opts) require.NoError(t, err) require.NotEmpty(t, boards) allBoards = append(allBoards, boards...) if !hasMore { break } opts.Page++ reps++ } assert.ElementsMatch(t, extractIDs(t, allBoards), extractIDs(t, boardsAdded1, boardsAdded2)) }) } func testGetBoardsComplianceHistory(t *testing.T, store store.Store) { team1 := testTeamID team2 := utils.NewID(utils.IDTypeTeam) boardsTeam1 := createTestBoards(t, store, team1, testUserID, 11) boardsTeam2 := createTestBoards(t, store, team2, testUserID, 7) boardsAdded := make([]*model.Board, 0) boardsAdded = append(boardsAdded, boardsTeam1...) boardsAdded = append(boardsAdded, boardsTeam2...) deleteTestBoard(t, store, boardsTeam1[0].ID, testUserID) deleteTestBoard(t, store, boardsTeam1[1].ID, testUserID) boardsDeleted := boardsTeam1[0:2] boardsTeam1 = boardsTeam1[2:] t.Log("boardsTeam1: ", extractIDs(t, boardsTeam1)) t.Log("boardsTeam2: ", extractIDs(t, boardsTeam2)) t.Log("boardsAdded: ", extractIDs(t, boardsAdded)) t.Log("boardsDeleted: ", extractIDs(t, boardsDeleted)) t.Run("Invalid teamID", func(t *testing.T) { opts := model.QueryBoardsComplianceHistoryOptions{ TeamID: utils.NewID(utils.IDTypeTeam), } boardHistories, hasMore, err := store.GetBoardsComplianceHistory(opts) assert.Empty(t, boardHistories) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("All teams, include deleted", func(t *testing.T) { opts := model.QueryBoardsComplianceHistoryOptions{ IncludeDeleted: true, } boardHistories, hasMore, err := store.GetBoardsComplianceHistory(opts) // boardHistories should contain a record for each board added, plus a record for the 2 deleted. assert.ElementsMatch(t, extractIDs(t, boardHistories), extractIDs(t, boardsAdded, boardsDeleted)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("All teams, exclude deleted", func(t *testing.T) { opts := model.QueryBoardsComplianceHistoryOptions{ IncludeDeleted: false, } boardHistories, hasMore, err := store.GetBoardsComplianceHistory(opts) // boardHistories should contain a record for each board added, minus the two deleted. assert.ElementsMatch(t, extractIDs(t, boardHistories), extractIDs(t, boardsTeam1, boardsTeam2)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("Specific team", func(t *testing.T) { opts := model.QueryBoardsComplianceHistoryOptions{ TeamID: team1, } boardHistories, hasMore, err := store.GetBoardsComplianceHistory(opts) assert.ElementsMatch(t, extractIDs(t, boardHistories), extractIDs(t, boardsTeam1)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("Pagination", func(t *testing.T) { opts := model.QueryBoardsComplianceHistoryOptions{ Page: 0, PerPage: 3, } reps := 0 allHistories := make([]*model.BoardHistory, 0) for { reps++ boardHistories, hasMore, err := store.GetBoardsComplianceHistory(opts) require.NoError(t, err) require.NotEmpty(t, boardHistories) allHistories = append(allHistories, boardHistories...) if !hasMore { break } opts.Page++ } assert.ElementsMatch(t, extractIDs(t, allHistories), extractIDs(t, boardsTeam1, boardsTeam2)) expectedCount := len(boardsTeam1) + len(boardsTeam2) assert.Equal(t, math.Floor(float64(expectedCount/opts.PerPage)+1), float64(reps)) }) } func testGetBlocksComplianceHistory(t *testing.T, store store.Store) { team1 := testTeamID team2 := utils.NewID(utils.IDTypeTeam) boardsTeam1 := createTestBoards(t, store, team1, testUserID, 3) boardsTeam2 := createTestBoards(t, store, team2, testUserID, 1) // add cards (13 in total) cards1Team1 := createTestCards(t, store, testUserID, boardsTeam1[0].ID, 3) cards2Team1 := createTestCards(t, store, testUserID, boardsTeam1[1].ID, 5) cards3Team1 := createTestCards(t, store, testUserID, boardsTeam1[2].ID, 2) cards1Team2 := createTestCards(t, store, testUserID, boardsTeam2[0].ID, 3) deleteTestBoard(t, store, boardsTeam1[0].ID, testUserID) cardsDeleted := cards1Team1 t.Run("Invalid teamID", func(t *testing.T) { opts := model.QueryBlocksComplianceHistoryOptions{ TeamID: utils.NewID(utils.IDTypeTeam), } boards, hasMore, err := store.GetBlocksComplianceHistory(opts) assert.Empty(t, boards) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("All teams, include deleted", func(t *testing.T) { opts := model.QueryBlocksComplianceHistoryOptions{ IncludeDeleted: true, } blockHistories, hasMore, err := store.GetBlocksComplianceHistory(opts) // blockHistories should have records for all cards added, plus all cards deleted assert.ElementsMatch(t, extractIDs(t, blockHistories, nil), extractIDs(t, cards1Team1, cards2Team1, cards3Team1, cards1Team2, cardsDeleted)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("All teams, exclude deleted", func(t *testing.T) { opts := model.QueryBlocksComplianceHistoryOptions{} blockHistories, hasMore, err := store.GetBlocksComplianceHistory(opts) // blockHistories should have records for all cards added that have not been deleted assert.ElementsMatch(t, extractIDs(t, blockHistories, nil), extractIDs(t, cards2Team1, cards3Team1, cards1Team2)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("Specific team", func(t *testing.T) { opts := model.QueryBlocksComplianceHistoryOptions{ TeamID: team1, } blockHistories, hasMore, err := store.GetBlocksComplianceHistory(opts) assert.ElementsMatch(t, extractIDs(t, blockHistories), extractIDs(t, cards2Team1, cards3Team1)) assert.False(t, hasMore) assert.NoError(t, err) }) t.Run("Pagination", func(t *testing.T) { opts := model.QueryBlocksComplianceHistoryOptions{ Page: 0, PerPage: 3, } reps := 0 allHistories := make([]*model.BlockHistory, 0) for { reps++ blockHistories, hasMore, err := store.GetBlocksComplianceHistory(opts) require.NoError(t, err) require.NotEmpty(t, blockHistories) allHistories = append(allHistories, blockHistories...) if !hasMore { break } opts.Page++ } assert.ElementsMatch(t, extractIDs(t, allHistories), extractIDs(t, cards2Team1, cards3Team1, cards1Team2)) expectedCount := len(cards2Team1) + len(cards3Team1) + len(cards1Team2) assert.Equal(t, math.Floor(float64(expectedCount/opts.PerPage)+1), float64(reps)) }) } ================================================ FILE: server/services/store/storetests/data_retention.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package storetests import ( "testing" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/require" ) const ( boardID = "board-id-test" categoryID = "category-id-test" ) func StoreTestDataRetention(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("RunDataRetention", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() category := model.Category{ ID: categoryID, Name: "TestCategory", UserID: testUserID, TeamID: testTeamID, } err := store.CreateCategory(category) require.NoError(t, err) testRunDataRetention(t, store, 0) testRunDataRetention(t, store, 2) testRunDataRetention(t, store, 10) }) } func LoadData(t *testing.T, store store.Store) { validBoard := model.Board{ ID: boardID, IsTemplate: false, ModifiedBy: testUserID, TeamID: testTeamID, } board, err := store.InsertBoard(&validBoard, testUserID) require.NoError(t, err) validBlock := &model.Block{ ID: "id-test", BoardID: board.ID, ModifiedBy: testUserID, } validBlock2 := &model.Block{ ID: "id-test2", BoardID: board.ID, ModifiedBy: testUserID, } validBlock3 := &model.Block{ ID: "id-test3", BoardID: board.ID, ModifiedBy: testUserID, } validBlock4 := &model.Block{ ID: "id-test4", BoardID: board.ID, ModifiedBy: testUserID, } newBlocks := []*model.Block{validBlock, validBlock2, validBlock3, validBlock4} err = store.InsertBlocks(newBlocks, testUserID) require.NoError(t, err) member := &model.BoardMember{ UserID: testUserID, BoardID: boardID, SchemeAdmin: true, } _, err = store.SaveMember(member) require.NoError(t, err) sharing := model.Sharing{ ID: boardID, Enabled: true, Token: "testToken", } err = store.UpsertSharing(sharing) require.NoError(t, err) err = store.AddUpdateCategoryBoard(testUserID, categoryID, []string{boardID}) require.NoError(t, err) } func testRunDataRetention(t *testing.T, store store.Store, batchSize int) { LoadData(t, store) blocks, err := store.GetBlocksForBoard(boardID) require.NoError(t, err) require.Len(t, blocks, 4) initialCount := len(blocks) t.Run("test no deletions", func(t *testing.T) { deletions, err := store.RunDataRetention(utils.GetMillisForTime(time.Now().Add(-time.Hour*1)), int64(batchSize)) require.NoError(t, err) require.Equal(t, int64(0), deletions) }) t.Run("test all deletions", func(t *testing.T) { deletions, err := store.RunDataRetention(utils.GetMillisForTime(time.Now().Add(time.Hour*1)), int64(batchSize)) require.NoError(t, err) require.True(t, deletions > int64(initialCount)) // expect all blocks to be deleted. blocks, errBlocks := store.GetBlocksForBoard(boardID) require.NoError(t, errBlocks) require.Equal(t, 0, len(blocks)) // GetMemberForBoard throws error on now rows found member, err := store.GetMemberForBoard(boardID, testUserID) require.Error(t, err) require.True(t, model.IsErrNotFound(err), err) require.Nil(t, member) // GetSharing throws error on now rows found sharing, err := store.GetSharing(boardID) require.Error(t, err) require.True(t, model.IsErrNotFound(err), err) require.Nil(t, sharing) category, err := store.GetUserCategoryBoards(boardID, testTeamID) require.NoError(t, err) require.Empty(t, category) }) } ================================================ FILE: server/services/store/storetests/files.go ================================================ package storetests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/require" ) func StoreTestFileStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { sqlStore, tearDown := setup(t) defer tearDown() t.Run("should save and retrieve fileinfo", func(t *testing.T) { fileInfo := &mmModel.FileInfo{ Id: "file_info_1", CreateAt: utils.GetMillis(), Name: "Dunder Mifflin Sales Report 2022", Extension: ".sales", Size: 112233, DeleteAt: 0, } err := sqlStore.SaveFileInfo(fileInfo) require.NoError(t, err) retrievedFileInfo, err := sqlStore.GetFileInfo("file_info_1") require.NoError(t, err) require.Equal(t, "file_info_1", retrievedFileInfo.Id) require.Equal(t, "Dunder Mifflin Sales Report 2022", retrievedFileInfo.Name) require.Equal(t, ".sales", retrievedFileInfo.Extension) require.Equal(t, int64(112233), retrievedFileInfo.Size) require.Equal(t, int64(0), retrievedFileInfo.DeleteAt) require.False(t, retrievedFileInfo.Archived) }) t.Run("should return an error on not found", func(t *testing.T) { fileInfo, err := sqlStore.GetFileInfo("nonexistent") require.Error(t, err) var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, fileInfo) }) } ================================================ FILE: server/services/store/storetests/helpers.go ================================================ package storetests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/stretchr/testify/require" ) func InsertBlocks(t *testing.T, s store.Store, blocks []*model.Block, userID string) { for i := range blocks { err := s.InsertBlock(blocks[i], userID) require.NoError(t, err) } } func DeleteBlocks(t *testing.T, s store.Store, blocks []*model.Block, modifiedBy string) { for _, block := range blocks { err := s.DeleteBlock(block.ID, modifiedBy) require.NoError(t, err) } } func ContainsBlockWithID(blocks []*model.Block, blockID string) bool { for _, block := range blocks { if block.ID == blockID { return true } } return false } ================================================ FILE: server/services/store/storetests/notificationhints.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package storetests import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" ) func StoreTestNotificationHintsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("UpsertNotificationHint", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUpsertNotificationHint(t, store) }) t.Run("DeleteNotificationHint", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDeleteNotificationHint(t, store) }) t.Run("GetNotificationHint", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetNotificationHint(t, store) }) t.Run("GetNextNotificationHint", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetNextNotificationHint(t, store) }) } func testUpsertNotificationHint(t *testing.T, store store.Store) { t.Run("create notification hint", func(t *testing.T) { hint := &model.NotificationHint{ BlockType: model.TypeCard, BlockID: utils.NewID(utils.IDTypeBlock), ModifiedByID: utils.NewID(utils.IDTypeUser), } hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) require.NoError(t, err, "upsert notification hint should not error") assert.Equal(t, hint.BlockID, hintNew.BlockID) assert.NoError(t, hintNew.IsValid()) }) t.Run("duplicate notification hint", func(t *testing.T) { hint := &model.NotificationHint{ BlockType: model.TypeCard, BlockID: utils.NewID(utils.IDTypeBlock), ModifiedByID: utils.NewID(utils.IDTypeUser), } hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) require.NoError(t, err, "upsert notification hint should not error") // sleep a short time so the notify_at timestamps won't collide time.Sleep(time.Millisecond * 20) hint = &model.NotificationHint{ BlockType: model.TypeCard, BlockID: hintNew.BlockID, ModifiedByID: hintNew.ModifiedByID, } hintDup, err := store.UpsertNotificationHint(hint, time.Second*15) require.NoError(t, err, "upsert notification hint should not error") // notify_at should be updated assert.Greater(t, hintDup.NotifyAt, hintNew.NotifyAt) }) t.Run("invalid notification hint", func(t *testing.T) { hint := &model.NotificationHint{} _, err := store.UpsertNotificationHint(hint, time.Second*15) assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") hint.BlockType = "board" _, err = store.UpsertNotificationHint(hint, time.Second*15) assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") _, err = store.UpsertNotificationHint(hint, time.Second*15) assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") hint.ModifiedByID = utils.NewID(utils.IDTypeUser) _, err = store.UpsertNotificationHint(hint, time.Second*15) assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") hint.BlockID = utils.NewID(utils.IDTypeBlock) hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) assert.NoError(t, err, "valid notification hint should not error") assert.NoError(t, hintNew.IsValid(), "created notification hint should be valid") }) } func testDeleteNotificationHint(t *testing.T, store store.Store) { t.Run("delete notification hint", func(t *testing.T) { hint := &model.NotificationHint{ BlockType: model.TypeCard, BlockID: utils.NewID(utils.IDTypeBlock), ModifiedByID: utils.NewID(utils.IDTypeUser), } hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) require.NoError(t, err, "create notification hint should not error") // check the notification hint exists hint, err = store.GetNotificationHint(hintNew.BlockID) require.NoError(t, err, "get notification hint should not error") assert.Equal(t, hintNew.BlockID, hint.BlockID) assert.Equal(t, hintNew.CreateAt, hint.CreateAt) err = store.DeleteNotificationHint(hintNew.BlockID) require.NoError(t, err, "delete notification hint should not error") // check the notification hint was deleted hint, err = store.GetNotificationHint(hintNew.BlockID) require.True(t, model.IsErrNotFound(err), "error should be of type store.ErrNotFound") assert.Nil(t, hint) }) t.Run("delete non-existent notification hint", func(t *testing.T) { err := store.DeleteNotificationHint("bogus") require.True(t, model.IsErrNotFound(err), "error should be of type store.ErrNotFound") }) } func testGetNotificationHint(t *testing.T, store store.Store) { t.Run("get notification hint", func(t *testing.T) { hint := &model.NotificationHint{ BlockType: model.TypeCard, BlockID: utils.NewID(utils.IDTypeBlock), ModifiedByID: utils.NewID(utils.IDTypeUser), } hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) require.NoError(t, err, "create notification hint should not error") // make sure notification hint can be fetched hint, err = store.GetNotificationHint(hintNew.BlockID) require.NoError(t, err, "get notification hint should not error") assert.Equal(t, hintNew, hint) }) t.Run("get non-existent notification hint", func(t *testing.T) { hint, err := store.GetNotificationHint("bogus") require.True(t, model.IsErrNotFound(err), "error should be of type store.ErrNotFound") assert.Nil(t, hint, "hint should be nil") }) } func testGetNextNotificationHint(t *testing.T, store store.Store) { t.Run("get next notification hint", func(t *testing.T) { const loops = 5 ids := [5]string{} modifiedBy := utils.NewID(utils.IDTypeUser) // create some hints with unique notifyAt for i := 0; i < loops; i++ { hint := &model.NotificationHint{ BlockType: model.TypeCard, BlockID: utils.NewID(utils.IDTypeBlock), ModifiedByID: modifiedBy, } hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) require.NoError(t, err, "create notification hint should not error") ids[i] = hintNew.BlockID time.Sleep(time.Millisecond * 20) // ensure next timestamp is unique } // check the hints come back in the right order notifyAt := utils.GetMillisForTime(time.Now().Add(time.Millisecond * 50)) for i := 0; i < loops; i++ { hint, err := store.GetNextNotificationHint(false) require.NoError(t, err, "get next notification hint should not error") require.NotNil(t, hint, "get next notification hint should not return nil") assert.Equal(t, ids[i], hint.BlockID) assert.Less(t, notifyAt, hint.NotifyAt) notifyAt = hint.NotifyAt err = store.DeleteNotificationHint(hint.BlockID) require.NoError(t, err, "delete notification hint should not error") } }) t.Run("get next notification hint from empty table", func(t *testing.T) { // empty the table err := emptyNotificationHintTable(store) require.NoError(t, err, "emptying notification hint table should not error") for { hint, err2 := store.GetNextNotificationHint(false) if model.IsErrNotFound(err2) { break } require.NoError(t, err2, "get next notification hint should not error") err2 = store.DeleteNotificationHint(hint.BlockID) require.NoError(t, err2, "delete notification hint should not error") } _, err = store.GetNextNotificationHint(false) require.True(t, model.IsErrNotFound(err), "error should be of type store.ErrNotFound") }) t.Run("get next notification hint and remove", func(t *testing.T) { // empty the table err := emptyNotificationHintTable(store) require.NoError(t, err, "emptying notification hint table should not error") hint := &model.NotificationHint{ BlockType: model.TypeCard, BlockID: utils.NewID(utils.IDTypeBlock), ModifiedByID: utils.NewID(utils.IDTypeUser), } hintNew, err := store.UpsertNotificationHint(hint, time.Second*1) require.NoError(t, err, "create notification hint should not error") hintDeleted, err := store.GetNextNotificationHint(true) require.NoError(t, err, "get next notification hint should not error") require.NotNil(t, hintDeleted, "get next notification hint should not return nil") assert.Equal(t, hintNew.BlockID, hintDeleted.BlockID) // should be no hint left _, err = store.GetNextNotificationHint(false) require.True(t, model.IsErrNotFound(err), "error should be of type store.ErrNotFound") }) } func emptyNotificationHintTable(store store.Store) error { for { hint, err := store.GetNextNotificationHint(false) if model.IsErrNotFound(err) { break } if err != nil { return err } err = store.DeleteNotificationHint(hint.BlockID) if err != nil { return err } } return nil } ================================================ FILE: server/services/store/storetests/session.go ================================================ package storetests import ( "fmt" "testing" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/stretchr/testify/require" ) func StoreTestSessionStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("CreateAndGetAndDeleteSession", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testCreateAndGetAndDeleteSession(t, store) }) t.Run("GetActiveUserCount", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetActiveUserCount(t, store) }) t.Run("UpdateSession", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUpdateSession(t, store) }) } func testCreateAndGetAndDeleteSession(t *testing.T, store store.Store) { session := &model.Session{ ID: "session-id", Token: "token", } t.Run("CreateAndGetSession", func(t *testing.T) { err := store.CreateSession(session) require.NoError(t, err) got, err := store.GetSession(session.Token, 60*60) require.NoError(t, err) require.Equal(t, session, got) }) t.Run("Get nonexistent session", func(t *testing.T) { got, err := store.GetSession("nonexistent-token", 60*60) require.True(t, model.IsErrNotFound(err)) require.Nil(t, got) }) t.Run("DeleteAndGetSession", func(t *testing.T) { err := store.DeleteSession(session.ID) require.NoError(t, err) _, err = store.GetSession(session.Token, 60*60) require.Error(t, err) }) } func testGetActiveUserCount(t *testing.T, store store.Store) { t.Run("no active user", func(t *testing.T) { count, err := store.GetActiveUserCount(60) require.NoError(t, err) require.Equal(t, 0, count) }) t.Run("active user", func(t *testing.T) { // gen random count active user session count := int(time.Now().Unix() % 10) for i := 0; i < count; i++ { session := &model.Session{ ID: fmt.Sprintf("id-%d", i), UserID: fmt.Sprintf("user-id-%d", i), Token: fmt.Sprintf("token-%d", i), } err := store.CreateSession(session) require.NoError(t, err) } got, err := store.GetActiveUserCount(60) require.NoError(t, err) require.Equal(t, count, got) }) } func testUpdateSession(t *testing.T, store store.Store) { session := &model.Session{ ID: "session-id", Token: "token", Props: map[string]interface{}{"field1": "A"}, } err := store.CreateSession(session) require.NoError(t, err) // update session session.Props["field1"] = "B" err = store.UpdateSession(session) require.NoError(t, err) got, err := store.GetSession(session.Token, 60) require.NoError(t, err) require.Equal(t, session, got) } ================================================ FILE: server/services/store/storetests/sharing.go ================================================ package storetests import ( "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/stretchr/testify/require" ) func StoreTestSharingStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("UpsertSharingAndGetSharing", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUpsertSharingAndGetSharing(t, store) }) } func testUpsertSharingAndGetSharing(t *testing.T, store store.Store) { t.Run("Insert first sharing and get it", func(t *testing.T) { sharing := model.Sharing{ ID: "sharing-id", Enabled: true, Token: "token", ModifiedBy: testUserID, } err := store.UpsertSharing(sharing) require.NoError(t, err) newSharing, err := store.GetSharing("sharing-id") require.NoError(t, err) newSharing.UpdateAt = 0 require.Equal(t, sharing, *newSharing) }) t.Run("Upsert the inserted sharing and get it", func(t *testing.T) { sharing := model.Sharing{ ID: "sharing-id", Enabled: true, Token: "token2", ModifiedBy: "user-id2", } newSharing, err := store.GetSharing("sharing-id") require.NoError(t, err) newSharing.UpdateAt = 0 require.NotEqual(t, sharing, *newSharing) err = store.UpsertSharing(sharing) require.NoError(t, err) newSharing, err = store.GetSharing("sharing-id") require.NoError(t, err) newSharing.UpdateAt = 0 require.Equal(t, sharing, *newSharing) }) t.Run("Get not existing sharing", func(t *testing.T) { _, err := store.GetSharing("not-existing") require.Error(t, err) require.True(t, model.IsErrNotFound(err)) }) } ================================================ FILE: server/services/store/storetests/subscriptions.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package storetests import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" ) //nolint:dupl func StoreTestSubscriptionsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("CreateSubscription", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testCreateSubscription(t, store) }) t.Run("DeleteSubscription", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testDeleteSubscription(t, store) }) t.Run("UndeleteSubscription", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUndeleteSubscription(t, store) }) t.Run("GetSubscription", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetSubscription(t, store) }) t.Run("GetSubscriptions", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetSubscriptions(t, store) }) t.Run("GetSubscribersForBlock", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetSubscribersForBlock(t, store) }) } func testCreateSubscription(t *testing.T, store store.Store) { t.Run("create subscriptions", func(t *testing.T) { users := createTestUsers(t, store, 10) blocks := createTestBlocks(t, store, users[0].ID, 50) for i, user := range users { for j := 0; j < i; j++ { sub := &model.Subscription{ BlockType: blocks[j].Type, BlockID: blocks[j].ID, SubscriberType: "user", SubscriberID: user.ID, } subNew, err := store.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") assert.NotZero(t, subNew.NotifiedAt) assert.NotZero(t, subNew.CreateAt) assert.Zero(t, subNew.DeleteAt) } } // ensure each user has the right number of subscriptions for i, user := range users { subs, err := store.GetSubscriptions(user.ID) require.NoError(t, err, "get subscriptions should not error") assert.Len(t, subs, i) } }) t.Run("duplicate subscription", func(t *testing.T) { admin := createTestUsers(t, store, 1)[0] user := createTestUsers(t, store, 1)[0] block := createTestBlocks(t, store, admin.ID, 1)[0] sub := &model.Subscription{ BlockType: block.Type, BlockID: block.ID, SubscriberType: "user", SubscriberID: user.ID, } subNew, err := store.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") sub = &model.Subscription{ BlockType: block.Type, BlockID: block.ID, SubscriberType: "user", SubscriberID: user.ID, } subDup, err := store.CreateSubscription(sub) require.NoError(t, err, "create duplicate subscription should not error") assert.Equal(t, subNew.BlockID, subDup.BlockID) assert.Equal(t, subNew.SubscriberID, subDup.SubscriberID) }) t.Run("invalid subscription", func(t *testing.T) { admin := createTestUsers(t, store, 1)[0] user := createTestUsers(t, store, 1)[0] block := createTestBlocks(t, store, admin.ID, 1)[0] sub := &model.Subscription{} _, err := store.CreateSubscription(sub) assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") sub.BlockType = block.Type _, err = store.CreateSubscription(sub) assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") sub.BlockID = block.ID _, err = store.CreateSubscription(sub) assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") sub.SubscriberType = "user" _, err = store.CreateSubscription(sub) assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") sub.SubscriberID = user.ID subNew, err := store.CreateSubscription(sub) assert.NoError(t, err, "valid subscription should not error") assert.NoError(t, subNew.IsValid(), "created subscription should be valid") }) } func testDeleteSubscription(t *testing.T, s store.Store) { t.Run("delete subscription", func(t *testing.T) { user := createTestUsers(t, s, 1)[0] block := createTestBlocks(t, s, user.ID, 1)[0] sub := &model.Subscription{ BlockType: block.Type, BlockID: block.ID, SubscriberType: "user", SubscriberID: user.ID, } subNew, err := s.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") // check the subscription exists subs, err := s.GetSubscriptions(user.ID) require.NoError(t, err, "get subscriptions should not error") assert.Len(t, subs, 1) assert.Equal(t, subNew.BlockID, subs[0].BlockID) assert.Equal(t, subNew.SubscriberID, subs[0].SubscriberID) err = s.DeleteSubscription(block.ID, user.ID) require.NoError(t, err, "delete subscription should not error") // check the subscription was deleted subs, err = s.GetSubscriptions(user.ID) require.NoError(t, err, "get subscriptions should not error") assert.Empty(t, subs) }) t.Run("delete non-existent subscription", func(t *testing.T) { err := s.DeleteSubscription("bogus", "bogus") require.Error(t, err, "delete non-existent subscription should error") require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") }) } func testUndeleteSubscription(t *testing.T, s store.Store) { t.Run("undelete subscription", func(t *testing.T) { user := createTestUsers(t, s, 1)[0] block := createTestBlocks(t, s, user.ID, 1)[0] sub := &model.Subscription{ BlockType: block.Type, BlockID: block.ID, SubscriberType: "user", SubscriberID: user.ID, } subNew, err := s.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") // check the subscription exists subs, err := s.GetSubscriptions(user.ID) require.NoError(t, err, "get subscriptions should not error") assert.Len(t, subs, 1) assert.Equal(t, subNew.BlockID, subs[0].BlockID) assert.Equal(t, subNew.SubscriberID, subs[0].SubscriberID) err = s.DeleteSubscription(block.ID, user.ID) require.NoError(t, err, "delete subscription should not error") // check the subscription was deleted subs, err = s.GetSubscriptions(user.ID) require.NoError(t, err, "get subscriptions should not error") assert.Empty(t, subs) // re-create the subscription subUndeleted, err := s.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") // check the undeleted subscription exists subs, err = s.GetSubscriptions(user.ID) require.NoError(t, err, "get subscriptions should not error") assert.Len(t, subs, 1) assert.Equal(t, subUndeleted.BlockID, subs[0].BlockID) assert.Equal(t, subUndeleted.SubscriberID, subs[0].SubscriberID) }) } func testGetSubscription(t *testing.T, s store.Store) { t.Run("get subscription", func(t *testing.T) { user := createTestUsers(t, s, 1)[0] block := createTestBlocks(t, s, user.ID, 1)[0] sub := &model.Subscription{ BlockType: block.Type, BlockID: block.ID, SubscriberType: "user", SubscriberID: user.ID, } subNew, err := s.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") // make sure subscription can be fetched sub, err = s.GetSubscription(block.ID, user.ID) require.NoError(t, err, "get subscription should not error") assert.Equal(t, subNew, sub) }) t.Run("get non-existent subscription", func(t *testing.T) { sub, err := s.GetSubscription("bogus", "bogus") require.Error(t, err, "get non-existent subscription should error") require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error") require.Nil(t, sub, "get subscription should return nil") }) } func testGetSubscriptions(t *testing.T, store store.Store) { t.Run("get subscriptions", func(t *testing.T) { author := createTestUsers(t, store, 1)[0] user := createTestUsers(t, store, 1)[0] blocks := createTestBlocks(t, store, author.ID, 50) for _, block := range blocks { sub := &model.Subscription{ BlockType: block.Type, BlockID: block.ID, SubscriberType: "user", SubscriberID: user.ID, } _, err := store.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") } // ensure user has the right number of subscriptions subs, err := store.GetSubscriptions(user.ID) require.NoError(t, err, "get subscriptions should not error") assert.Len(t, subs, len(blocks)) // ensure author has no subscriptions subs, err = store.GetSubscriptions(author.ID) require.NoError(t, err, "get subscriptions should not error") assert.Empty(t, subs) }) t.Run("get subscriptions for invalid user", func(t *testing.T) { subs, err := store.GetSubscriptions("bogus") require.NoError(t, err, "get subscriptions should not error") assert.Empty(t, subs) }) } func testGetSubscribersForBlock(t *testing.T, store store.Store) { t.Run("get subscribers for block", func(t *testing.T) { users := createTestUsers(t, store, 50) blocks := createTestBlocks(t, store, users[0].ID, 2) for _, user := range users { sub := &model.Subscription{ BlockType: blocks[1].Type, BlockID: blocks[1].ID, SubscriberType: "user", SubscriberID: user.ID, } _, err := store.CreateSubscription(sub) require.NoError(t, err, "create subscription should not error") } // make sure block[1] has the right number of users subscribed subs, err := store.GetSubscribersForBlock(blocks[1].ID) require.NoError(t, err, "get subscribers for block should not error") assert.Len(t, subs, 50) count, err := store.GetSubscribersCountForBlock(blocks[1].ID) require.NoError(t, err, "get subscribers for block should not error") assert.Equal(t, 50, count) // make sure block[0] has zero users subscribed subs, err = store.GetSubscribersForBlock(blocks[0].ID) require.NoError(t, err, "get subscribers for block should not error") assert.Empty(t, subs) count, err = store.GetSubscribersCountForBlock(blocks[0].ID) require.NoError(t, err, "get subscribers for block should not error") assert.Zero(t, count) }) t.Run("get subscribers for invalid block", func(t *testing.T) { subs, err := store.GetSubscribersForBlock("bogus") require.NoError(t, err, "get subscribers for block should not error") assert.Empty(t, subs) }) } ================================================ FILE: server/services/store/storetests/system.go ================================================ package storetests import ( "testing" "github.com/mattermost/focalboard/server/services/store" "github.com/stretchr/testify/require" ) // these system settings are created when running the data migrations, // so they will be present after the tests setup. var dataMigrationSystemSettings = map[string]string{ "UniqueIDsMigrationComplete": "true", "CategoryUuidIdMigrationComplete": "true", "DeDuplicateCategoryBoardTableComplete": "true", } func addBaseSettings(m map[string]string) map[string]string { r := map[string]string{} for k, v := range dataMigrationSystemSettings { r[k] = v } for k, v := range m { r[k] = v } return r } func StoreTestSystemStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("SetGetSystemSettings", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testSetGetSystemSettings(t, store) }) } func testSetGetSystemSettings(t *testing.T, store store.Store) { t.Run("Get empty settings", func(t *testing.T) { settings, err := store.GetSystemSettings() require.NoError(t, err) require.Equal(t, dataMigrationSystemSettings, settings) }) t.Run("Set, update and get multiple settings", func(t *testing.T) { err := store.SetSystemSetting("test-1", "test-value-1") require.NoError(t, err) err = store.SetSystemSetting("test-2", "test-value-2") require.NoError(t, err) settings, err := store.GetSystemSettings() require.NoError(t, err) require.Equal(t, addBaseSettings(map[string]string{"test-1": "test-value-1", "test-2": "test-value-2"}), settings) err = store.SetSystemSetting("test-2", "test-value-updated-2") require.NoError(t, err) settings, err = store.GetSystemSettings() require.NoError(t, err) require.Equal(t, addBaseSettings(map[string]string{"test-1": "test-value-1", "test-2": "test-value-updated-2"}), settings) }) t.Run("Get a single setting", func(t *testing.T) { value, err := store.GetSystemSetting("test-1") require.NoError(t, err) require.Equal(t, "test-value-1", value) }) } ================================================ FILE: server/services/store/storetests/teams.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package storetests import ( "fmt" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "testing" "github.com/stretchr/testify/require" "github.com/mattermost/focalboard/server/services/store" ) func StoreTestTeamStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("GetTeam", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetTeam(t, store) }) t.Run("UpsertTeamSignupToken", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUpsertTeamSignupToken(t, store) }) t.Run("UpsertTeamSettings", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testUpsertTeamSettings(t, store) }) t.Run("GetAllTeams", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetAllTeams(t, store) }) } func testGetTeam(t *testing.T, store store.Store) { t.Run("Nonexistent team", func(t *testing.T) { got, err := store.GetTeam("nonexistent-id") require.Error(t, err) require.True(t, model.IsErrNotFound(err)) require.Nil(t, got) }) t.Run("Valid team", func(t *testing.T) { teamID := "0" team := &model.Team{ ID: teamID, SignupToken: utils.NewID(utils.IDTypeToken), } err := store.UpsertTeamSignupToken(*team) require.NoError(t, err) got, err := store.GetTeam(teamID) require.NoError(t, err) require.Equal(t, teamID, got.ID) }) } func testUpsertTeamSignupToken(t *testing.T, store store.Store) { t.Run("Insert and update team with signup token", func(t *testing.T) { teamID := "0" team := &model.Team{ ID: teamID, SignupToken: utils.NewID(utils.IDTypeToken), } // insert err := store.UpsertTeamSignupToken(*team) require.NoError(t, err) got, err := store.GetTeam(teamID) require.NoError(t, err) require.Equal(t, team.ID, got.ID) require.Equal(t, team.SignupToken, got.SignupToken) // update signup token team.SignupToken = utils.NewID(utils.IDTypeToken) err = store.UpsertTeamSignupToken(*team) require.NoError(t, err) got, err = store.GetTeam(teamID) require.NoError(t, err) require.Equal(t, team.ID, got.ID) require.Equal(t, team.SignupToken, got.SignupToken) }) } func testUpsertTeamSettings(t *testing.T, store store.Store) { t.Run("Insert and update team with settings", func(t *testing.T) { teamID := "0" team := &model.Team{ ID: teamID, Settings: map[string]interface{}{ "field1": "A", }, } // insert err := store.UpsertTeamSettings(*team) require.NoError(t, err) got, err := store.GetTeam(teamID) require.NoError(t, err) require.Equal(t, team.ID, got.ID) require.Equal(t, team.Settings, got.Settings) // update settings team.Settings = map[string]interface{}{ "field1": "B", } err = store.UpsertTeamSettings(*team) require.NoError(t, err) got2, err := store.GetTeam(teamID) require.NoError(t, err) require.Equal(t, team.ID, got2.ID) require.Equal(t, team.Settings, got2.Settings) require.Equal(t, got.SignupToken, got2.SignupToken) }) } func testGetAllTeams(t *testing.T, store store.Store) { t.Run("No teams response", func(t *testing.T) { got, err := store.GetAllTeams() require.NoError(t, err) require.Empty(t, got) }) t.Run("Insert multiple team and get all teams", func(t *testing.T) { // insert teamCount := 10 for i := 0; i < teamCount; i++ { teamID := fmt.Sprintf("%d", i) team := &model.Team{ ID: teamID, SignupToken: utils.NewID(utils.IDTypeToken), } err := store.UpsertTeamSignupToken(*team) require.NoError(t, err) } got, err := store.GetAllTeams() require.NoError(t, err) require.Len(t, got, teamCount) }) } ================================================ FILE: server/services/store/storetests/users.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package storetests import ( "fmt" "testing" "time" "github.com/stretchr/testify/require" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" ) //nolint:dupl func StoreTestUserStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { t.Run("GetUsersByTeam", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetUsersByTeam(t, store) }) t.Run("CreateAndGetUser", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testCreateAndGetUser(t, store) }) t.Run("GetUsersList", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testGetUsersList(t, store) }) t.Run("CreateAndUpdateUser", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testCreateAndUpdateUser(t, store) }) t.Run("CreateAndGetRegisteredUserCount", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testCreateAndGetRegisteredUserCount(t, store) }) t.Run("TestPatchUserProps", func(t *testing.T) { store, tearDown := setup(t) defer tearDown() testPatchUserProps(t, store) }) } func testGetUsersByTeam(t *testing.T, store store.Store) { t.Run("GetTeamUsers", func(t *testing.T) { users, err := store.GetUsersByTeam("team_1", "", false, false) require.Equal(t, 0, len(users)) require.NoError(t, err) userID := utils.NewID(utils.IDTypeUser) user, err := store.CreateUser(&model.User{ ID: userID, Username: "darth.vader", }) require.NoError(t, err) require.NotNil(t, user) require.Equal(t, userID, user.ID) require.Equal(t, "darth.vader", user.Username) defer func() { _, _ = store.UpdateUser(&model.User{ ID: userID, DeleteAt: utils.GetMillis(), }) }() users, err = store.GetUsersByTeam("team_1", "", false, false) require.Equal(t, 1, len(users)) require.Equal(t, "darth.vader", users[0].Username) require.NoError(t, err) }) } func testCreateAndGetUser(t *testing.T, store store.Store) { user := &model.User{ ID: utils.NewID(utils.IDTypeUser), Username: "damao", Email: "mock@email.com", } t.Run("CreateUser", func(t *testing.T) { newUser, err := store.CreateUser(user) require.NoError(t, err) require.NotNil(t, newUser) }) t.Run("GetUserByID", func(t *testing.T) { got, err := store.GetUserByID(user.ID) require.NoError(t, err) require.Equal(t, user.ID, got.ID) require.Equal(t, user.Username, got.Username) require.Equal(t, user.Email, got.Email) }) t.Run("GetUserByID nonexistent", func(t *testing.T) { got, err := store.GetUserByID("nonexistent-id") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, got) }) t.Run("GetUserByUsername", func(t *testing.T) { got, err := store.GetUserByUsername(user.Username) require.NoError(t, err) require.Equal(t, user.ID, got.ID) require.Equal(t, user.Username, got.Username) require.Equal(t, user.Email, got.Email) }) t.Run("GetUserByUsername nonexistent", func(t *testing.T) { got, err := store.GetUserByID("nonexistent-username") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, got) }) t.Run("GetUserByEmail", func(t *testing.T) { got, err := store.GetUserByEmail(user.Email) require.NoError(t, err) require.Equal(t, user.ID, got.ID) require.Equal(t, user.Username, got.Username) require.Equal(t, user.Email, got.Email) }) t.Run("GetUserByEmail nonexistent", func(t *testing.T) { got, err := store.GetUserByID("nonexistent-email") var nf *model.ErrNotFound require.ErrorAs(t, err, &nf) require.Nil(t, got) }) } func testGetUsersList(t *testing.T, store store.Store) { for _, id := range []string{"user1", "user2"} { user := &model.User{ ID: id, Username: fmt.Sprintf("%s-username", id), Email: fmt.Sprintf("%s@sample.com", id), } newUser, err := store.CreateUser(user) require.NoError(t, err) require.NotNil(t, newUser) } testCases := []struct { Name string UserIDs []string ExpectedError bool ExpectedIDs []string }{ { Name: "all of the IDs are found", UserIDs: []string{"user1", "user2"}, ExpectedError: false, ExpectedIDs: []string{"user1", "user2"}, }, { Name: "some of the IDs are found", UserIDs: []string{"user2", "non-existent"}, ExpectedError: true, ExpectedIDs: []string{"user2"}, }, { Name: "none of the IDs are found", UserIDs: []string{"non-existent-1", "non-existent-2"}, ExpectedError: true, ExpectedIDs: []string{}, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { users, err := store.GetUsersList(tc.UserIDs, false, false) if tc.ExpectedError { require.Error(t, err) require.True(t, model.IsErrNotFound(err)) } else { require.NoError(t, err) } userIDs := []string{} for _, user := range users { userIDs = append(userIDs, user.ID) } require.ElementsMatch(t, tc.ExpectedIDs, userIDs) }) } } func testCreateAndUpdateUser(t *testing.T, store store.Store) { user := &model.User{ ID: utils.NewID(utils.IDTypeUser), } newUser, err := store.CreateUser(user) require.NoError(t, err) require.NotNil(t, newUser) t.Run("UpdateUser", func(t *testing.T) { user.Username = "damao" user.Email = "mock@email.com" uUser, err := store.UpdateUser(user) require.NoError(t, err) require.NotNil(t, uUser) require.Equal(t, user.Username, uUser.Username) require.Equal(t, user.Email, uUser.Email) got, err := store.GetUserByID(user.ID) require.NoError(t, err) require.Equal(t, user.ID, got.ID) require.Equal(t, user.Username, got.Username) require.Equal(t, user.Email, got.Email) }) t.Run("UpdateUserPassword", func(t *testing.T) { newPassword := utils.NewID(utils.IDTypeNone) err := store.UpdateUserPassword(user.Username, newPassword) require.NoError(t, err) got, err := store.GetUserByUsername(user.Username) require.NoError(t, err) require.Equal(t, user.Username, got.Username) require.Equal(t, newPassword, got.Password) }) t.Run("UpdateUserPasswordByID", func(t *testing.T) { newPassword := utils.NewID(utils.IDTypeNone) err := store.UpdateUserPasswordByID(user.ID, newPassword) require.NoError(t, err) got, err := store.GetUserByID(user.ID) require.NoError(t, err) require.Equal(t, user.ID, got.ID) require.Equal(t, newPassword, got.Password) }) } func testCreateAndGetRegisteredUserCount(t *testing.T, store store.Store) { randomN := int(time.Now().Unix() % 10) for i := 0; i < randomN; i++ { user, err := store.CreateUser(&model.User{ ID: utils.NewID(utils.IDTypeUser), }) require.NoError(t, err) require.NotNil(t, user) } got, err := store.GetRegisteredUserCount() require.NoError(t, err) require.Equal(t, randomN, got) } func testPatchUserProps(t *testing.T, store store.Store) { user := &model.User{ ID: utils.NewID(utils.IDTypeUser), } newUser, err := store.CreateUser(user) require.NoError(t, err) require.NotNil(t, newUser) key1 := "new_key_1" key2 := "new_key_2" key3 := "new_key_3" // Only update props patch := model.UserPreferencesPatch{ UpdatedFields: map[string]string{ key1: "new_value_1", key2: "new_value_2", key3: "new_value_3", }, } userPreferences, err := store.PatchUserPreferences(user.ID, patch) require.NoError(t, err) require.Equal(t, 3, len(userPreferences)) for _, preference := range userPreferences { switch preference.Name { case key1: require.Equal(t, "new_value_1", preference.Value) case key2: require.Equal(t, "new_value_2", preference.Value) case key3: require.Equal(t, "new_value_3", preference.Value) } } // Delete a prop patch = model.UserPreferencesPatch{ DeletedFields: []string{ key1, }, } userPreferences, err = store.PatchUserPreferences(user.ID, patch) require.NoError(t, err) for _, preference := range userPreferences { switch preference.Name { case key1: t.Errorf("new_key_1 shouldn't exist in user preference as we just deleted it") case key2: require.Equal(t, "new_value_2", preference.Value) case key3: require.Equal(t, "new_value_3", preference.Value) } } // update and delete together patch = model.UserPreferencesPatch{ UpdatedFields: map[string]string{ key3: "new_value_3_new_again", }, DeletedFields: []string{ key2, }, } userPreferences, err = store.PatchUserPreferences(user.ID, patch) require.NoError(t, err) for _, preference := range userPreferences { switch preference.Name { case key1: t.Errorf("new_key_1 shouldn't exist in user preference as we just deleted it") case key2: t.Errorf("new_key_2 shouldn't exist in user preference as we just deleted it") case key3: require.Equal(t, "new_value_3_new_again", preference.Value) } } } ================================================ FILE: server/services/store/storetests/util.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package storetests import ( "fmt" "sort" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func createTestUsers(t *testing.T, store store.Store, num int) []*model.User { var users []*model.User for i := 0; i < num; i++ { user := &model.User{ ID: utils.NewID(utils.IDTypeUser), Username: fmt.Sprintf("mooncake.%d", i), Email: fmt.Sprintf("mooncake.%d@example.com", i), } newUser, err := store.CreateUser(user) require.NoError(t, err) require.NotNil(t, newUser) users = append(users, user) } return users } func createTestBlocks(t *testing.T, store store.Store, userID string, num int) []*model.Block { var blocks []*model.Block for i := 0; i < num; i++ { block := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: utils.NewID(utils.IDTypeBoard), Type: model.TypeCard, CreatedBy: userID, } err := store.InsertBlock(block, userID) require.NoError(t, err) blocks = append(blocks, block) } return blocks } func createTestBlocksForCard(t *testing.T, store store.Store, cardID string, num int) []*model.Block { card, err := store.GetBlock(cardID) require.NoError(t, err) assert.EqualValues(t, model.TypeCard, card.Type) var blocks []*model.Block for i := 0; i < num; i++ { block := &model.Block{ ID: utils.NewID(utils.IDTypeBlock), BoardID: card.BoardID, Type: model.TypeText, CreatedBy: card.CreatedBy, ParentID: card.ID, Title: fmt.Sprintf("text %d", i), } err := store.InsertBlock(block, card.CreatedBy) require.NoError(t, err) blocks = append(blocks, block) } return blocks } //nolint:unparam func createTestCards(t *testing.T, store store.Store, userID string, boardID string, num int) []*model.Block { var blocks []*model.Block for i := 0; i < num; i++ { block := &model.Block{ ID: utils.NewID(utils.IDTypeCard), BoardID: boardID, ParentID: boardID, Type: model.TypeCard, CreatedBy: userID, Title: fmt.Sprintf("card %d", i), } err := store.InsertBlock(block, userID) require.NoError(t, err) blocks = append(blocks, block) } return blocks } //nolint:unparam func createTestBoards(t *testing.T, store store.Store, teamID string, userID string, num int) []*model.Board { var boards []*model.Board for i := 0; i < num; i++ { board := &model.Board{ ID: utils.NewID(utils.IDTypeBoard), TeamID: teamID, Type: "O", CreatedBy: userID, Title: fmt.Sprintf("board %d", i), } boardNew, err := store.InsertBoard(board, userID) require.NoError(t, err) boards = append(boards, boardNew) } return boards } //nolint:unparam func deleteTestBoard(t *testing.T, store store.Store, boardID string, userID string) { err := store.DeleteBoard(boardID, userID) require.NoError(t, err) } // extractIDs is a test helper that extracts a sorted slice of IDs from slices of various struct types. // Might have used generics here except that would require implementing a `GetID` method on each type. func extractIDs(t *testing.T, arr ...any) []string { ids := make([]string, 0) for _, item := range arr { if item == nil { continue } switch tarr := item.(type) { case []*model.Board: for _, b := range tarr { if b != nil { ids = append(ids, b.ID) } } case []*model.BoardHistory: for _, bh := range tarr { ids = append(ids, bh.ID) } case []*model.Block: for _, b := range tarr { if b != nil { ids = append(ids, b.ID) } } case []*model.BlockHistory: for _, bh := range tarr { ids = append(ids, bh.ID) } default: t.Errorf("unsupported type %T extracting board ID", item) } } // sort the ids to make it easier to compare lists of ids visually. sort.Strings(ids) return ids } ================================================ FILE: server/services/telemetry/mocks/ServerIface.go ================================================ // Code generated by mockery v1.0.0. DO NOT EDIT. // Regenerate this file using `make telemetry-mocks`. package mocks import ( httpservice "github.com/mattermost/mattermost/server/v8/platform/services/httpservice" mock "github.com/stretchr/testify/mock" model "github.com/mattermost/mattermost/server/public/model" plugin "github.com/mattermost/mattermost/server/public/plugin" ) // ServerIface is an autogenerated mock type for the ServerIface type type ServerIface struct { mock.Mock } // Config provides a mock function with given fields: func (_m *ServerIface) Config() *model.Config { ret := _m.Called() var r0 *model.Config if rf, ok := ret.Get(0).(func() *model.Config); ok { r0 = rf() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Config) } } return r0 } // GetPluginsEnvironment provides a mock function with given fields: func (_m *ServerIface) GetPluginsEnvironment() *plugin.Environment { ret := _m.Called() var r0 *plugin.Environment if rf, ok := ret.Get(0).(func() *plugin.Environment); ok { r0 = rf() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*plugin.Environment) } } return r0 } // GetRoleByName provides a mock function with given fields: _a0 func (_m *ServerIface) GetRoleByName(_a0 string) (*model.Role, *model.AppError) { ret := _m.Called(_a0) var r0 *model.Role if rf, ok := ret.Get(0).(func(string) *model.Role); ok { r0 = rf(_a0) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Role) } } var r1 *model.AppError if rf, ok := ret.Get(1).(func(string) *model.AppError); ok { r1 = rf(_a0) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*model.AppError) } } return r0, r1 } // GetSchemes provides a mock function with given fields: _a0, _a1, _a2 func (_m *ServerIface) GetSchemes(_a0 string, _a1 int, _a2 int) ([]*model.Scheme, *model.AppError) { ret := _m.Called(_a0, _a1, _a2) var r0 []*model.Scheme if rf, ok := ret.Get(0).(func(string, int, int) []*model.Scheme); ok { r0 = rf(_a0, _a1, _a2) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Scheme) } } var r1 *model.AppError if rf, ok := ret.Get(1).(func(string, int, int) *model.AppError); ok { r1 = rf(_a0, _a1, _a2) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*model.AppError) } } return r0, r1 } // HttpService provides a mock function with given fields: func (_m *ServerIface) HttpService() httpservice.HTTPService { ret := _m.Called() var r0 httpservice.HTTPService if rf, ok := ret.Get(0).(func() httpservice.HTTPService); ok { r0 = rf() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(httpservice.HTTPService) } } return r0 } // IsLeader provides a mock function with given fields: func (_m *ServerIface) IsLeader() bool { ret := _m.Called() var r0 bool if rf, ok := ret.Get(0).(func() bool); ok { r0 = rf() } else { r0 = ret.Get(0).(bool) } return r0 } // License provides a mock function with given fields: func (_m *ServerIface) License() *model.License { ret := _m.Called() var r0 *model.License if rf, ok := ret.Get(0).(func() *model.License); ok { r0 = rf() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.License) } } return r0 } ================================================ FILE: server/services/telemetry/telemetry.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package telemetry import ( "os" "strings" "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/scheduler" rudder "github.com/rudderlabs/analytics-go" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/utils" ) const ( rudderKey = "placeholder_rudder_key" rudderDataplaneURL = "placeholder_rudder_dataplane_url" timeBetweenTelemetryChecks = 10 * time.Minute ) type TrackerFunc func() (Tracker, error) type Tracker map[string]interface{} type Service struct { trackers map[string]TrackerFunc logger mlog.LoggerIFace rudderClient rudder.Client telemetryID string timestampLastTelemetrySent time.Time } type RudderConfig struct { RudderKey string DataplaneURL string } func New(telemetryID string, logger mlog.LoggerIFace) *Service { service := &Service{ logger: logger, telemetryID: telemetryID, trackers: map[string]TrackerFunc{}, } return service } func (ts *Service) RegisterTracker(name string, f TrackerFunc) { ts.trackers[name] = f } func (ts *Service) getRudderConfig() RudderConfig { if !strings.Contains(rudderKey, "placeholder") && !strings.Contains(rudderDataplaneURL, "placeholder") { return RudderConfig{rudderKey, rudderDataplaneURL} } if os.Getenv("RUDDER_KEY") != "" && os.Getenv("RUDDER_DATAPLANE_URL") != "" { return RudderConfig{os.Getenv("RUDDER_KEY"), os.Getenv("RUDDER_DATAPLANE_URL")} } return RudderConfig{} } func (ts *Service) sendDailyTelemetry(override bool) { config := ts.getRudderConfig() if (config.DataplaneURL != "" && config.RudderKey != "") || override { ts.initRudder(config.DataplaneURL, config.RudderKey) for name, tracker := range ts.trackers { m, err := tracker() if err != nil { ts.logger.Error("Error fetching telemetry data", mlog.String("name", name), mlog.Err(err)) continue } ts.sendTelemetry(name, m) } } } func (ts *Service) sendTelemetry(event string, properties map[string]interface{}) { if ts.rudderClient != nil { var context *rudder.Context _ = ts.rudderClient.Enqueue(rudder.Track{ Event: event, UserId: ts.telemetryID, Properties: properties, Context: context, }) } } func (ts *Service) initRudder(endpoint, rudderKey string) { if ts.rudderClient == nil { config := rudder.Config{} config.Logger = rudder.StdLogger(ts.logger.StdLogger(model.LvlFBTelemetry)) config.Endpoint = endpoint // For testing if endpoint != rudderDataplaneURL { config.Verbose = true config.BatchSize = 1 } client, err := rudder.NewWithConfig(rudderKey, endpoint, config) if err != nil { ts.logger.Fatal("Failed to create Rudder instance") return } _ = client.Enqueue(rudder.Identify{ UserId: ts.telemetryID, }) ts.rudderClient = client } } func (ts *Service) doTelemetryIfNeeded(firstRun time.Time) { hoursSinceFirstServerRun := time.Since(firstRun).Hours() // Send once every 10 minutes for the first hour if hoursSinceFirstServerRun < 1 { ts.doTelemetry() return } // Send once every hour thereafter for the first 12 hours if hoursSinceFirstServerRun <= 12 && time.Since(ts.timestampLastTelemetrySent) >= time.Hour { ts.doTelemetry() return } // Send at the 24 hour mark and every 24 hours after if hoursSinceFirstServerRun > 12 && time.Since(ts.timestampLastTelemetrySent) >= 24*time.Hour { ts.doTelemetry() return } } func (ts *Service) RunTelemetryJob(firstRunMillis int64) { // Send on boot ts.doTelemetry() scheduler.CreateRecurringTask("Telemetry", func() { ts.doTelemetryIfNeeded(utils.TimeFromMillis(firstRunMillis)) }, timeBetweenTelemetryChecks) } func (ts *Service) doTelemetry() { ts.timestampLastTelemetrySent = time.Now() ts.sendDailyTelemetry(false) } // Shutdown closes the telemetry client. func (ts *Service) Shutdown() error { if ts.rudderClient != nil { return ts.rudderClient.Close() } return nil } ================================================ FILE: server/services/telemetry/telemetry_test.go ================================================ package telemetry import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/stretchr/testify/require" ) func mockServer() (chan []byte, *httptest.Server) { done := make(chan []byte, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { buf := bytes.NewBuffer(nil) if _, err := io.Copy(buf, r.Body); err != nil { panic(err) } var v interface{} err := json.Unmarshal(buf.Bytes(), &v) if err != nil { panic(err) } b, err := json.MarshalIndent(v, "", " ") if err != nil { panic(err) } // filter the identify message if strings.Contains(string(b), `"type": "identify"`) { return } done <- b })) return done, server } func TestTelemetry(t *testing.T) { receiveChan, server := mockServer() os.Setenv("RUDDER_KEY", "mock-test-rudder-key") os.Setenv("RUDDER_DATAPLANE_URL", server.URL) checkMockRudderServer := func(t *testing.T) { // check mock rudder server got got := string(<-receiveChan) require.Contains(t, got, "mockTrackerKey") require.Contains(t, got, "mockTrackerValue") } t.Run("Register tracker and run telemetry job", func(t *testing.T) { service := New("mockTelemetryID", mlog.CreateConsoleTestLogger(t)) service.RegisterTracker("mockTracker", func() (Tracker, error) { return map[string]interface{}{ "mockTrackerKey": "mockTrackerValue", }, nil }) service.RunTelemetryJob(time.Now().UnixNano() / int64(time.Millisecond)) checkMockRudderServer(t) }) t.Run("do telemetry if needed", func(t *testing.T) { service := New("mockTelemetryID", mlog.CreateConsoleTestLogger(t)) service.RegisterTracker("mockTracker", func() (Tracker, error) { return map[string]interface{}{ "mockTrackerKey": "mockTrackerValue", }, nil }) firstRun := time.Now() t.Run("Send once every 10 minutes for the first hour", func(t *testing.T) { service.doTelemetryIfNeeded(firstRun.Add(-30 * time.Minute)) checkMockRudderServer(t) }) t.Run("Send once every hour thereafter for the first 12 hours", func(t *testing.T) { // firstRun is 2 hours ago and timestampLastTelemetrySent is hour ago // need to do telemetry service.timestampLastTelemetrySent = time.Now().Add(-time.Hour) service.doTelemetryIfNeeded(firstRun.Add(-2 * time.Hour)) checkMockRudderServer(t) // firstRun is 2 hours ago and timestampLastTelemetrySent is just now // no need to do telemetry service.doTelemetryIfNeeded(firstRun.Add(-2 * time.Hour)) require.Equal(t, 0, len(receiveChan)) }) t.Run("Send at the 24 hour mark and every 24 hours after", func(t *testing.T) { // firstRun is 24 hours ago and timestampLastTelemetrySent is 24 hours ago // need to do telemetry service.timestampLastTelemetrySent = time.Now().Add(-24 * time.Hour) service.doTelemetryIfNeeded(firstRun.Add(-24 * time.Hour)) checkMockRudderServer(t) // firstRun is 24 hours ago and timestampLastTelemetrySent is just now // no need to do telemetry service.doTelemetryIfNeeded(firstRun.Add(-24 * time.Hour)) require.Equal(t, 0, len(receiveChan)) }) }) } ================================================ FILE: server/services/webhook/webhook.go ================================================ package webhook import ( "bytes" "encoding/json" "io" "net/http" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // NotifyUpdate calls webhooks. func (wh *Client) NotifyUpdate(block *model.Block) { if len(wh.config.WebhookUpdate) < 1 { return } json, err := json.Marshal(block) if err != nil { wh.logger.Fatal("NotifyUpdate: json.Marshal", mlog.Err(err)) } for _, url := range wh.config.WebhookUpdate { resp, _ := http.Post(url, "application/json", bytes.NewBuffer(json)) //nolint:gosec _, _ = io.ReadAll(resp.Body) resp.Body.Close() wh.logger.Debug("webhook.NotifyUpdate", mlog.String("url", url)) } } // Client is a webhook client. type Client struct { config *config.Configuration logger mlog.LoggerIFace } // NewClient creates a new Client. func NewClient(config *config.Configuration, logger mlog.LoggerIFace) *Client { return &Client{ config: config, logger: logger, } } ================================================ FILE: server/services/webhook/webhook_test.go ================================================ package webhook import ( "net/http" "net/http/httptest" "testing" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/config" "github.com/stretchr/testify/assert" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func TestClientUpdateNotify(t *testing.T) { var isNotified bool ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { isNotified = true })) defer ts.Close() cfg := &config.Configuration{ WebhookUpdate: []string{ts.URL}, } logger, _ := mlog.NewLogger() defer func() { err := logger.Shutdown() assert.NoError(t, err) }() client := NewClient(cfg, logger) client.NotifyUpdate(&model.Block{}) if !isNotified { t.Error("webhook url not be notified") } } ================================================ FILE: server/swagger/README.md ================================================ # Swagger / OpenAPI 2.0 auto-generated files ⚠️ **Warning:** The API is currently considered Beta and major changes are planned. Please [see this note](https://github.com/mattermost/focalboard/discussions/2139) for more details. This folder is generated by the `make swagger` command from comments in the server code. Prerequisites: 1. [go-swagger](https://goswagger.io/install.html) 2. [openapi-generator](https://github.com/OpenAPITools/openapi-generator) These can be installed via Homebrew: ``` brew tap go-swagger/go-swagger brew install go-swagger brew install openapi-generator ``` # Server API documentation See the generated [server API documentation here](https://htmlpreview.github.io/?https://github.com/mattermost/focalboard/blob/main/server/swagger/docs/html/index.html). # How to authenticate To auth against Personal Server, first call login with your credentials to get a token, e.g. ``` curl -X POST \ -H "Accept: application/json" \ -H "X-Requested-With: XMLHttpRequest" \ -H "Content-Type: application/json" \ "http://localhost:8000/api/v2/login" \ -d '{ "type" : "normal", "username" : "testuser", "password" : "testpass" }' ``` This should return a token in the form: ``` {"token":"abcdefghijklmnopqrstuvwxyz1"} ``` Pass this as the bearer auth to subsequent calls, e.g. ``` curl -X GET \ -H "Accept: application/json" \ -H "Authorization: Bearer abcdefghijklmnopqrstuvwxyz1" \ -H "X-Requested-With: XMLHttpRequest" \ -H "Content-Type: application/json" \ "http://localhost:8000/api/v2/teams/0/boards" ``` # Differences for Mattermost Boards The auto-generated Swagger API documentation is for Focalboard Personal Server. If you are calling the API on Mattermost Boards, the additional changes are: ### API URLs endpoint The API endpoint is at `https://SERVERNAME/plugins/focalboard/api/`, e.g. `https://community.mattermost.com/plugins/focalboard/api/`. ### Use the Mattermost auth token Refer to the [Mattermost API documentation here](https://api.mattermost.com/#tag/authentication) on how to obtain the auth token. Pass this token as a bearer token to the Boards APIs, e.g. ``` curl -i -H "X-Requested-With: XMLHttpRequest" -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz' https://community.mattermost.com/plugins/focalboard/api/v2/workspaces ``` Note that the `X-Requested-With: XMLHttpRequest` header is required to pass the CSRF check. # We want to hear from you! If you are planning on using the Boards API, we would love to hear about what you'd like to do, and how we can improve the APIs in the future. [See here](https://github.com/mattermost/focalboard/discussions/2139) for more details on how to connect. ================================================ FILE: server/swagger/docs/html/.openapi-generator/VERSION ================================================ 6.2.1 ================================================ FILE: server/swagger/docs/html/.openapi-generator-ignore ================================================ # OpenAPI Generator Ignore # Generated by openapi-generator https://github.com/openapitools/openapi-generator # Use this file to prevent files from being overwritten by the generator. # The patterns follow closely to .gitignore or .dockerignore. # As an example, the C# client generator defines ApiClient.cs. # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: #ApiClient.cs # You can match any string of characters against a directory, file or extension with a single asterisk (*): #foo/*/qux # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux # You can recursively match patterns against a directory, file or extension with a double asterisk (**): #foo/**/qux # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux # You can also negate patterns with an exclamation (!). # For example, you can ignore all files in a docs folder with the file extension .md: #docs/*.md # Then explicitly reverse the ignore rule for a single file: #!docs/README.md ================================================ FILE: server/swagger/docs/html/index.html ================================================ Focalboard Server

Focalboard Server

Default

addMember

Adds a new member to a board


/boards/{boardID}/members

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}/members" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.addMember(boardID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#addMember");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.addMember(boardID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#addMember");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
Object *body = Object; // 

[apiInstance addMemberWith:boardID
    body:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.addMember(boardID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class addMemberExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.addMember(boardID, body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.addMember: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$body = Object; // Object | 

try {
    $result = $api_instance->addMember($boardID, $body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->addMember: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->addMember(boardID => $boardID, body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->addMember: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
body = Object # Object | 

try:
    api_response = api_instance.add_member(boardID, body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->addMember: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.addMember(boardID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
Body parameters
Name Description
body *

membership to replace the current one with

Responses


archiveExportBoard

Exports an archive of all blocks for one boards.


/boards/{boardID}/archive/export

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/archive/export"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Id of board to export

        try {
            apiInstance.archiveExportBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#archiveExportBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Id of board to export

        try {
            apiInstance.archiveExportBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#archiveExportBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Id of board to export (default to null)

// Exports an archive of all blocks for one boards.
[apiInstance archiveExportBoardWith:boardID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Id of board to export

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.archiveExportBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class archiveExportBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Id of board to export (default to null)

            try {
                // Exports an archive of all blocks for one boards.
                apiInstance.archiveExportBoard(boardID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.archiveExportBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Id of board to export

try {
    $api_instance->archiveExportBoard($boardID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->archiveExportBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Id of board to export

eval {
    $api_instance->archiveExportBoard(boardID => $boardID);
};
if ($@) {
    warn "Exception when calling DefaultApi->archiveExportBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Id of board to export (default to null)

try:
    # Exports an archive of all blocks for one boards.
    api_instance.archive_export_board(boardID)
except ApiException as e:
    print("Exception when calling DefaultApi->archiveExportBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.archiveExportBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Id of board to export
Required

Responses


archiveExportTeam

Exports an archive of all blocks for all the boards in a team.


/teams/{teamID}/archive/export

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/archive/export"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Id of team

        try {
            apiInstance.archiveExportTeam(teamID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#archiveExportTeam");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Id of team

        try {
            apiInstance.archiveExportTeam(teamID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#archiveExportTeam");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Id of team (default to null)

// Exports an archive of all blocks for all the boards in a team.
[apiInstance archiveExportTeamWith:teamID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Id of team

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.archiveExportTeam(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class archiveExportTeamExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Id of team (default to null)

            try {
                // Exports an archive of all blocks for all the boards in a team.
                apiInstance.archiveExportTeam(teamID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.archiveExportTeam: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Id of team

try {
    $api_instance->archiveExportTeam($teamID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->archiveExportTeam: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Id of team

eval {
    $api_instance->archiveExportTeam(teamID => $teamID);
};
if ($@) {
    warn "Exception when calling DefaultApi->archiveExportTeam: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Id of team (default to null)

try:
    # Exports an archive of all blocks for all the boards in a team.
    api_instance.archive_export_team(teamID)
except ApiException as e:
    print("Exception when calling DefaultApi->archiveExportTeam: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.archiveExportTeam(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Id of team
Required

Responses


archiveImport

Import an archive of boards.


/teams/{teamID}/archive/import

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: multipart/form-data" \
 "http://localhost/api/v2/teams/{teamID}/archive/import"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        File file = BINARY_DATA_HERE; // File | archive file to import

        try {
            apiInstance.archiveImport(teamID, file);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#archiveImport");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        File file = BINARY_DATA_HERE; // File | archive file to import

        try {
            apiInstance.archiveImport(teamID, file);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#archiveImport");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
File *file = BINARY_DATA_HERE; // archive file to import (default to null)

// Import an archive of boards.
[apiInstance archiveImportWith:teamID
    file:file
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var file = BINARY_DATA_HERE; // {File} archive file to import

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.archiveImport(teamID, file, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class archiveImportExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var file = BINARY_DATA_HERE;  // File | archive file to import (default to null)

            try {
                // Import an archive of boards.
                apiInstance.archiveImport(teamID, file);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.archiveImport: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$file = BINARY_DATA_HERE; // File | archive file to import

try {
    $api_instance->archiveImport($teamID, $file);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->archiveImport: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $file = BINARY_DATA_HERE; # File | archive file to import

eval {
    $api_instance->archiveImport(teamID => $teamID, file => $file);
};
if ($@) {
    warn "Exception when calling DefaultApi->archiveImport: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
file = BINARY_DATA_HERE # File | archive file to import (default to null)

try:
    # Import an archive of boards.
    api_instance.archive_import(teamID, file)
except ApiException as e:
    print("Exception when calling DefaultApi->archiveImport: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let file = BINARY_DATA_HERE; // File

    let mut context = DefaultApi::Context::default();
    let result = client.archiveImport(teamID, file, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Form parameters
Name Description
file*
File (binary)
archive file to import
Required

Responses


changePassword

Change a user's password


/users/{userID}/changepassword

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/users/{userID}/changepassword" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID
        Object body = Object; // Object | 

        try {
            apiInstance.changePassword(userID, body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#changePassword");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID
        Object body = Object; // Object | 

        try {
            apiInstance.changePassword(userID, body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#changePassword");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *userID = userID_example; // User ID (default to null)
Object *body = Object; // 

[apiInstance changePasswordWith:userID
    body:body
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var userID = userID_example; // {String} User ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.changePassword(userID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class changePasswordExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var userID = userID_example;  // String | User ID (default to null)
            var body = Object;  // Object | 

            try {
                apiInstance.changePassword(userID, body);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.changePassword: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$userID = userID_example; // String | User ID
$body = Object; // Object | 

try {
    $api_instance->changePassword($userID, $body);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->changePassword: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $userID = userID_example; # String | User ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    $api_instance->changePassword(userID => $userID, body => $body);
};
if ($@) {
    warn "Exception when calling DefaultApi->changePassword: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
userID = userID_example # String | User ID (default to null)
body = Object # Object | 

try:
    api_instance.change_password(userID, body)
except ApiException as e:
    print("Exception when calling DefaultApi->changePassword: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let userID = userID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.changePassword(userID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
userID*
String
User ID
Required
Body parameters
Name Description
body *

Change password request

Responses


cloudLimits

Fetches the cloud limits of the server.


/limits

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/limits"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            Object result = apiInstance.cloudLimits();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#cloudLimits");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            Object result = apiInstance.cloudLimits();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#cloudLimits");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

// Fetches the cloud limits of the server.
[apiInstance cloudLimitsWithCompletionHandler: 
              ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.cloudLimits(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class cloudLimitsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                // Fetches the cloud limits of the server.
                Object result = apiInstance.cloudLimits();
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.cloudLimits: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $result = $api_instance->cloudLimits();
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->cloudLimits: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    my $result = $api_instance->cloudLimits();
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->cloudLimits: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    # Fetches the cloud limits of the server.
    api_response = api_instance.cloud_limits()
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->cloudLimits: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.cloudLimits(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


createBoard

Creates a new board


/boards

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.createBoard(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.createBoard(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
Object *body = Object; // 

[apiInstance createBoardWith:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.createBoard(body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class createBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.createBoard(body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.createBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$body = Object; // Object | 

try {
    $result = $api_instance->createBoard($body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->createBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->createBoard(body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->createBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
body = Object # Object | 

try:
    api_response = api_instance.create_board(body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->createBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.createBoard(body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Body parameters
Name Description
body *

the board to create

Responses


createCard

Creates a new card for the specified board.


/boards/{boardID}/cards

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}/cards?disable_notify=" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk data inserting)

        try {
            Object result = apiInstance.createCard(boardID, body, disableNotify);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createCard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk data inserting)

        try {
            Object result = apiInstance.createCard(boardID, body, disableNotify);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createCard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
Object *body = Object; // 
oas_any_type_not_mapped *disableNotify = ; // Disables notifications (for bulk data inserting) (optional) (default to null)

// Creates a new card for the specified board.
[apiInstance createCardWith:boardID
    body:body
    disableNotify:disableNotify
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var body = Object; // {Object} 
var opts = {
  'disableNotify':  // {oas_any_type_not_mapped} Disables notifications (for bulk data inserting)
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.createCard(boardID, body, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class createCardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var body = Object;  // Object | 
            var disableNotify = new oas_any_type_not_mapped(); // oas_any_type_not_mapped | Disables notifications (for bulk data inserting) (optional)  (default to null)

            try {
                // Creates a new card for the specified board.
                Object result = apiInstance.createCard(boardID, body, disableNotify);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.createCard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$body = Object; // Object | 
$disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk data inserting)

try {
    $result = $api_instance->createCard($boardID, $body, $disableNotify);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->createCard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 
my $disableNotify = ; # oas_any_type_not_mapped | Disables notifications (for bulk data inserting)

eval {
    my $result = $api_instance->createCard(boardID => $boardID, body => $body, disableNotify => $disableNotify);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->createCard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
body = Object # Object | 
disableNotify =  # oas_any_type_not_mapped | Disables notifications (for bulk data inserting) (optional) (default to null)

try:
    # Creates a new card for the specified board.
    api_response = api_instance.create_card(boardID, body, disableNotify=disableNotify)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->createCard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let body = Object; // Object
    let disableNotify = ; // oas_any_type_not_mapped

    let mut context = DefaultApi::Context::default();
    let result = client.createCard(boardID, body, disableNotify, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
Body parameters
Name Description
body *

the card to create

Query parameters
Name Description
disable_notify
oas_any_type_not_mapped
Disables notifications (for bulk data inserting)

Responses


createCategory

Create a category for boards


/teams/{teamID}/categories

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/teams/{teamID}/categories" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.createCategory(teamID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createCategory");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.createCategory(teamID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createCategory");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
Object *body = Object; // 

[apiInstance createCategoryWith:teamID
    body:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.createCategory(teamID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class createCategoryExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.createCategory(teamID, body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.createCategory: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$body = Object; // Object | 

try {
    $result = $api_instance->createCategory($teamID, $body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->createCategory: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->createCategory(teamID => $teamID, body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->createCategory: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
body = Object # Object | 

try:
    api_response = api_instance.create_category(teamID, body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->createCategory: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.createCategory(teamID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Body parameters
Name Description
body *

category to create

Responses


createSubscription

Creates a subscription to a block for a user. The user will receive change notifications for the block.


/subscriptions

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/subscriptions" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.createSubscription(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createSubscription");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.createSubscription(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#createSubscription");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
Object *body = Object; // 

// Creates a subscription to a block for a user. The user will receive change notifications for the block.
[apiInstance createSubscriptionWith:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.createSubscription(body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class createSubscriptionExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var body = Object;  // Object | 

            try {
                // Creates a subscription to a block for a user. The user will receive change notifications for the block.
                Object result = apiInstance.createSubscription(body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.createSubscription: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$body = Object; // Object | 

try {
    $result = $api_instance->createSubscription($body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->createSubscription: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->createSubscription(body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->createSubscription: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
body = Object # Object | 

try:
    # Creates a subscription to a block for a user. The user will receive change notifications for the block.
    api_response = api_instance.create_subscription(body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->createSubscription: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.createSubscription(body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Body parameters
Name Description
body *

subscription definition

Responses


deleteBlock

Deletes a block


/boards/{boardID}/blocks/{blockID}

Usage and SDK Samples

curl -X DELETE \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/blocks/{blockID}?disable_notify="
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | ID of block to delete
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk deletion)

        try {
            apiInstance.deleteBlock(boardID, blockID, disableNotify);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteBlock");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | ID of block to delete
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk deletion)

        try {
            apiInstance.deleteBlock(boardID, blockID, disableNotify);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteBlock");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
String *blockID = blockID_example; // ID of block to delete (default to null)
oas_any_type_not_mapped *disableNotify = ; // Disables notifications (for bulk deletion) (optional) (default to null)

[apiInstance deleteBlockWith:boardID
    blockID:blockID
    disableNotify:disableNotify
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var blockID = blockID_example; // {String} ID of block to delete
var opts = {
  'disableNotify':  // {oas_any_type_not_mapped} Disables notifications (for bulk deletion)
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.deleteBlock(boardID, blockID, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class deleteBlockExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var blockID = blockID_example;  // String | ID of block to delete (default to null)
            var disableNotify = new oas_any_type_not_mapped(); // oas_any_type_not_mapped | Disables notifications (for bulk deletion) (optional)  (default to null)

            try {
                apiInstance.deleteBlock(boardID, blockID, disableNotify);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.deleteBlock: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$blockID = blockID_example; // String | ID of block to delete
$disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk deletion)

try {
    $api_instance->deleteBlock($boardID, $blockID, $disableNotify);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->deleteBlock: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $blockID = blockID_example; # String | ID of block to delete
my $disableNotify = ; # oas_any_type_not_mapped | Disables notifications (for bulk deletion)

eval {
    $api_instance->deleteBlock(boardID => $boardID, blockID => $blockID, disableNotify => $disableNotify);
};
if ($@) {
    warn "Exception when calling DefaultApi->deleteBlock: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
blockID = blockID_example # String | ID of block to delete (default to null)
disableNotify =  # oas_any_type_not_mapped | Disables notifications (for bulk deletion) (optional) (default to null)

try:
    api_instance.delete_block(boardID, blockID, disableNotify=disableNotify)
except ApiException as e:
    print("Exception when calling DefaultApi->deleteBlock: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let blockID = blockID_example; // String
    let disableNotify = ; // oas_any_type_not_mapped

    let mut context = DefaultApi::Context::default();
    let result = client.deleteBlock(boardID, blockID, disableNotify, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
blockID*
String
ID of block to delete
Required
Query parameters
Name Description
disable_notify
oas_any_type_not_mapped
Disables notifications (for bulk deletion)

Responses


deleteBoard

Removes a board


/boards/{boardID}

Usage and SDK Samples

curl -X DELETE \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            apiInstance.deleteBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            apiInstance.deleteBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance deleteBoardWith:boardID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.deleteBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class deleteBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                apiInstance.deleteBoard(boardID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.deleteBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $api_instance->deleteBoard($boardID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->deleteBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    $api_instance->deleteBoard(boardID => $boardID);
};
if ($@) {
    warn "Exception when calling DefaultApi->deleteBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_instance.delete_board(boardID)
except ApiException as e:
    print("Exception when calling DefaultApi->deleteBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.deleteBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


deleteBoardsAndBlocks

Deletes boards and blocks


/boards-and-blocks

Usage and SDK Samples

curl -X DELETE \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards-and-blocks" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            apiInstance.deleteBoardsAndBlocks(body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteBoardsAndBlocks");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            apiInstance.deleteBoardsAndBlocks(body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteBoardsAndBlocks");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
Object *body = Object; // 

[apiInstance deleteBoardsAndBlocksWith:body
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.deleteBoardsAndBlocks(body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class deleteBoardsAndBlocksExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var body = Object;  // Object | 

            try {
                apiInstance.deleteBoardsAndBlocks(body);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.deleteBoardsAndBlocks: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$body = Object; // Object | 

try {
    $api_instance->deleteBoardsAndBlocks($body);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->deleteBoardsAndBlocks: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    $api_instance->deleteBoardsAndBlocks(body => $body);
};
if ($@) {
    warn "Exception when calling DefaultApi->deleteBoardsAndBlocks: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
body = Object # Object | 

try:
    api_instance.delete_boards_and_blocks(body)
except ApiException as e:
    print("Exception when calling DefaultApi->deleteBoardsAndBlocks: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.deleteBoardsAndBlocks(body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Body parameters
Name Description
body *

the boards and blocks to delete

Responses


deleteCategory

Delete a category


/teams/{teamID}/categories/{categoryID}

Usage and SDK Samples

curl -X DELETE \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/categories/{categoryID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String categoryID = categoryID_example; // String | Category ID

        try {
            apiInstance.deleteCategory(teamID, categoryID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteCategory");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String categoryID = categoryID_example; // String | Category ID

        try {
            apiInstance.deleteCategory(teamID, categoryID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteCategory");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *categoryID = categoryID_example; // Category ID (default to null)

[apiInstance deleteCategoryWith:teamID
    categoryID:categoryID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var categoryID = categoryID_example; // {String} Category ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.deleteCategory(teamID, categoryID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class deleteCategoryExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var categoryID = categoryID_example;  // String | Category ID (default to null)

            try {
                apiInstance.deleteCategory(teamID, categoryID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.deleteCategory: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$categoryID = categoryID_example; // String | Category ID

try {
    $api_instance->deleteCategory($teamID, $categoryID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->deleteCategory: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $categoryID = categoryID_example; # String | Category ID

eval {
    $api_instance->deleteCategory(teamID => $teamID, categoryID => $categoryID);
};
if ($@) {
    warn "Exception when calling DefaultApi->deleteCategory: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
categoryID = categoryID_example # String | Category ID (default to null)

try:
    api_instance.delete_category(teamID, categoryID)
except ApiException as e:
    print("Exception when calling DefaultApi->deleteCategory: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let categoryID = categoryID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.deleteCategory(teamID, categoryID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
categoryID*
String
Category ID
Required

Responses


deleteMember

Deletes a member from a board


/boards/{boardID}/members/{userID}

Usage and SDK Samples

curl -X DELETE \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/members/{userID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String userID = userID_example; // String | User ID

        try {
            apiInstance.deleteMember(boardID, userID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteMember");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String userID = userID_example; // String | User ID

        try {
            apiInstance.deleteMember(boardID, userID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteMember");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
String *userID = userID_example; // User ID (default to null)

[apiInstance deleteMemberWith:boardID
    userID:userID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var userID = userID_example; // {String} User ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.deleteMember(boardID, userID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class deleteMemberExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var userID = userID_example;  // String | User ID (default to null)

            try {
                apiInstance.deleteMember(boardID, userID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.deleteMember: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$userID = userID_example; // String | User ID

try {
    $api_instance->deleteMember($boardID, $userID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->deleteMember: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $userID = userID_example; # String | User ID

eval {
    $api_instance->deleteMember(boardID => $boardID, userID => $userID);
};
if ($@) {
    warn "Exception when calling DefaultApi->deleteMember: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
userID = userID_example # String | User ID (default to null)

try:
    api_instance.delete_member(boardID, userID)
except ApiException as e:
    print("Exception when calling DefaultApi->deleteMember: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let userID = userID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.deleteMember(boardID, userID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
userID*
String
User ID
Required

Responses


deleteSubscription

Deletes a subscription a user has for a a block. The user will no longer receive change notifications for the block.


/subscriptions/{blockID}/{subscriberID}

Usage and SDK Samples

curl -X DELETE \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/subscriptions/{blockID}/{subscriberID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String blockID = blockID_example; // String | Block ID
        String subscriberID = subscriberID_example; // String | Subscriber ID

        try {
            apiInstance.deleteSubscription(blockID, subscriberID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteSubscription");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String blockID = blockID_example; // String | Block ID
        String subscriberID = subscriberID_example; // String | Subscriber ID

        try {
            apiInstance.deleteSubscription(blockID, subscriberID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#deleteSubscription");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *blockID = blockID_example; // Block ID (default to null)
String *subscriberID = subscriberID_example; // Subscriber ID (default to null)

// Deletes a subscription a user has for a a block. The user will no longer receive change notifications for the block.
[apiInstance deleteSubscriptionWith:blockID
    subscriberID:subscriberID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var blockID = blockID_example; // {String} Block ID
var subscriberID = subscriberID_example; // {String} Subscriber ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.deleteSubscription(blockID, subscriberID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class deleteSubscriptionExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var blockID = blockID_example;  // String | Block ID (default to null)
            var subscriberID = subscriberID_example;  // String | Subscriber ID (default to null)

            try {
                // Deletes a subscription a user has for a a block. The user will no longer receive change notifications for the block.
                apiInstance.deleteSubscription(blockID, subscriberID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.deleteSubscription: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$blockID = blockID_example; // String | Block ID
$subscriberID = subscriberID_example; // String | Subscriber ID

try {
    $api_instance->deleteSubscription($blockID, $subscriberID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->deleteSubscription: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $blockID = blockID_example; # String | Block ID
my $subscriberID = subscriberID_example; # String | Subscriber ID

eval {
    $api_instance->deleteSubscription(blockID => $blockID, subscriberID => $subscriberID);
};
if ($@) {
    warn "Exception when calling DefaultApi->deleteSubscription: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
blockID = blockID_example # String | Block ID (default to null)
subscriberID = subscriberID_example # String | Subscriber ID (default to null)

try:
    # Deletes a subscription a user has for a a block. The user will no longer receive change notifications for the block.
    api_instance.delete_subscription(blockID, subscriberID)
except ApiException as e:
    print("Exception when calling DefaultApi->deleteSubscription: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let blockID = blockID_example; // String
    let subscriberID = subscriberID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.deleteSubscription(blockID, subscriberID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
blockID*
String
Block ID
Required
subscriberID*
String
Subscriber ID
Required

Responses


duplicateBlock

Returns the new created blocks


/boards/{boardID}/blocks/{blockID}/duplicate

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/blocks/{blockID}/duplicate"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | Block ID

        try {
            array[Object] result = apiInstance.duplicateBlock(boardID, blockID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#duplicateBlock");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | Block ID

        try {
            array[Object] result = apiInstance.duplicateBlock(boardID, blockID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#duplicateBlock");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
String *blockID = blockID_example; // Block ID (default to null)

[apiInstance duplicateBlockWith:boardID
    blockID:blockID
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var blockID = blockID_example; // {String} Block ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.duplicateBlock(boardID, blockID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class duplicateBlockExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var blockID = blockID_example;  // String | Block ID (default to null)

            try {
                array[Object] result = apiInstance.duplicateBlock(boardID, blockID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.duplicateBlock: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$blockID = blockID_example; // String | Block ID

try {
    $result = $api_instance->duplicateBlock($boardID, $blockID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->duplicateBlock: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $blockID = blockID_example; # String | Block ID

eval {
    my $result = $api_instance->duplicateBlock(boardID => $boardID, blockID => $blockID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->duplicateBlock: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
blockID = blockID_example # String | Block ID (default to null)

try:
    api_response = api_instance.duplicate_block(boardID, blockID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->duplicateBlock: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let blockID = blockID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.duplicateBlock(boardID, blockID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
blockID*
String
Block ID
Required

Responses


duplicateBoard

Returns the new created board and all the blocks


/boards/{boardID}/duplicate

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/duplicate"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.duplicateBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#duplicateBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.duplicateBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#duplicateBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance duplicateBoardWith:boardID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.duplicateBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class duplicateBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                Object result = apiInstance.duplicateBoard(boardID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.duplicateBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $result = $api_instance->duplicateBoard($boardID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->duplicateBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    my $result = $api_instance->duplicateBoard(boardID => $boardID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->duplicateBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_response = api_instance.duplicate_board(boardID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->duplicateBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.duplicateBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


getBlocks

Returns blocks


/boards/{boardID}/blocks

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/blocks?parent_id=parentId_example&type=type_example"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String parentId = parentId_example; // String | ID of parent block, omit to specify all blocks
        String type = type_example; // String | Type of blocks to return, omit to specify all types

        try {
            array[Object] result = apiInstance.getBlocks(boardID, parentId, type);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBlocks");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String parentId = parentId_example; // String | ID of parent block, omit to specify all blocks
        String type = type_example; // String | Type of blocks to return, omit to specify all types

        try {
            array[Object] result = apiInstance.getBlocks(boardID, parentId, type);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBlocks");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
String *parentId = parentId_example; // ID of parent block, omit to specify all blocks (optional) (default to null)
String *type = type_example; // Type of blocks to return, omit to specify all types (optional) (default to null)

[apiInstance getBlocksWith:boardID
    parentId:parentId
    type:type
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var opts = {
  'parentId': parentId_example, // {String} ID of parent block, omit to specify all blocks
  'type': type_example // {String} Type of blocks to return, omit to specify all types
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getBlocks(boardID, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getBlocksExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var parentId = parentId_example;  // String | ID of parent block, omit to specify all blocks (optional)  (default to null)
            var type = type_example;  // String | Type of blocks to return, omit to specify all types (optional)  (default to null)

            try {
                array[Object] result = apiInstance.getBlocks(boardID, parentId, type);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getBlocks: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$parentId = parentId_example; // String | ID of parent block, omit to specify all blocks
$type = type_example; // String | Type of blocks to return, omit to specify all types

try {
    $result = $api_instance->getBlocks($boardID, $parentId, $type);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getBlocks: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $parentId = parentId_example; # String | ID of parent block, omit to specify all blocks
my $type = type_example; # String | Type of blocks to return, omit to specify all types

eval {
    my $result = $api_instance->getBlocks(boardID => $boardID, parentId => $parentId, type => $type);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getBlocks: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
parentId = parentId_example # String | ID of parent block, omit to specify all blocks (optional) (default to null)
type = type_example # String | Type of blocks to return, omit to specify all types (optional) (default to null)

try:
    api_response = api_instance.get_blocks(boardID, parentId=parentId, type=type)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getBlocks: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let parentId = parentId_example; // String
    let type = type_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getBlocks(boardID, parentId, type, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
Query parameters
Name Description
parent_id
String
ID of parent block, omit to specify all blocks
type
String
Type of blocks to return, omit to specify all types

Responses


getBoard

Returns a board


/boards/{boardID}

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.getBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.getBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance getBoardWith:boardID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                Object result = apiInstance.getBoard(boardID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $result = $api_instance->getBoard($boardID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    my $result = $api_instance->getBoard(boardID => $boardID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_response = api_instance.get_board(boardID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


getBoardMetadata

Returns a board's metadata


/boards/{boardID}/metadata

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/metadata"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.getBoardMetadata(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBoardMetadata");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.getBoardMetadata(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBoardMetadata");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance getBoardMetadataWith:boardID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getBoardMetadata(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getBoardMetadataExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                Object result = apiInstance.getBoardMetadata(boardID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getBoardMetadata: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $result = $api_instance->getBoardMetadata($boardID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getBoardMetadata: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    my $result = $api_instance->getBoardMetadata(boardID => $boardID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getBoardMetadata: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_response = api_instance.get_board_metadata(boardID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getBoardMetadata: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getBoardMetadata(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


getBoards

Returns team boards


/teams/{teamID}/boards

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/boards"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            array[Object] result = apiInstance.getBoards(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBoards");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            array[Object] result = apiInstance.getBoards(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getBoards");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)

[apiInstance getBoardsWith:teamID
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getBoards(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getBoardsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)

            try {
                array[Object] result = apiInstance.getBoards(teamID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getBoards: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID

try {
    $result = $api_instance->getBoards($teamID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getBoards: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID

eval {
    my $result = $api_instance->getBoards(teamID => $teamID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getBoards: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)

try:
    api_response = api_instance.get_boards(teamID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getBoards: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getBoards(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required

Responses


getCard

Fetches the specified card.


/cards/{cardID}

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/cards/{cardID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String cardID = cardID_example; // String | Card ID

        try {
            Object result = apiInstance.getCard(cardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getCard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String cardID = cardID_example; // String | Card ID

        try {
            Object result = apiInstance.getCard(cardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getCard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *cardID = cardID_example; // Card ID (default to null)

// Fetches the specified card.
[apiInstance getCardWith:cardID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var cardID = cardID_example; // {String} Card ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getCard(cardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getCardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var cardID = cardID_example;  // String | Card ID (default to null)

            try {
                // Fetches the specified card.
                Object result = apiInstance.getCard(cardID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getCard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$cardID = cardID_example; // String | Card ID

try {
    $result = $api_instance->getCard($cardID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getCard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $cardID = cardID_example; # String | Card ID

eval {
    my $result = $api_instance->getCard(cardID => $cardID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getCard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
cardID = cardID_example # String | Card ID (default to null)

try:
    # Fetches the specified card.
    api_response = api_instance.get_card(cardID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getCard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let cardID = cardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getCard(cardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
cardID*
String
Card ID
Required

Responses


getCards

Fetches cards for the specified board.


/boards/{boardID}/cards

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/cards?page=56&per_page=56"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Integer page = 56; // Integer | The page to select (default=0)
        Integer perPage = 56; // Integer | Number of cards to return per page(default=100)

        try {
            array[Object] result = apiInstance.getCards(boardID, page, perPage);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getCards");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Integer page = 56; // Integer | The page to select (default=0)
        Integer perPage = 56; // Integer | Number of cards to return per page(default=100)

        try {
            array[Object] result = apiInstance.getCards(boardID, page, perPage);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getCards");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
Integer *page = 56; // The page to select (default=0) (optional) (default to null)
Integer *perPage = 56; // Number of cards to return per page(default=100) (optional) (default to null)

// Fetches cards for the specified board.
[apiInstance getCardsWith:boardID
    page:page
    perPage:perPage
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var opts = {
  'page': 56, // {Integer} The page to select (default=0)
  'perPage': 56 // {Integer} Number of cards to return per page(default=100)
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getCards(boardID, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getCardsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var page = 56;  // Integer | The page to select (default=0) (optional)  (default to null)
            var perPage = 56;  // Integer | Number of cards to return per page(default=100) (optional)  (default to null)

            try {
                // Fetches cards for the specified board.
                array[Object] result = apiInstance.getCards(boardID, page, perPage);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getCards: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$page = 56; // Integer | The page to select (default=0)
$perPage = 56; // Integer | Number of cards to return per page(default=100)

try {
    $result = $api_instance->getCards($boardID, $page, $perPage);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getCards: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $page = 56; # Integer | The page to select (default=0)
my $perPage = 56; # Integer | Number of cards to return per page(default=100)

eval {
    my $result = $api_instance->getCards(boardID => $boardID, page => $page, perPage => $perPage);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getCards: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
page = 56 # Integer | The page to select (default=0) (optional) (default to null)
perPage = 56 # Integer | Number of cards to return per page(default=100) (optional) (default to null)

try:
    # Fetches cards for the specified board.
    api_response = api_instance.get_cards(boardID, page=page, perPage=perPage)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getCards: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let page = 56; // Integer
    let perPage = 56; // Integer

    let mut context = DefaultApi::Context::default();
    let result = client.getCards(boardID, page, perPage, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
Query parameters
Name Description
page
Integer
The page to select (default=0)
per_page
Integer
Number of cards to return per page(default=100)

Responses


getChannel

Returns the requested channel


/teams/{teamID}/channels/{channelID}

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/channels/{channelID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String channelID = channelID_example; // String | Channel ID

        try {
            array[Channel] result = apiInstance.getChannel(teamID, channelID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getChannel");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String channelID = channelID_example; // String | Channel ID

        try {
            array[Channel] result = apiInstance.getChannel(teamID, channelID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getChannel");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *channelID = channelID_example; // Channel ID (default to null)

[apiInstance getChannelWith:teamID
    channelID:channelID
              completionHandler: ^(array[Channel] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var channelID = channelID_example; // {String} Channel ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getChannel(teamID, channelID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getChannelExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var channelID = channelID_example;  // String | Channel ID (default to null)

            try {
                array[Channel] result = apiInstance.getChannel(teamID, channelID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getChannel: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$channelID = channelID_example; // String | Channel ID

try {
    $result = $api_instance->getChannel($teamID, $channelID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getChannel: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $channelID = channelID_example; # String | Channel ID

eval {
    my $result = $api_instance->getChannel(teamID => $teamID, channelID => $channelID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getChannel: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
channelID = channelID_example # String | Channel ID (default to null)

try:
    api_response = api_instance.get_channel(teamID, channelID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getChannel: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let channelID = channelID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getChannel(teamID, channelID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
channelID*
String
Channel ID
Required

Responses


getClientConfig

Returns the client configuration


/clientConfig

Usage and SDK Samples

curl -X GET \
 -H "Accept: application/json" \
 "http://localhost/api/v2/clientConfig"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            Object result = apiInstance.getClientConfig();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getClientConfig");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            Object result = apiInstance.getClientConfig();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getClientConfig");
            e.printStackTrace();
        }
    }
}


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

[apiInstance getClientConfigWithCompletionHandler: 
              ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getClientConfig(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getClientConfigExample
    {
        public void main()
        {

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                Object result = apiInstance.getClientConfig();
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getClientConfig: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $result = $api_instance->getClientConfig();
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getClientConfig: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    my $result = $api_instance->getClientConfig();
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getClientConfig: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    api_response = api_instance.get_client_config()
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getClientConfig: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.getClientConfig(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


getFile

Returns the contents of an uploaded file


/files/teams/{teamID}/{boardID}/{filename}

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json,image/jpg,image/png,image/gif" \
 "http://localhost/api/v2/files/teams/{teamID}/{boardID}/{filename}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String boardID = boardID_example; // String | Board ID
        String filename = filename_example; // String | name of the file

        try {
            apiInstance.getFile(teamID, boardID, filename);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getFile");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String boardID = boardID_example; // String | Board ID
        String filename = filename_example; // String | name of the file

        try {
            apiInstance.getFile(teamID, boardID, filename);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getFile");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *boardID = boardID_example; // Board ID (default to null)
String *filename = filename_example; // name of the file (default to null)

[apiInstance getFileWith:teamID
    boardID:boardID
    filename:filename
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var boardID = boardID_example; // {String} Board ID
var filename = filename_example; // {String} name of the file

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.getFile(teamID, boardID, filename, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getFileExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var boardID = boardID_example;  // String | Board ID (default to null)
            var filename = filename_example;  // String | name of the file (default to null)

            try {
                apiInstance.getFile(teamID, boardID, filename);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getFile: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$boardID = boardID_example; // String | Board ID
$filename = filename_example; // String | name of the file

try {
    $api_instance->getFile($teamID, $boardID, $filename);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getFile: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $boardID = boardID_example; # String | Board ID
my $filename = filename_example; # String | name of the file

eval {
    $api_instance->getFile(teamID => $teamID, boardID => $boardID, filename => $filename);
};
if ($@) {
    warn "Exception when calling DefaultApi->getFile: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
boardID = boardID_example # String | Board ID (default to null)
filename = filename_example # String | name of the file (default to null)

try:
    api_instance.get_file(teamID, boardID, filename)
except ApiException as e:
    print("Exception when calling DefaultApi->getFile: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let boardID = boardID_example; // String
    let filename = filename_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getFile(teamID, boardID, filename, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
boardID*
String
Board ID
Required
filename*
String
name of the file
Required

Responses


getMe

Returns the currently logged-in user


/users/me

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/users/me"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            Object result = apiInstance.getMe();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getMe");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            Object result = apiInstance.getMe();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getMe");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

[apiInstance getMeWithCompletionHandler: 
              ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getMe(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getMeExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                Object result = apiInstance.getMe();
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getMe: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $result = $api_instance->getMe();
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getMe: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    my $result = $api_instance->getMe();
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getMe: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    api_response = api_instance.get_me()
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getMe: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.getMe(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


getMembersForBoard

Returns the members of the board


/boards/{boardID}/members

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/members"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            array[Object] result = apiInstance.getMembersForBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getMembersForBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            array[Object] result = apiInstance.getMembersForBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getMembersForBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance getMembersForBoardWith:boardID
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getMembersForBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getMembersForBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                array[Object] result = apiInstance.getMembersForBoard(boardID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getMembersForBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $result = $api_instance->getMembersForBoard($boardID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getMembersForBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    my $result = $api_instance->getMembersForBoard(boardID => $boardID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getMembersForBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_response = api_instance.get_members_for_board(boardID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getMembersForBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getMembersForBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


getMyMemberships

Returns the currently users board memberships


/users/me/memberships

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/users/me/memberships"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            array[Object] result = apiInstance.getMyMemberships();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getMyMemberships");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            array[Object] result = apiInstance.getMyMemberships();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getMyMemberships");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

[apiInstance getMyMembershipsWithCompletionHandler: 
              ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getMyMemberships(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getMyMembershipsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                array[Object] result = apiInstance.getMyMemberships();
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getMyMemberships: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $result = $api_instance->getMyMemberships();
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getMyMemberships: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    my $result = $api_instance->getMyMemberships();
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getMyMemberships: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    api_response = api_instance.get_my_memberships()
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getMyMemberships: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.getMyMemberships(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


getSharing

Returns sharing information for a board


/boards/{boardID}/sharing

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/sharing"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.getSharing(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getSharing");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.getSharing(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getSharing");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance getSharingWith:boardID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getSharing(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getSharingExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                Object result = apiInstance.getSharing(boardID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getSharing: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $result = $api_instance->getSharing($boardID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getSharing: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    my $result = $api_instance->getSharing(boardID => $boardID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getSharing: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_response = api_instance.get_sharing(boardID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getSharing: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getSharing(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


getSubscriptions

Gets subscriptions for a user.


/subscriptions/{subscriberID}

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/subscriptions/{subscriberID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String subscriberID = subscriberID_example; // String | Subscriber ID

        try {
            array[Object] result = apiInstance.getSubscriptions(subscriberID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getSubscriptions");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String subscriberID = subscriberID_example; // String | Subscriber ID

        try {
            array[Object] result = apiInstance.getSubscriptions(subscriberID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getSubscriptions");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *subscriberID = subscriberID_example; // Subscriber ID (default to null)

// Gets subscriptions for a user.
[apiInstance getSubscriptionsWith:subscriberID
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var subscriberID = subscriberID_example; // {String} Subscriber ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getSubscriptions(subscriberID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getSubscriptionsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var subscriberID = subscriberID_example;  // String | Subscriber ID (default to null)

            try {
                // Gets subscriptions for a user.
                array[Object] result = apiInstance.getSubscriptions(subscriberID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getSubscriptions: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$subscriberID = subscriberID_example; // String | Subscriber ID

try {
    $result = $api_instance->getSubscriptions($subscriberID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getSubscriptions: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $subscriberID = subscriberID_example; # String | Subscriber ID

eval {
    my $result = $api_instance->getSubscriptions(subscriberID => $subscriberID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getSubscriptions: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
subscriberID = subscriberID_example # String | Subscriber ID (default to null)

try:
    # Gets subscriptions for a user.
    api_response = api_instance.get_subscriptions(subscriberID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getSubscriptions: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let subscriberID = subscriberID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getSubscriptions(subscriberID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
subscriberID*
String
Subscriber ID
Required

Responses


getTeam

Returns information of the root team


/teams/{teamID}

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            Object result = apiInstance.getTeam(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTeam");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            Object result = apiInstance.getTeam(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTeam");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)

[apiInstance getTeamWith:teamID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getTeam(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getTeamExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)

            try {
                Object result = apiInstance.getTeam(teamID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getTeam: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID

try {
    $result = $api_instance->getTeam($teamID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getTeam: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID

eval {
    my $result = $api_instance->getTeam(teamID => $teamID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getTeam: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)

try:
    api_response = api_instance.get_team(teamID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getTeam: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getTeam(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required

Responses


getTeamUsers

Returns team users


/teams/{teamID}/users

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/users?search=search_example&exclude_bots=true"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String search = search_example; // String | string to filter users list
        Boolean excludeBots = true; // Boolean | exclude bot users

        try {
            array[Object] result = apiInstance.getTeamUsers(teamID, search, excludeBots);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTeamUsers");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String search = search_example; // String | string to filter users list
        Boolean excludeBots = true; // Boolean | exclude bot users

        try {
            array[Object] result = apiInstance.getTeamUsers(teamID, search, excludeBots);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTeamUsers");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *search = search_example; // string to filter users list (optional) (default to null)
Boolean *excludeBots = true; // exclude bot users (optional) (default to null)

[apiInstance getTeamUsersWith:teamID
    search:search
    excludeBots:excludeBots
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var opts = {
  'search': search_example, // {String} string to filter users list
  'excludeBots': true // {Boolean} exclude bot users
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getTeamUsers(teamID, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getTeamUsersExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var search = search_example;  // String | string to filter users list (optional)  (default to null)
            var excludeBots = true;  // Boolean | exclude bot users (optional)  (default to null)

            try {
                array[Object] result = apiInstance.getTeamUsers(teamID, search, excludeBots);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getTeamUsers: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$search = search_example; // String | string to filter users list
$excludeBots = true; // Boolean | exclude bot users

try {
    $result = $api_instance->getTeamUsers($teamID, $search, $excludeBots);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getTeamUsers: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $search = search_example; # String | string to filter users list
my $excludeBots = true; # Boolean | exclude bot users

eval {
    my $result = $api_instance->getTeamUsers(teamID => $teamID, search => $search, excludeBots => $excludeBots);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getTeamUsers: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
search = search_example # String | string to filter users list (optional) (default to null)
excludeBots = true # Boolean | exclude bot users (optional) (default to null)

try:
    api_response = api_instance.get_team_users(teamID, search=search, excludeBots=excludeBots)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getTeamUsers: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let search = search_example; // String
    let excludeBots = true; // Boolean

    let mut context = DefaultApi::Context::default();
    let result = client.getTeamUsers(teamID, search, excludeBots, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Query parameters
Name Description
search
exclude_bots
Boolean
exclude bot users

Responses


getTeams

Returns information of all the teams


/teams

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            array[Object] result = apiInstance.getTeams();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTeams");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            array[Object] result = apiInstance.getTeams();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTeams");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

[apiInstance getTeamsWithCompletionHandler: 
              ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getTeams(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getTeamsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                array[Object] result = apiInstance.getTeams();
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getTeams: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $result = $api_instance->getTeams();
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getTeams: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    my $result = $api_instance->getTeams();
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getTeams: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    api_response = api_instance.get_teams()
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getTeams: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.getTeams(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


getTemplates

Returns team templates


/teams/{teamID}/templates

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/templates"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            array[Object] result = apiInstance.getTemplates(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTemplates");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            array[Object] result = apiInstance.getTemplates(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getTemplates");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)

[apiInstance getTemplatesWith:teamID
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getTemplates(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getTemplatesExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)

            try {
                array[Object] result = apiInstance.getTemplates(teamID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getTemplates: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID

try {
    $result = $api_instance->getTemplates($teamID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getTemplates: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID

eval {
    my $result = $api_instance->getTemplates(teamID => $teamID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getTemplates: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)

try:
    api_response = api_instance.get_templates(teamID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getTemplates: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getTemplates(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required

Responses


getUser

Returns a user


/users/{userID}

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/users/{userID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID

        try {
            Object result = apiInstance.getUser(userID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUser");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID

        try {
            Object result = apiInstance.getUser(userID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUser");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *userID = userID_example; // User ID (default to null)

[apiInstance getUserWith:userID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var userID = userID_example; // {String} User ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getUser(userID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getUserExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var userID = userID_example;  // String | User ID (default to null)

            try {
                Object result = apiInstance.getUser(userID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getUser: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$userID = userID_example; // String | User ID

try {
    $result = $api_instance->getUser($userID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getUser: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $userID = userID_example; # String | User ID

eval {
    my $result = $api_instance->getUser(userID => $userID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getUser: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
userID = userID_example # String | User ID (default to null)

try:
    api_response = api_instance.get_user(userID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getUser: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let userID = userID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getUser(userID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
userID*
String
User ID
Required

Responses


getUserBoardsInsights

Returns user boards insights


/users/me/boards/insights

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/users/me/boards/insights?time_range=timeRange_example&page=page_example&per_page=perPage_example"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String timeRange = timeRange_example; // String | duration of data to calculate insights for
        String page = page_example; // String | page offset for top boards
        String perPage = perPage_example; // String | limit for boards in a page.

        try {
            array[Object] result = apiInstance.getUserBoardsInsights(teamID, timeRange, page, perPage);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUserBoardsInsights");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String timeRange = timeRange_example; // String | duration of data to calculate insights for
        String page = page_example; // String | page offset for top boards
        String perPage = perPage_example; // String | limit for boards in a page.

        try {
            array[Object] result = apiInstance.getUserBoardsInsights(teamID, timeRange, page, perPage);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUserBoardsInsights");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *timeRange = timeRange_example; // duration of data to calculate insights for (default to null)
String *page = page_example; // page offset for top boards (default to null)
String *perPage = perPage_example; // limit for boards in a page. (default to null)

[apiInstance getUserBoardsInsightsWith:teamID
    timeRange:timeRange
    page:page
    perPage:perPage
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var timeRange = timeRange_example; // {String} duration of data to calculate insights for
var page = page_example; // {String} page offset for top boards
var perPage = perPage_example; // {String} limit for boards in a page.

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getUserBoardsInsights(teamID, timeRange, page, perPage, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getUserBoardsInsightsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var timeRange = timeRange_example;  // String | duration of data to calculate insights for (default to null)
            var page = page_example;  // String | page offset for top boards (default to null)
            var perPage = perPage_example;  // String | limit for boards in a page. (default to null)

            try {
                array[Object] result = apiInstance.getUserBoardsInsights(teamID, timeRange, page, perPage);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getUserBoardsInsights: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$timeRange = timeRange_example; // String | duration of data to calculate insights for
$page = page_example; // String | page offset for top boards
$perPage = perPage_example; // String | limit for boards in a page.

try {
    $result = $api_instance->getUserBoardsInsights($teamID, $timeRange, $page, $perPage);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getUserBoardsInsights: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $timeRange = timeRange_example; # String | duration of data to calculate insights for
my $page = page_example; # String | page offset for top boards
my $perPage = perPage_example; # String | limit for boards in a page.

eval {
    my $result = $api_instance->getUserBoardsInsights(teamID => $teamID, timeRange => $timeRange, page => $page, perPage => $perPage);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getUserBoardsInsights: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
timeRange = timeRange_example # String | duration of data to calculate insights for (default to null)
page = page_example # String | page offset for top boards (default to null)
perPage = perPage_example # String | limit for boards in a page. (default to null)

try:
    api_response = api_instance.get_user_boards_insights(teamID, timeRange, page, perPage)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getUserBoardsInsights: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let timeRange = timeRange_example; // String
    let page = page_example; // String
    let perPage = perPage_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getUserBoardsInsights(teamID, timeRange, page, perPage, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Query parameters
Name Description
time_range*
String
duration of data to calculate insights for
Required
page*
String
page offset for top boards
Required
per_page*
String
limit for boards in a page.
Required

Responses


getUserCategoryBoards

Gets the user's board categories


/teams/{teamID}/categories

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/categories"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            array[Object] result = apiInstance.getUserCategoryBoards(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUserCategoryBoards");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            array[Object] result = apiInstance.getUserCategoryBoards(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUserCategoryBoards");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)

[apiInstance getUserCategoryBoardsWith:teamID
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getUserCategoryBoards(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getUserCategoryBoardsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)

            try {
                array[Object] result = apiInstance.getUserCategoryBoards(teamID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getUserCategoryBoards: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID

try {
    $result = $api_instance->getUserCategoryBoards($teamID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getUserCategoryBoards: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID

eval {
    my $result = $api_instance->getUserCategoryBoards(teamID => $teamID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getUserCategoryBoards: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)

try:
    api_response = api_instance.get_user_category_boards(teamID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getUserCategoryBoards: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getUserCategoryBoards(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required

Responses


getUserConfig

Returns an array of user preferences


/users/me/config

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/users/me/config"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            Preferences result = apiInstance.getUserConfig();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUserConfig");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            Preferences result = apiInstance.getUserConfig();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUserConfig");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

[apiInstance getUserConfigWithCompletionHandler: 
              ^(Preferences output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getUserConfig(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getUserConfigExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                Preferences result = apiInstance.getUserConfig();
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getUserConfig: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $result = $api_instance->getUserConfig();
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getUserConfig: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    my $result = $api_instance->getUserConfig();
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getUserConfig: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    api_response = api_instance.get_user_config()
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getUserConfig: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.getUserConfig(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


getUsersList

Returns a user[]


/users

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/users"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID

        try {
            Object result = apiInstance.getUsersList(userID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUsersList");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID

        try {
            Object result = apiInstance.getUsersList(userID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#getUsersList");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *userID = userID_example; // User ID (default to null)

[apiInstance getUsersListWith:userID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var userID = userID_example; // {String} User ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.getUsersList(userID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class getUsersListExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var userID = userID_example;  // String | User ID (default to null)

            try {
                Object result = apiInstance.getUsersList(userID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.getUsersList: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$userID = userID_example; // String | User ID

try {
    $result = $api_instance->getUsersList($userID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->getUsersList: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $userID = userID_example; # String | User ID

eval {
    my $result = $api_instance->getUsersList(userID => $userID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->getUsersList: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
userID = userID_example # String | User ID (default to null)

try:
    api_response = api_instance.get_users_list(userID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->getUsersList: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let userID = userID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.getUsersList(userID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
userID*
String
User ID
Required

Responses


handleNotifyAdminUpgrade

Notifies admins for upgrade request.


/api/v2/teams/{teamID}/notifyadminupgrade

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/api/v2/teams/{teamID}/notifyadminupgrade"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            apiInstance.handleNotifyAdminUpgrade(teamID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#handleNotifyAdminUpgrade");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            apiInstance.handleNotifyAdminUpgrade(teamID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#handleNotifyAdminUpgrade");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)

// Notifies admins for upgrade request.
[apiInstance handleNotifyAdminUpgradeWith:teamID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.handleNotifyAdminUpgrade(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class handleNotifyAdminUpgradeExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)

            try {
                // Notifies admins for upgrade request.
                apiInstance.handleNotifyAdminUpgrade(teamID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.handleNotifyAdminUpgrade: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID

try {
    $api_instance->handleNotifyAdminUpgrade($teamID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->handleNotifyAdminUpgrade: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID

eval {
    $api_instance->handleNotifyAdminUpgrade(teamID => $teamID);
};
if ($@) {
    warn "Exception when calling DefaultApi->handleNotifyAdminUpgrade: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)

try:
    # Notifies admins for upgrade request.
    api_instance.handle_notify_admin_upgrade(teamID)
except ApiException as e:
    print("Exception when calling DefaultApi->handleNotifyAdminUpgrade: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.handleNotifyAdminUpgrade(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required

Responses


handleStatistics

Fetches the statistic of the server.


/statistics

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/statistics"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            BoardStatistics result = apiInstance.handleStatistics();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#handleStatistics");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            BoardStatistics result = apiInstance.handleStatistics();
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#handleStatistics");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

// Fetches the statistic  of the server.
[apiInstance handleStatisticsWithCompletionHandler: 
              ^(BoardStatistics output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.handleStatistics(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class handleStatisticsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                // Fetches the statistic  of the server.
                BoardStatistics result = apiInstance.handleStatistics();
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.handleStatistics: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $result = $api_instance->handleStatistics();
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->handleStatistics: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    my $result = $api_instance->handleStatistics();
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->handleStatistics: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    # Fetches the statistic  of the server.
    api_response = api_instance.handle_statistics()
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->handleStatistics: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.handleStatistics(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


handleTeamBoardsInsights

Returns team boards insights


/teams/{teamID}/boards/insights

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/boards/insights?time_range=timeRange_example&page=page_example&per_page=perPage_example"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String timeRange = timeRange_example; // String | duration of data to calculate insights for
        String page = page_example; // String | page offset for top boards
        String perPage = perPage_example; // String | limit for boards in a page.

        try {
            array[Object] result = apiInstance.handleTeamBoardsInsights(teamID, timeRange, page, perPage);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#handleTeamBoardsInsights");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String timeRange = timeRange_example; // String | duration of data to calculate insights for
        String page = page_example; // String | page offset for top boards
        String perPage = perPage_example; // String | limit for boards in a page.

        try {
            array[Object] result = apiInstance.handleTeamBoardsInsights(teamID, timeRange, page, perPage);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#handleTeamBoardsInsights");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *timeRange = timeRange_example; // duration of data to calculate insights for (default to null)
String *page = page_example; // page offset for top boards (default to null)
String *perPage = perPage_example; // limit for boards in a page. (default to null)

[apiInstance handleTeamBoardsInsightsWith:teamID
    timeRange:timeRange
    page:page
    perPage:perPage
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var timeRange = timeRange_example; // {String} duration of data to calculate insights for
var page = page_example; // {String} page offset for top boards
var perPage = perPage_example; // {String} limit for boards in a page.

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.handleTeamBoardsInsights(teamID, timeRange, page, perPage, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class handleTeamBoardsInsightsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var timeRange = timeRange_example;  // String | duration of data to calculate insights for (default to null)
            var page = page_example;  // String | page offset for top boards (default to null)
            var perPage = perPage_example;  // String | limit for boards in a page. (default to null)

            try {
                array[Object] result = apiInstance.handleTeamBoardsInsights(teamID, timeRange, page, perPage);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.handleTeamBoardsInsights: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$timeRange = timeRange_example; // String | duration of data to calculate insights for
$page = page_example; // String | page offset for top boards
$perPage = perPage_example; // String | limit for boards in a page.

try {
    $result = $api_instance->handleTeamBoardsInsights($teamID, $timeRange, $page, $perPage);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->handleTeamBoardsInsights: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $timeRange = timeRange_example; # String | duration of data to calculate insights for
my $page = page_example; # String | page offset for top boards
my $perPage = perPage_example; # String | limit for boards in a page.

eval {
    my $result = $api_instance->handleTeamBoardsInsights(teamID => $teamID, timeRange => $timeRange, page => $page, perPage => $perPage);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->handleTeamBoardsInsights: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
timeRange = timeRange_example # String | duration of data to calculate insights for (default to null)
page = page_example # String | page offset for top boards (default to null)
perPage = perPage_example # String | limit for boards in a page. (default to null)

try:
    api_response = api_instance.handle_team_boards_insights(teamID, timeRange, page, perPage)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->handleTeamBoardsInsights: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let timeRange = timeRange_example; // String
    let page = page_example; // String
    let perPage = perPage_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.handleTeamBoardsInsights(teamID, timeRange, page, perPage, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Query parameters
Name Description
time_range*
String
duration of data to calculate insights for
Required
page*
String
page offset for top boards
Required
per_page*
String
limit for boards in a page.
Required

Responses


hello

Responds with `Hello` if the web service is running.


/hello

Usage and SDK Samples

curl -X GET \
 "http://localhost/api/v2/hello"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            apiInstance.hello();
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#hello");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            apiInstance.hello();
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#hello");
            e.printStackTrace();
        }
    }
}


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

// Responds with `Hello` if the web service is running.
[apiInstance helloWithCompletionHandler: 
              ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.hello(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class helloExample
    {
        public void main()
        {

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                // Responds with `Hello` if the web service is running.
                apiInstance.hello();
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.hello: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $api_instance->hello();
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->hello: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    $api_instance->hello();
};
if ($@) {
    warn "Exception when calling DefaultApi->hello: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    # Responds with `Hello` if the web service is running.
    api_instance.hello()
except ApiException as e:
    print("Exception when calling DefaultApi->hello: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.hello(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


insertBoardsAndBlocks

Creates new boards and blocks


/boards-and-blocks

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards-and-blocks" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.insertBoardsAndBlocks(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#insertBoardsAndBlocks");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.insertBoardsAndBlocks(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#insertBoardsAndBlocks");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
Object *body = Object; // 

[apiInstance insertBoardsAndBlocksWith:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.insertBoardsAndBlocks(body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class insertBoardsAndBlocksExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.insertBoardsAndBlocks(body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.insertBoardsAndBlocks: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$body = Object; // Object | 

try {
    $result = $api_instance->insertBoardsAndBlocks($body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->insertBoardsAndBlocks: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->insertBoardsAndBlocks(body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->insertBoardsAndBlocks: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
body = Object # Object | 

try:
    api_response = api_instance.insert_boards_and_blocks(body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->insertBoardsAndBlocks: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.insertBoardsAndBlocks(body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Body parameters
Name Description
body *

the boards and blocks to create

Responses


joinBoard

Become a member of a board


/boards/{boardID}/join

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/join"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.joinBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#joinBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            Object result = apiInstance.joinBoard(boardID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#joinBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance joinBoardWith:boardID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.joinBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class joinBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                Object result = apiInstance.joinBoard(boardID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.joinBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $result = $api_instance->joinBoard($boardID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->joinBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    my $result = $api_instance->joinBoard(boardID => $boardID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->joinBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_response = api_instance.join_board(boardID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->joinBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.joinBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


leaveBoard

Remove your own membership from a board


/boards/{boardID}/leave

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/leave"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            apiInstance.leaveBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#leaveBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID

        try {
            apiInstance.leaveBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#leaveBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance leaveBoardWith:boardID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.leaveBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class leaveBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                apiInstance.leaveBoard(boardID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.leaveBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID

try {
    $api_instance->leaveBoard($boardID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->leaveBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID

eval {
    $api_instance->leaveBoard(boardID => $boardID);
};
if ($@) {
    warn "Exception when calling DefaultApi->leaveBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)

try:
    api_instance.leave_board(boardID)
except ApiException as e:
    print("Exception when calling DefaultApi->leaveBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.leaveBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required

Responses


login

Login user


/login

Usage and SDK Samples

curl -X POST \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/login" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.login(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#login");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.login(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#login");
            e.printStackTrace();
        }
    }
}


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
Object *body = Object; // 

[apiInstance loginWith:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.login(body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class loginExample
    {
        public void main()
        {

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.login(body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.login: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$body = Object; // Object | 

try {
    $result = $api_instance->login($body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->login: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->login(body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->login: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
body = Object # Object | 

try:
    api_response = api_instance.login(body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->login: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.login(body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Body parameters
Name Description
body *

Login request

Responses


logout

Logout user


/logout

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/logout"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            apiInstance.logout();
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#logout");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            apiInstance.logout();
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#logout");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

[apiInstance logoutWithCompletionHandler: 
              ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.logout(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class logoutExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                apiInstance.logout();
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.logout: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $api_instance->logout();
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->logout: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    $api_instance->logout();
};
if ($@) {
    warn "Exception when calling DefaultApi->logout: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    api_instance.logout()
except ApiException as e:
    print("Exception when calling DefaultApi->logout: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.logout(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


onboard

Onboards a user on Boards.


/team/{teamID}/onboard

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/team/{teamID}/onboard"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            onboard_200_response result = apiInstance.onboard(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#onboard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            onboard_200_response result = apiInstance.onboard(teamID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#onboard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)

// Onboards a user on Boards.
[apiInstance onboardWith:teamID
              completionHandler: ^(onboard_200_response output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.onboard(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class onboardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)

            try {
                // Onboards a user on Boards.
                onboard_200_response result = apiInstance.onboard(teamID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.onboard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID

try {
    $result = $api_instance->onboard($teamID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->onboard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID

eval {
    my $result = $api_instance->onboard(teamID => $teamID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->onboard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)

try:
    # Onboards a user on Boards.
    api_response = api_instance.onboard(teamID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->onboard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.onboard(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required

Responses


patchBlock

Partially updates a block


/boards/{boardID}/blocks/{blockID}

Usage and SDK Samples

curl -X PATCH \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}/blocks/{blockID}?disable_notify=" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | ID of block to patch
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk patching)

        try {
            apiInstance.patchBlock(boardID, blockID, body, disableNotify);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBlock");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | ID of block to patch
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk patching)

        try {
            apiInstance.patchBlock(boardID, blockID, body, disableNotify);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBlock");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
String *blockID = blockID_example; // ID of block to patch (default to null)
Object *body = Object; // 
oas_any_type_not_mapped *disableNotify = ; // Disables notifications (for bulk patching) (optional) (default to null)

[apiInstance patchBlockWith:boardID
    blockID:blockID
    body:body
    disableNotify:disableNotify
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var blockID = blockID_example; // {String} ID of block to patch
var body = Object; // {Object} 
var opts = {
  'disableNotify':  // {oas_any_type_not_mapped} Disables notifications (for bulk patching)
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.patchBlock(boardID, blockID, body, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class patchBlockExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var blockID = blockID_example;  // String | ID of block to patch (default to null)
            var body = Object;  // Object | 
            var disableNotify = new oas_any_type_not_mapped(); // oas_any_type_not_mapped | Disables notifications (for bulk patching) (optional)  (default to null)

            try {
                apiInstance.patchBlock(boardID, blockID, body, disableNotify);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.patchBlock: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$blockID = blockID_example; // String | ID of block to patch
$body = Object; // Object | 
$disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk patching)

try {
    $api_instance->patchBlock($boardID, $blockID, $body, $disableNotify);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->patchBlock: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $blockID = blockID_example; # String | ID of block to patch
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 
my $disableNotify = ; # oas_any_type_not_mapped | Disables notifications (for bulk patching)

eval {
    $api_instance->patchBlock(boardID => $boardID, blockID => $blockID, body => $body, disableNotify => $disableNotify);
};
if ($@) {
    warn "Exception when calling DefaultApi->patchBlock: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
blockID = blockID_example # String | ID of block to patch (default to null)
body = Object # Object | 
disableNotify =  # oas_any_type_not_mapped | Disables notifications (for bulk patching) (optional) (default to null)

try:
    api_instance.patch_block(boardID, blockID, body, disableNotify=disableNotify)
except ApiException as e:
    print("Exception when calling DefaultApi->patchBlock: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let blockID = blockID_example; // String
    let body = Object; // Object
    let disableNotify = ; // oas_any_type_not_mapped

    let mut context = DefaultApi::Context::default();
    let result = client.patchBlock(boardID, blockID, body, disableNotify, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
blockID*
String
ID of block to patch
Required
Body parameters
Name Description
body *

block patch to apply

Query parameters
Name Description
disable_notify
oas_any_type_not_mapped
Disables notifications (for bulk patching)

Responses


patchBlocks

Partially updates batch of blocks


/boards/{boardID}/blocks/

Usage and SDK Samples

curl -X PATCH \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}/blocks/?disable_notify=" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Workspace ID
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk patching)

        try {
            apiInstance.patchBlocks(boardID, body, disableNotify);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBlocks");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Workspace ID
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk patching)

        try {
            apiInstance.patchBlocks(boardID, body, disableNotify);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBlocks");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Workspace ID (default to null)
Object *body = Object; // 
oas_any_type_not_mapped *disableNotify = ; // Disables notifications (for bulk patching) (optional) (default to null)

[apiInstance patchBlocksWith:boardID
    body:body
    disableNotify:disableNotify
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Workspace ID
var body = Object; // {Object} 
var opts = {
  'disableNotify':  // {oas_any_type_not_mapped} Disables notifications (for bulk patching)
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.patchBlocks(boardID, body, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class patchBlocksExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Workspace ID (default to null)
            var body = Object;  // Object | 
            var disableNotify = new oas_any_type_not_mapped(); // oas_any_type_not_mapped | Disables notifications (for bulk patching) (optional)  (default to null)

            try {
                apiInstance.patchBlocks(boardID, body, disableNotify);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.patchBlocks: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Workspace ID
$body = Object; // Object | 
$disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk patching)

try {
    $api_instance->patchBlocks($boardID, $body, $disableNotify);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->patchBlocks: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Workspace ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 
my $disableNotify = ; # oas_any_type_not_mapped | Disables notifications (for bulk patching)

eval {
    $api_instance->patchBlocks(boardID => $boardID, body => $body, disableNotify => $disableNotify);
};
if ($@) {
    warn "Exception when calling DefaultApi->patchBlocks: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Workspace ID (default to null)
body = Object # Object | 
disableNotify =  # oas_any_type_not_mapped | Disables notifications (for bulk patching) (optional) (default to null)

try:
    api_instance.patch_blocks(boardID, body, disableNotify=disableNotify)
except ApiException as e:
    print("Exception when calling DefaultApi->patchBlocks: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let body = Object; // Object
    let disableNotify = ; // oas_any_type_not_mapped

    let mut context = DefaultApi::Context::default();
    let result = client.patchBlocks(boardID, body, disableNotify, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Workspace ID
Required
Body parameters
Name Description
body *

block Ids and block patches to apply

Query parameters
Name Description
disable_notify
oas_any_type_not_mapped
Disables notifications (for bulk patching)

Responses


patchBoard

Partially updates a board


/boards/{boardID}

Usage and SDK Samples

curl -X PATCH \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.patchBoard(boardID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.patchBoard(boardID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
Object *body = Object; // 

[apiInstance patchBoardWith:boardID
    body:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.patchBoard(boardID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class patchBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.patchBoard(boardID, body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.patchBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$body = Object; // Object | 

try {
    $result = $api_instance->patchBoard($boardID, $body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->patchBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->patchBoard(boardID => $boardID, body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->patchBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
body = Object # Object | 

try:
    api_response = api_instance.patch_board(boardID, body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->patchBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.patchBoard(boardID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
Body parameters
Name Description
body *

board patch to apply

Responses


patchBoardsAndBlocks

Patches a set of related boards and blocks


/boards-and-blocks

Usage and SDK Samples

curl -X PATCH \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards-and-blocks" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.patchBoardsAndBlocks(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBoardsAndBlocks");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.patchBoardsAndBlocks(body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchBoardsAndBlocks");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
Object *body = Object; // 

[apiInstance patchBoardsAndBlocksWith:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.patchBoardsAndBlocks(body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class patchBoardsAndBlocksExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.patchBoardsAndBlocks(body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.patchBoardsAndBlocks: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$body = Object; // Object | 

try {
    $result = $api_instance->patchBoardsAndBlocks($body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->patchBoardsAndBlocks: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->patchBoardsAndBlocks(body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->patchBoardsAndBlocks: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
body = Object # Object | 

try:
    api_response = api_instance.patch_boards_and_blocks(body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->patchBoardsAndBlocks: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.patchBoardsAndBlocks(body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Body parameters
Name Description
body *

the patches for the boards and blocks

Responses


patchCard

Patches the specified card.


/cards/{cardID}/cards

Usage and SDK Samples

curl -X PATCH \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/cards/{cardID}/cards?disable_notify=" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String cardID = cardID_example; // String | Card ID
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk data patching)

        try {
            Object result = apiInstance.patchCard(cardID, body, disableNotify);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchCard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String cardID = cardID_example; // String | Card ID
        Object body = Object; // Object | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk data patching)

        try {
            Object result = apiInstance.patchCard(cardID, body, disableNotify);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#patchCard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *cardID = cardID_example; // Card ID (default to null)
Object *body = Object; // 
oas_any_type_not_mapped *disableNotify = ; // Disables notifications (for bulk data patching) (optional) (default to null)

// Patches the specified card.
[apiInstance patchCardWith:cardID
    body:body
    disableNotify:disableNotify
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var cardID = cardID_example; // {String} Card ID
var body = Object; // {Object} 
var opts = {
  'disableNotify':  // {oas_any_type_not_mapped} Disables notifications (for bulk data patching)
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.patchCard(cardID, body, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class patchCardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var cardID = cardID_example;  // String | Card ID (default to null)
            var body = Object;  // Object | 
            var disableNotify = new oas_any_type_not_mapped(); // oas_any_type_not_mapped | Disables notifications (for bulk data patching) (optional)  (default to null)

            try {
                // Patches the specified card.
                Object result = apiInstance.patchCard(cardID, body, disableNotify);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.patchCard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$cardID = cardID_example; // String | Card ID
$body = Object; // Object | 
$disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk data patching)

try {
    $result = $api_instance->patchCard($cardID, $body, $disableNotify);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->patchCard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $cardID = cardID_example; # String | Card ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 
my $disableNotify = ; # oas_any_type_not_mapped | Disables notifications (for bulk data patching)

eval {
    my $result = $api_instance->patchCard(cardID => $cardID, body => $body, disableNotify => $disableNotify);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->patchCard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
cardID = cardID_example # String | Card ID (default to null)
body = Object # Object | 
disableNotify =  # oas_any_type_not_mapped | Disables notifications (for bulk data patching) (optional) (default to null)

try:
    # Patches the specified card.
    api_response = api_instance.patch_card(cardID, body, disableNotify=disableNotify)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->patchCard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let cardID = cardID_example; // String
    let body = Object; // Object
    let disableNotify = ; // oas_any_type_not_mapped

    let mut context = DefaultApi::Context::default();
    let result = client.patchCard(cardID, body, disableNotify, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
cardID*
String
Card ID
Required
Body parameters
Name Description
body *

the card patch

Query parameters
Name Description
disable_notify
oas_any_type_not_mapped
Disables notifications (for bulk data patching)

Responses


ping

Responds with server metadata if the web service is running.


/ping

Usage and SDK Samples

curl -X GET \
 "http://localhost/api/v2/ping"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();

        try {
            apiInstance.ping();
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#ping");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();

        try {
            apiInstance.ping();
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#ping");
            e.printStackTrace();
        }
    }
}


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];

// Responds with server metadata if the web service is running.
[apiInstance pingWithCompletionHandler: 
              ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.ping(callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class pingExample
    {
        public void main()
        {

            // Create an instance of the API class
            var apiInstance = new DefaultApi();

            try {
                // Responds with server metadata if the web service is running.
                apiInstance.ping();
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.ping: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();

try {
    $api_instance->ping();
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->ping: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();

eval {
    $api_instance->ping();
};
if ($@) {
    warn "Exception when calling DefaultApi->ping: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()

try:
    # Responds with server metadata if the web service is running.
    api_instance.ping()
except ApiException as e:
    print("Exception when calling DefaultApi->ping: %s\n" % e)
extern crate DefaultApi;

pub fn main() {

    let mut context = DefaultApi::Context::default();
    let result = client.ping(&context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Responses


postSharing

Sets sharing information for a board


/boards/{boardID}/sharing

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}/sharing" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 

        try {
            apiInstance.postSharing(boardID, body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#postSharing");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        Object body = Object; // Object | 

        try {
            apiInstance.postSharing(boardID, body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#postSharing");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
Object *body = Object; // 

[apiInstance postSharingWith:boardID
    body:body
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.postSharing(boardID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class postSharingExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var body = Object;  // Object | 

            try {
                apiInstance.postSharing(boardID, body);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.postSharing: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$body = Object; // Object | 

try {
    $api_instance->postSharing($boardID, $body);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->postSharing: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    $api_instance->postSharing(boardID => $boardID, body => $body);
};
if ($@) {
    warn "Exception when calling DefaultApi->postSharing: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
body = Object # Object | 

try:
    api_instance.post_sharing(boardID, body)
except ApiException as e:
    print("Exception when calling DefaultApi->postSharing: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.postSharing(boardID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
Body parameters
Name Description
body *

sharing information for a root block

Responses


regenerateSignupToken

Regenerates the signup token for the root team


/teams/{teamID}/regenerate_signup_token

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/regenerate_signup_token"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            apiInstance.regenerateSignupToken(teamID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#regenerateSignupToken");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID

        try {
            apiInstance.regenerateSignupToken(teamID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#regenerateSignupToken");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)

[apiInstance regenerateSignupTokenWith:teamID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.regenerateSignupToken(teamID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class regenerateSignupTokenExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)

            try {
                apiInstance.regenerateSignupToken(teamID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.regenerateSignupToken: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID

try {
    $api_instance->regenerateSignupToken($teamID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->regenerateSignupToken: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID

eval {
    $api_instance->regenerateSignupToken(teamID => $teamID);
};
if ($@) {
    warn "Exception when calling DefaultApi->regenerateSignupToken: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)

try:
    api_instance.regenerate_signup_token(teamID)
except ApiException as e:
    print("Exception when calling DefaultApi->regenerateSignupToken: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.regenerateSignupToken(teamID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required

Responses


register

Register new user


/register

Usage and SDK Samples

curl -X POST \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/register" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            apiInstance.register(body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#register");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        Object body = Object; // Object | 

        try {
            apiInstance.register(body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#register");
            e.printStackTrace();
        }
    }
}


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
Object *body = Object; // 

[apiInstance registerWith:body
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.register(body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class registerExample
    {
        public void main()
        {

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var body = Object;  // Object | 

            try {
                apiInstance.register(body);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.register: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$body = Object; // Object | 

try {
    $api_instance->register($body);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->register: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    $api_instance->register(body => $body);
};
if ($@) {
    warn "Exception when calling DefaultApi->register: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
body = Object # Object | 

try:
    api_instance.register(body)
except ApiException as e:
    print("Exception when calling DefaultApi->register: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.register(body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Body parameters
Name Description
body *

Register request

Responses


searchAllBoards

Returns the boards that match with a search term


/boards/search

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/search?q=q_example"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String q = q_example; // String | The search term. Must have at least one character

        try {
            array[Object] result = apiInstance.searchAllBoards(q);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchAllBoards");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String q = q_example; // String | The search term. Must have at least one character

        try {
            array[Object] result = apiInstance.searchAllBoards(q);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchAllBoards");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *q = q_example; // The search term. Must have at least one character (default to null)

[apiInstance searchAllBoardsWith:q
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var q = q_example; // {String} The search term. Must have at least one character

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.searchAllBoards(q, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class searchAllBoardsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var q = q_example;  // String | The search term. Must have at least one character (default to null)

            try {
                array[Object] result = apiInstance.searchAllBoards(q);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.searchAllBoards: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$q = q_example; // String | The search term. Must have at least one character

try {
    $result = $api_instance->searchAllBoards($q);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->searchAllBoards: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $q = q_example; # String | The search term. Must have at least one character

eval {
    my $result = $api_instance->searchAllBoards(q => $q);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->searchAllBoards: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
q = q_example # String | The search term. Must have at least one character (default to null)

try:
    api_response = api_instance.search_all_boards(q)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->searchAllBoards: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let q = q_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.searchAllBoards(q, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Query parameters
Name Description
q*
String
The search term. Must have at least one character
Required

Responses


searchBoards

Returns the boards that match with a search term in the team


/teams/{teamID}/boards/search

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/boards/search?q=q_example"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String q = q_example; // String | The search term. Must have at least one character

        try {
            array[Object] result = apiInstance.searchBoards(teamID, q);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchBoards");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String q = q_example; // String | The search term. Must have at least one character

        try {
            array[Object] result = apiInstance.searchBoards(teamID, q);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchBoards");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *q = q_example; // The search term. Must have at least one character (default to null)

[apiInstance searchBoardsWith:teamID
    q:q
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var q = q_example; // {String} The search term. Must have at least one character

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.searchBoards(teamID, q, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class searchBoardsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var q = q_example;  // String | The search term. Must have at least one character (default to null)

            try {
                array[Object] result = apiInstance.searchBoards(teamID, q);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.searchBoards: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$q = q_example; // String | The search term. Must have at least one character

try {
    $result = $api_instance->searchBoards($teamID, $q);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->searchBoards: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $q = q_example; # String | The search term. Must have at least one character

eval {
    my $result = $api_instance->searchBoards(teamID => $teamID, q => $q);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->searchBoards: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
q = q_example # String | The search term. Must have at least one character (default to null)

try:
    api_response = api_instance.search_boards(teamID, q)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->searchBoards: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let q = q_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.searchBoards(teamID, q, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Query parameters
Name Description
q*
String
The search term. Must have at least one character
Required

Responses


searchLinkableBoards

Returns the boards that match with a search term in the team and the user has permission to manage members


/teams/{teamID}/boards/search/linkable

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/boards/search/linkable?q=q_example"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String q = q_example; // String | The search term. Must have at least one character

        try {
            array[Object] result = apiInstance.searchLinkableBoards(teamID, q);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchLinkableBoards");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String q = q_example; // String | The search term. Must have at least one character

        try {
            array[Object] result = apiInstance.searchLinkableBoards(teamID, q);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchLinkableBoards");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *q = q_example; // The search term. Must have at least one character (default to null)

[apiInstance searchLinkableBoardsWith:teamID
    q:q
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var q = q_example; // {String} The search term. Must have at least one character

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.searchLinkableBoards(teamID, q, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class searchLinkableBoardsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var q = q_example;  // String | The search term. Must have at least one character (default to null)

            try {
                array[Object] result = apiInstance.searchLinkableBoards(teamID, q);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.searchLinkableBoards: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$q = q_example; // String | The search term. Must have at least one character

try {
    $result = $api_instance->searchLinkableBoards($teamID, $q);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->searchLinkableBoards: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $q = q_example; # String | The search term. Must have at least one character

eval {
    my $result = $api_instance->searchLinkableBoards(teamID => $teamID, q => $q);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->searchLinkableBoards: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
q = q_example # String | The search term. Must have at least one character (default to null)

try:
    api_response = api_instance.search_linkable_boards(teamID, q)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->searchLinkableBoards: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let q = q_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.searchLinkableBoards(teamID, q, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Query parameters
Name Description
q*
String
The search term. Must have at least one character
Required

Responses


searchMyChannels

Returns the user available channels


/teams/{teamID}/channels

Usage and SDK Samples

curl -X GET \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/channels?search=search_example"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String search = search_example; // String | string to filter channels list

        try {
            array[Channel] result = apiInstance.searchMyChannels(teamID, search);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchMyChannels");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String search = search_example; // String | string to filter channels list

        try {
            array[Channel] result = apiInstance.searchMyChannels(teamID, search);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#searchMyChannels");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *search = search_example; // string to filter channels list (optional) (default to null)

[apiInstance searchMyChannelsWith:teamID
    search:search
              completionHandler: ^(array[Channel] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var opts = {
  'search': search_example // {String} string to filter channels list
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.searchMyChannels(teamID, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class searchMyChannelsExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var search = search_example;  // String | string to filter channels list (optional)  (default to null)

            try {
                array[Channel] result = apiInstance.searchMyChannels(teamID, search);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.searchMyChannels: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$search = search_example; // String | string to filter channels list

try {
    $result = $api_instance->searchMyChannels($teamID, $search);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->searchMyChannels: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $search = search_example; # String | string to filter channels list

eval {
    my $result = $api_instance->searchMyChannels(teamID => $teamID, search => $search);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->searchMyChannels: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
search = search_example # String | string to filter channels list (optional) (default to null)

try:
    api_response = api_instance.search_my_channels(teamID, search=search)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->searchMyChannels: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let search = search_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.searchMyChannels(teamID, search, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
Query parameters
Name Description
search

Responses


undeleteBlock

Undeletes a block


/boards/{boardID}/blocks/{blockID}/undelete

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/blocks/{blockID}/undelete"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | ID of block to undelete

        try {
            Object result = apiInstance.undeleteBlock(boardID, blockID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#undeleteBlock");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String blockID = blockID_example; // String | ID of block to undelete

        try {
            Object result = apiInstance.undeleteBlock(boardID, blockID);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#undeleteBlock");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
String *blockID = blockID_example; // ID of block to undelete (default to null)

[apiInstance undeleteBlockWith:boardID
    blockID:blockID
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var blockID = blockID_example; // {String} ID of block to undelete

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.undeleteBlock(boardID, blockID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class undeleteBlockExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var blockID = blockID_example;  // String | ID of block to undelete (default to null)

            try {
                Object result = apiInstance.undeleteBlock(boardID, blockID);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.undeleteBlock: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$blockID = blockID_example; // String | ID of block to undelete

try {
    $result = $api_instance->undeleteBlock($boardID, $blockID);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->undeleteBlock: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $blockID = blockID_example; # String | ID of block to undelete

eval {
    my $result = $api_instance->undeleteBlock(boardID => $boardID, blockID => $blockID);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->undeleteBlock: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
blockID = blockID_example # String | ID of block to undelete (default to null)

try:
    api_response = api_instance.undelete_block(boardID, blockID)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->undeleteBlock: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let blockID = blockID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.undeleteBlock(boardID, blockID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
blockID*
String
ID of block to undelete
Required

Responses


undeleteBoard

Undeletes a board


/boards/{boardID}/undelete

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/boards/{boardID}/undelete"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | ID of board to undelete

        try {
            apiInstance.undeleteBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#undeleteBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | ID of board to undelete

        try {
            apiInstance.undeleteBoard(boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#undeleteBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // ID of board to undelete (default to null)

[apiInstance undeleteBoardWith:boardID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} ID of board to undelete

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.undeleteBoard(boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class undeleteBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | ID of board to undelete (default to null)

            try {
                apiInstance.undeleteBoard(boardID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.undeleteBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | ID of board to undelete

try {
    $api_instance->undeleteBoard($boardID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->undeleteBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | ID of board to undelete

eval {
    $api_instance->undeleteBoard(boardID => $boardID);
};
if ($@) {
    warn "Exception when calling DefaultApi->undeleteBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | ID of board to undelete (default to null)

try:
    api_instance.undelete_board(boardID)
except ApiException as e:
    print("Exception when calling DefaultApi->undeleteBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.undeleteBoard(boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
ID of board to undelete
Required

Responses


updateBlocks

Insert blocks. The specified IDs will only be used to link blocks with existing ones, the rest will be replaced by server generated IDs


/boards/{boardID}/blocks

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}/blocks?disable_notify=" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        array[Object] body = ; // array[Object] | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk inserting)

        try {
            array[Object] result = apiInstance.updateBlocks(boardID, body, disableNotify);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateBlocks");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        array[Object] body = ; // array[Object] | 
        oas_any_type_not_mapped disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk inserting)

        try {
            array[Object] result = apiInstance.updateBlocks(boardID, body, disableNotify);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateBlocks");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
array[Object] *body = ; // 
oas_any_type_not_mapped *disableNotify = ; // Disables notifications (for bulk inserting) (optional) (default to null)

[apiInstance updateBlocksWith:boardID
    body:body
    disableNotify:disableNotify
              completionHandler: ^(array[Object] output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var body = ; // {array[Object]} 
var opts = {
  'disableNotify':  // {oas_any_type_not_mapped} Disables notifications (for bulk inserting)
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.updateBlocks(boardID, body, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class updateBlocksExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var body = new array[Object](); // array[Object] | 
            var disableNotify = new oas_any_type_not_mapped(); // oas_any_type_not_mapped | Disables notifications (for bulk inserting) (optional)  (default to null)

            try {
                array[Object] result = apiInstance.updateBlocks(boardID, body, disableNotify);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.updateBlocks: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$body = ; // array[Object] | 
$disableNotify = ; // oas_any_type_not_mapped | Disables notifications (for bulk inserting)

try {
    $result = $api_instance->updateBlocks($boardID, $body, $disableNotify);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->updateBlocks: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $body = [WWW::OPenAPIClient::Object::array[Object]->new()]; # array[Object] | 
my $disableNotify = ; # oas_any_type_not_mapped | Disables notifications (for bulk inserting)

eval {
    my $result = $api_instance->updateBlocks(boardID => $boardID, body => $body, disableNotify => $disableNotify);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->updateBlocks: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
body =  # array[Object] | 
disableNotify =  # oas_any_type_not_mapped | Disables notifications (for bulk inserting) (optional) (default to null)

try:
    api_response = api_instance.update_blocks(boardID, body, disableNotify=disableNotify)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->updateBlocks: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let body = ; // array[Object]
    let disableNotify = ; // oas_any_type_not_mapped

    let mut context = DefaultApi::Context::default();
    let result = client.updateBlocks(boardID, body, disableNotify, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
Body parameters
Name Description
body *

array of blocks to insert or update

Query parameters
Name Description
disable_notify
oas_any_type_not_mapped
Disables notifications (for bulk inserting)

Responses


updateCategory

Create a category for boards


/teams/{teamID}/categories/{categoryID}

Usage and SDK Samples

curl -X PUT \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/teams/{teamID}/categories/{categoryID}" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String categoryID = categoryID_example; // String | Category ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.updateCategory(teamID, categoryID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateCategory");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String categoryID = categoryID_example; // String | Category ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.updateCategory(teamID, categoryID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateCategory");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *categoryID = categoryID_example; // Category ID (default to null)
Object *body = Object; // 

[apiInstance updateCategoryWith:teamID
    categoryID:categoryID
    body:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var categoryID = categoryID_example; // {String} Category ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.updateCategory(teamID, categoryID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class updateCategoryExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var categoryID = categoryID_example;  // String | Category ID (default to null)
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.updateCategory(teamID, categoryID, body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.updateCategory: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$categoryID = categoryID_example; // String | Category ID
$body = Object; // Object | 

try {
    $result = $api_instance->updateCategory($teamID, $categoryID, $body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->updateCategory: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $categoryID = categoryID_example; # String | Category ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->updateCategory(teamID => $teamID, categoryID => $categoryID, body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->updateCategory: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
categoryID = categoryID_example # String | Category ID (default to null)
body = Object # Object | 

try:
    api_response = api_instance.update_category(teamID, categoryID, body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->updateCategory: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let categoryID = categoryID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.updateCategory(teamID, categoryID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
categoryID*
String
Category ID
Required
Body parameters
Name Description
body *

category to update

Responses


updateCategoryBoard

Set the category of a board


/teams/{teamID}/categories/{categoryID}/boards/{boardID}

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 "http://localhost/api/v2/teams/{teamID}/categories/{categoryID}/boards/{boardID}"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String categoryID = categoryID_example; // String | Category ID
        String boardID = boardID_example; // String | Board ID

        try {
            apiInstance.updateCategoryBoard(teamID, categoryID, boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateCategoryBoard");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | Team ID
        String categoryID = categoryID_example; // String | Category ID
        String boardID = boardID_example; // String | Board ID

        try {
            apiInstance.updateCategoryBoard(teamID, categoryID, boardID);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateCategoryBoard");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // Team ID (default to null)
String *categoryID = categoryID_example; // Category ID (default to null)
String *boardID = boardID_example; // Board ID (default to null)

[apiInstance updateCategoryBoardWith:teamID
    categoryID:categoryID
    boardID:boardID
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} Team ID
var categoryID = categoryID_example; // {String} Category ID
var boardID = boardID_example; // {String} Board ID

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.updateCategoryBoard(teamID, categoryID, boardID, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class updateCategoryBoardExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | Team ID (default to null)
            var categoryID = categoryID_example;  // String | Category ID (default to null)
            var boardID = boardID_example;  // String | Board ID (default to null)

            try {
                apiInstance.updateCategoryBoard(teamID, categoryID, boardID);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.updateCategoryBoard: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | Team ID
$categoryID = categoryID_example; // String | Category ID
$boardID = boardID_example; // String | Board ID

try {
    $api_instance->updateCategoryBoard($teamID, $categoryID, $boardID);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->updateCategoryBoard: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | Team ID
my $categoryID = categoryID_example; # String | Category ID
my $boardID = boardID_example; # String | Board ID

eval {
    $api_instance->updateCategoryBoard(teamID => $teamID, categoryID => $categoryID, boardID => $boardID);
};
if ($@) {
    warn "Exception when calling DefaultApi->updateCategoryBoard: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | Team ID (default to null)
categoryID = categoryID_example # String | Category ID (default to null)
boardID = boardID_example # String | Board ID (default to null)

try:
    api_instance.update_category_board(teamID, categoryID, boardID)
except ApiException as e:
    print("Exception when calling DefaultApi->updateCategoryBoard: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let categoryID = categoryID_example; // String
    let boardID = boardID_example; // String

    let mut context = DefaultApi::Context::default();
    let result = client.updateCategoryBoard(teamID, categoryID, boardID, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
Team ID
Required
categoryID*
String
Category ID
Required
boardID*
String
Board ID
Required

Responses


updateMember

Updates a board member


/boards/{boardID}/members/{userID}

Usage and SDK Samples

curl -X PUT \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/boards/{boardID}/members/{userID}" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String userID = userID_example; // String | User ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.updateMember(boardID, userID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateMember");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String boardID = boardID_example; // String | Board ID
        String userID = userID_example; // String | User ID
        Object body = Object; // Object | 

        try {
            Object result = apiInstance.updateMember(boardID, userID, body);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateMember");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *boardID = boardID_example; // Board ID (default to null)
String *userID = userID_example; // User ID (default to null)
Object *body = Object; // 

[apiInstance updateMemberWith:boardID
    userID:userID
    body:body
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var boardID = boardID_example; // {String} Board ID
var userID = userID_example; // {String} User ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.updateMember(boardID, userID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class updateMemberExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var boardID = boardID_example;  // String | Board ID (default to null)
            var userID = userID_example;  // String | User ID (default to null)
            var body = Object;  // Object | 

            try {
                Object result = apiInstance.updateMember(boardID, userID, body);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.updateMember: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$boardID = boardID_example; // String | Board ID
$userID = userID_example; // String | User ID
$body = Object; // Object | 

try {
    $result = $api_instance->updateMember($boardID, $userID, $body);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->updateMember: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $boardID = boardID_example; # String | Board ID
my $userID = userID_example; # String | User ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    my $result = $api_instance->updateMember(boardID => $boardID, userID => $userID, body => $body);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->updateMember: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
boardID = boardID_example # String | Board ID (default to null)
userID = userID_example # String | User ID (default to null)
body = Object # Object | 

try:
    api_response = api_instance.update_member(boardID, userID, body)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->updateMember: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let boardID = boardID_example; // String
    let userID = userID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.updateMember(boardID, userID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
boardID*
String
Board ID
Required
userID*
String
User ID
Required
Body parameters
Name Description
body *

membership to replace the current one with

Responses


updateUserConfig

Updates user config


/users/{userID}/config

Usage and SDK Samples

curl -X PATCH \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: application/json" \
 "http://localhost/api/v2/users/{userID}/config" \
 -d ''
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID
        Object body = Object; // Object | 

        try {
            apiInstance.updateUserConfig(userID, body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateUserConfig");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String userID = userID_example; // String | User ID
        Object body = Object; // Object | 

        try {
            apiInstance.updateUserConfig(userID, body);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#updateUserConfig");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *userID = userID_example; // User ID (default to null)
Object *body = Object; // 

[apiInstance updateUserConfigWith:userID
    body:body
              completionHandler: ^(NSError* error) {
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var userID = userID_example; // {String} User ID
var body = Object; // {Object} 

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully.');
  }
};
api.updateUserConfig(userID, body, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class updateUserConfigExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var userID = userID_example;  // String | User ID (default to null)
            var body = Object;  // Object | 

            try {
                apiInstance.updateUserConfig(userID, body);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.updateUserConfig: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$userID = userID_example; // String | User ID
$body = Object; // Object | 

try {
    $api_instance->updateUserConfig($userID, $body);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->updateUserConfig: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $userID = userID_example; # String | User ID
my $body = WWW::OPenAPIClient::Object::Object->new(); # Object | 

eval {
    $api_instance->updateUserConfig(userID => $userID, body => $body);
};
if ($@) {
    warn "Exception when calling DefaultApi->updateUserConfig: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
userID = userID_example # String | User ID (default to null)
body = Object # Object | 

try:
    api_instance.update_user_config(userID, body)
except ApiException as e:
    print("Exception when calling DefaultApi->updateUserConfig: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let userID = userID_example; // String
    let body = Object; // Object

    let mut context = DefaultApi::Context::default();
    let result = client.updateUserConfig(userID, body, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
userID*
String
User ID
Required
Body parameters
Name Description
body *

User config patch to apply

Responses


uploadFile

Upload a binary file, attached to a root block


/teams/{teamID}/boards/{boardID}/files

Usage and SDK Samples

curl -X POST \
-H "Authorization: [[apiKey]]" \
 -H "Accept: application/json" \
 -H "Content-Type: multipart/form-data" \
 "http://localhost/api/v2/teams/{teamID}/boards/{boardID}/files"
import org.openapitools.client.*;
import org.openapitools.client.auth.*;
import org.openapitools.client.model.*;
import org.openapitools.client.api.DefaultApi;

import java.io.File;
import java.util.*;

public class DefaultApiExample {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        
        // Configure API key authorization: BearerAuth
        ApiKeyAuth BearerAuth = (ApiKeyAuth) defaultClient.getAuthentication("BearerAuth");
        BearerAuth.setApiKey("YOUR API KEY");
        // Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
        //BearerAuth.setApiKeyPrefix("Token");

        // Create an instance of the API class
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | ID of the team
        String boardID = boardID_example; // String | Board ID
        File uploaded file = BINARY_DATA_HERE; // File | The file to upload

        try {
            Object result = apiInstance.uploadFile(teamID, boardID, uploaded file);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#uploadFile");
            e.printStackTrace();
        }
    }
}
import org.openapitools.client.api.DefaultApi;

public class DefaultApiExample {
    public static void main(String[] args) {
        DefaultApi apiInstance = new DefaultApi();
        String teamID = teamID_example; // String | ID of the team
        String boardID = boardID_example; // String | Board ID
        File uploaded file = BINARY_DATA_HERE; // File | The file to upload

        try {
            Object result = apiInstance.uploadFile(teamID, boardID, uploaded file);
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling DefaultApi#uploadFile");
            e.printStackTrace();
        }
    }
}
Configuration *apiConfig = [Configuration sharedConfig];

// Configure API key authorization: (authentication scheme: BearerAuth)
[apiConfig setApiKey:@"YOUR_API_KEY" forApiKeyIdentifier:@"Authorization"];
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//[apiConfig setApiKeyPrefix:@"Bearer" forApiKeyIdentifier:@"Authorization"];


// Create an instance of the API class
DefaultApi *apiInstance = [[DefaultApi alloc] init];
String *teamID = teamID_example; // ID of the team (default to null)
String *boardID = boardID_example; // Board ID (default to null)
File *uploaded file = BINARY_DATA_HERE; // The file to upload (optional) (default to null)

[apiInstance uploadFileWith:teamID
    boardID:boardID
    uploaded file:uploaded file
              completionHandler: ^(Object output, NSError* error) {
    if (output) {
        NSLog(@"%@", output);
    }
    if (error) {
        NSLog(@"Error: %@", error);
    }
}];
var FocalboardServer = require('focalboard_server');
var defaultClient = FocalboardServer.ApiClient.instance;

// Configure API key authorization: BearerAuth
var BearerAuth = defaultClient.authentications['BearerAuth'];
BearerAuth.apiKey = "YOUR API KEY";
// Uncomment the following line to set a prefix for the API key, e.g. "Token" (defaults to null)
//BearerAuth.apiKeyPrefix['Authorization'] = "Token";

// Create an instance of the API class
var api = new FocalboardServer.DefaultApi()
var teamID = teamID_example; // {String} ID of the team
var boardID = boardID_example; // {String} Board ID
var opts = {
  'uploaded file': BINARY_DATA_HERE // {File} The file to upload
};

var callback = function(error, data, response) {
  if (error) {
    console.error(error);
  } else {
    console.log('API called successfully. Returned data: ' + data);
  }
};
api.uploadFile(teamID, boardID, opts, callback);
using System;
using System.Diagnostics;
using Org.OpenAPITools.Api;
using Org.OpenAPITools.Client;
using Org.OpenAPITools.Model;

namespace Example
{
    public class uploadFileExample
    {
        public void main()
        {
            // Configure API key authorization: BearerAuth
            Configuration.Default.ApiKey.Add("Authorization", "YOUR_API_KEY");
            // Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
            // Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");

            // Create an instance of the API class
            var apiInstance = new DefaultApi();
            var teamID = teamID_example;  // String | ID of the team (default to null)
            var boardID = boardID_example;  // String | Board ID (default to null)
            var uploaded file = BINARY_DATA_HERE;  // File | The file to upload (optional)  (default to null)

            try {
                Object result = apiInstance.uploadFile(teamID, boardID, uploaded file);
                Debug.WriteLine(result);
            } catch (Exception e) {
                Debug.Print("Exception when calling DefaultApi.uploadFile: " + e.Message );
            }
        }
    }
}
<?php
require_once(__DIR__ . '/vendor/autoload.php');

// Configure API key authorization: BearerAuth
OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKey('Authorization', 'YOUR_API_KEY');
// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
// OpenAPITools\Client\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Authorization', 'Bearer');

// Create an instance of the API class
$api_instance = new OpenAPITools\Client\Api\DefaultApi();
$teamID = teamID_example; // String | ID of the team
$boardID = boardID_example; // String | Board ID
$uploaded file = BINARY_DATA_HERE; // File | The file to upload

try {
    $result = $api_instance->uploadFile($teamID, $boardID, $uploaded file);
    print_r($result);
} catch (Exception $e) {
    echo 'Exception when calling DefaultApi->uploadFile: ', $e->getMessage(), PHP_EOL;
}
?>
use Data::Dumper;
use WWW::OPenAPIClient::Configuration;
use WWW::OPenAPIClient::DefaultApi;

# Configure API key authorization: BearerAuth
$WWW::OPenAPIClient::Configuration::api_key->{'Authorization'} = 'YOUR_API_KEY';
# uncomment below to setup prefix (e.g. Bearer) for API key, if needed
#$WWW::OPenAPIClient::Configuration::api_key_prefix->{'Authorization'} = "Bearer";

# Create an instance of the API class
my $api_instance = WWW::OPenAPIClient::DefaultApi->new();
my $teamID = teamID_example; # String | ID of the team
my $boardID = boardID_example; # String | Board ID
my $uploaded file = BINARY_DATA_HERE; # File | The file to upload

eval {
    my $result = $api_instance->uploadFile(teamID => $teamID, boardID => $boardID, uploaded file => $uploaded file);
    print Dumper($result);
};
if ($@) {
    warn "Exception when calling DefaultApi->uploadFile: $@\n";
}
from __future__ import print_statement
import time
import openapi_client
from openapi_client.rest import ApiException
from pprint import pprint

# Configure API key authorization: BearerAuth
openapi_client.configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# openapi_client.configuration.api_key_prefix['Authorization'] = 'Bearer'

# Create an instance of the API class
api_instance = openapi_client.DefaultApi()
teamID = teamID_example # String | ID of the team (default to null)
boardID = boardID_example # String | Board ID (default to null)
uploaded file = BINARY_DATA_HERE # File | The file to upload (optional) (default to null)

try:
    api_response = api_instance.upload_file(teamID, boardID, uploaded file=uploaded file)
    pprint(api_response)
except ApiException as e:
    print("Exception when calling DefaultApi->uploadFile: %s\n" % e)
extern crate DefaultApi;

pub fn main() {
    let teamID = teamID_example; // String
    let boardID = boardID_example; // String
    let uploaded file = BINARY_DATA_HERE; // File

    let mut context = DefaultApi::Context::default();
    let result = client.uploadFile(teamID, boardID, uploaded file, &context).wait();

    println!("{:?}", result);
}

Scopes

Parameters

Path parameters
Name Description
teamID*
String
ID of the team
Required
boardID*
String
Board ID
Required
Form parameters
Name Description
uploaded file
File (binary)
The file to upload

Responses


================================================ FILE: server/swagger/swagger.yml ================================================ basePath: /api/v2 consumes: - application/json definitions: Block: description: Block is the basic data unit x-go-package: github.com/mattermost/focalboard/server/model BlockPatch: description: BlockPatch is a patch for modify blocks x-go-package: github.com/mattermost/focalboard/server/model BlockPatchBatch: description: BlockPatchBatch is a batch of IDs and patches for modify blocks x-go-package: github.com/mattermost/focalboard/server/model Board: description: Board groups a set of blocks and its layout x-go-package: github.com/mattermost/focalboard/server/model BoardInsight: description: BoardInsight gives insight into activities in a Board x-go-package: github.com/mattermost/focalboard/server/model BoardMember: description: BoardMember stores the information of the membership of a user on a board x-go-package: github.com/mattermost/focalboard/server/model BoardMemberHistoryEntry: description: BoardMemberHistoryEntry stores the information of the membership of a user on a board x-go-package: github.com/mattermost/focalboard/server/model BoardMetadata: description: BoardMetadata contains metadata for a Board x-go-package: github.com/mattermost/focalboard/server/model BoardPatch: description: BoardPatch is a patch for modify boards x-go-package: github.com/mattermost/focalboard/server/model BoardsAndBlocks: description: |- BoardsAndBlocks is used to operate over boards and blocks at the same time x-go-package: github.com/mattermost/focalboard/server/model BoardsCloudLimits: description: |- BoardsCloudLimits is the representation of the limits for the Boards server x-go-package: github.com/mattermost/focalboard/server/model BoardsStatistics: description: BoardsStatistics is the representation of the statistics for the Boards server x-go-package: github.com/mattermost/focalboard/server/model Card: title: Card represents a group of content blocks and properties. x-go-package: github.com/mattermost/focalboard/server/model CardPatch: description: CardPatch is a patch for modifying cards x-go-package: github.com/mattermost/focalboard/server/model Category: description: Category is a board category x-go-package: github.com/mattermost/focalboard/server/model CategoryBoards: description: CategoryBoards is a board category and associated boards x-go-package: github.com/mattermost/focalboard/server/model ChangePasswordRequest: description: ChangePasswordRequest is a user password change request x-go-package: github.com/mattermost/focalboard/server/model ClientConfig: description: ClientConfig is the client configuration x-go-package: github.com/mattermost/focalboard/server/model DeleteBoardsAndBlocks: description: |- DeleteBoardsAndBlocks is used to list the boards and blocks to delete on a request x-go-package: github.com/mattermost/focalboard/server/model ErrorResponse: description: ErrorResponse is an error response x-go-package: github.com/mattermost/focalboard/server/model FileUploadResponse: description: FileUploadResponse is the response to a file upload x-go-package: github.com/mattermost/focalboard/server/api LoginRequest: description: LoginRequest is a login request x-go-package: github.com/mattermost/focalboard/server/model LoginResponse: description: LoginResponse is a login response x-go-package: github.com/mattermost/focalboard/server/model NotificationHint: description: |- NotificationHint provides a hint that a block has been modified and has subscribers that should be notified. x-go-package: github.com/mattermost/focalboard/server/model PatchBoardsAndBlocks: description: |- PatchBoardsAndBlocks is used to patch multiple boards and blocks on a single request x-go-package: github.com/mattermost/focalboard/server/model RegisterRequest: description: RegisterRequest is a user registration request x-go-package: github.com/mattermost/focalboard/server/model Sharing: description: Sharing is sharing information for a root block x-go-package: github.com/mattermost/focalboard/server/model Subscriber: description: Subscriber is an entity (e.g. user, channel) that can subscribe to events from boards, cards, etc x-go-package: github.com/mattermost/focalboard/server/model Subscription: title: Subscription is a subscription to a board, card, etc, for a user or channel. x-go-package: github.com/mattermost/focalboard/server/model Team: description: Team is information global to a team x-go-package: github.com/mattermost/focalboard/server/model User: description: User is a user x-go-package: github.com/mattermost/focalboard/server/model UserPreferencesPatch: description: UserPreferencesPatch is a user property patch x-go-package: github.com/mattermost/focalboard/server/model host: localhost info: contact: email: api@focalboard.com name: Focalboard url: https://www.focalboard.com description: Focalboard Server license: name: Custom url: https://github.com/mattermost/focalboard/blob/main/LICENSE.txt title: Focalboard Server version: 2.0.0 paths: /api/v2/teams/{teamID}/notifyadminupgrade: get: operationId: handleNotifyAdminUpgrade parameters: - description: Team ID in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Notifies admins for upgrade request. /boards: post: description: Creates a new board operationId: createBoard parameters: - description: the board to create in: body name: Body required: true schema: $ref: '#/definitions/Board' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Board' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards-and-blocks: delete: description: Deletes boards and blocks operationId: deleteBoardsAndBlocks parameters: - description: the boards and blocks to delete in: body name: Body required: true schema: $ref: '#/definitions/DeleteBoardsAndBlocks' produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] patch: description: Patches a set of related boards and blocks operationId: patchBoardsAndBlocks parameters: - description: the patches for the boards and blocks in: body name: Body required: true schema: $ref: '#/definitions/PatchBoardsAndBlocks' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardsAndBlocks' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] post: description: Creates new boards and blocks operationId: insertBoardsAndBlocks parameters: - description: the boards and blocks to create in: body name: Body required: true schema: $ref: '#/definitions/BoardsAndBlocks' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardsAndBlocks' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}: delete: description: Removes a board operationId: deleteBoard parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] get: description: Returns a board operationId: getBoard parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Board' "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] patch: description: Partially updates a board operationId: patchBoard parameters: - description: Board ID in: path name: boardID required: true type: string - description: board patch to apply in: body name: Body required: true schema: $ref: '#/definitions/BoardPatch' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Board' "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/archive/export: get: operationId: archiveExportBoard parameters: - description: Id of board to export in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Exports an archive of all blocks for one boards. /boards/{boardID}/blocks: get: description: Returns blocks operationId: getBlocks parameters: - description: Board ID in: path name: boardID required: true type: string - description: ID of parent block, omit to specify all blocks in: query name: parent_id type: string - description: Type of blocks to return, omit to specify all types in: query name: type type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Block' type: array "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] post: description: |- Insert blocks. The specified IDs will only be used to link blocks with existing ones, the rest will be replaced by server generated IDs operationId: updateBlocks parameters: - description: Board ID in: path name: boardID required: true type: string - description: Disables notifications (for bulk inserting) in: query name: disable_notify type: bool - description: array of blocks to insert or update in: body name: Body required: true schema: items: $ref: '#/definitions/Block' type: array produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Block' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/blocks/: patch: description: Partially updates batch of blocks operationId: patchBlocks parameters: - description: Workspace ID in: path name: boardID required: true type: string - description: Disables notifications (for bulk patching) in: query name: disable_notify type: bool - description: block Ids and block patches to apply in: body name: Body required: true schema: $ref: '#/definitions/BlockPatchBatch' produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/blocks/{blockID}: delete: description: Deletes a block operationId: deleteBlock parameters: - description: Board ID in: path name: boardID required: true type: string - description: ID of block to delete in: path name: blockID required: true type: string - description: Disables notifications (for bulk deletion) in: query name: disable_notify type: bool produces: - application/json responses: "200": description: success "404": description: block not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] patch: description: Partially updates a block operationId: patchBlock parameters: - description: Board ID in: path name: boardID required: true type: string - description: ID of block to patch in: path name: blockID required: true type: string - description: Disables notifications (for bulk patching) in: query name: disable_notify type: bool - description: block patch to apply in: body name: Body required: true schema: $ref: '#/definitions/BlockPatch' produces: - application/json responses: "200": description: success "404": description: block not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/blocks/{blockID}/duplicate: post: description: Returns the new created blocks operationId: duplicateBlock parameters: - description: Board ID in: path name: boardID required: true type: string - description: Block ID in: path name: blockID required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Block' type: array "404": description: board or block not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/blocks/{blockID}/undelete: post: description: Undeletes a block operationId: undeleteBlock parameters: - description: Board ID in: path name: boardID required: true type: string - description: ID of block to undelete in: path name: blockID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BlockPatch' "404": description: block not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/cards: get: operationId: getCards parameters: - description: Board ID in: path name: boardID required: true type: string - description: The page to select (default=0) in: query name: page type: integer - description: Number of cards to return per page(default=100) in: query name: per_page type: integer produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Card' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Fetches cards for the specified board. post: operationId: createCard parameters: - description: Board ID in: path name: boardID required: true type: string - description: the card to create in: body name: Body required: true schema: $ref: '#/definitions/Card' - description: Disables notifications (for bulk data inserting) in: query name: disable_notify type: bool produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Card' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Creates a new card for the specified board. /boards/{boardID}/duplicate: post: description: Returns the new created board and all the blocks operationId: duplicateBoard parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardsAndBlocks' "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/join: post: description: Become a member of a board operationId: joinBoard parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardMember' "403": description: access denied "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/leave: post: description: Remove your own membership from a board operationId: leaveBoard parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success "403": description: access denied "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/members: get: description: Returns the members of the board operationId: getMembersForBoard parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/BoardMember' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] post: description: Adds a new member to a board operationId: addMember parameters: - description: Board ID in: path name: boardID required: true type: string - description: membership to replace the current one with in: body name: Body required: true schema: $ref: '#/definitions/BoardMember' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardMember' "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/members/{userID}: delete: description: Deletes a member from a board operationId: deleteMember parameters: - description: Board ID in: path name: boardID required: true type: string - description: User ID in: path name: userID required: true type: string produces: - application/json responses: "200": description: success "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] put: description: Updates a board member operationId: updateMember parameters: - description: Board ID in: path name: boardID required: true type: string - description: User ID in: path name: userID required: true type: string - description: membership to replace the current one with in: body name: Body required: true schema: $ref: '#/definitions/BoardMember' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardMember' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/metadata: get: description: Returns a board's metadata operationId: getBoardMetadata parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardMetadata' "404": description: board not found "501": description: required license not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/sharing: get: description: Returns sharing information for a board operationId: getSharing parameters: - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Sharing' "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] post: description: Sets sharing information for a board operationId: postSharing parameters: - description: Board ID in: path name: boardID required: true type: string - description: sharing information for a root block in: body name: Body required: true schema: $ref: '#/definitions/Sharing' produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/{boardID}/undelete: post: description: Undeletes a board operationId: undeleteBoard parameters: - description: ID of board to undelete in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /boards/search: get: description: Returns the boards that match with a search term operationId: searchAllBoards parameters: - description: The search term. Must have at least one character in: query name: q required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Board' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /cards/{cardID}: get: operationId: getCard parameters: - description: Card ID in: path name: cardID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Card' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Fetches the specified card. /cards/{cardID}/cards: patch: operationId: patchCard parameters: - description: Card ID in: path name: cardID required: true type: string - description: the card patch in: body name: Body required: true schema: $ref: '#/definitions/CardPatch' - description: Disables notifications (for bulk data patching) in: query name: disable_notify type: bool produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Card' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Patches the specified card. /clientConfig: get: description: Returns the client configuration operationId: getClientConfig produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/ClientConfig' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' /files/teams/{teamID}/{boardID}/{filename}: get: description: Returns the contents of an uploaded file operationId: getFile parameters: - description: Team ID in: path name: teamID required: true type: string - description: Board ID in: path name: boardID required: true type: string - description: name of the file in: path name: filename required: true type: string produces: - application/json - image/jpg - image/png - image/gif responses: "200": description: success "404": description: file not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /hello: get: operationId: hello produces: - text/plain responses: "200": description: success summary: Responds with `Hello` if the web service is running. /limits: get: operationId: cloudLimits produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardsCloudLimits' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Fetches the cloud limits of the server. /login: post: description: Login user operationId: login parameters: - description: Login request in: body name: body required: true schema: $ref: '#/definitions/LoginRequest' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/LoginResponse' "401": description: invalid login schema: $ref: '#/definitions/ErrorResponse' "500": description: internal error schema: $ref: '#/definitions/ErrorResponse' /logout: post: description: Logout user operationId: logout produces: - application/json responses: "200": description: success "500": description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /ping: get: operationId: ping produces: - application/json responses: "200": description: success summary: Responds with server metadata if the web service is running. /register: post: description: Register new user operationId: register parameters: - description: Register request in: body name: body required: true schema: $ref: '#/definitions/RegisterRequest' produces: - application/json responses: "200": description: success "401": description: invalid registration token "500": description: internal error schema: $ref: '#/definitions/ErrorResponse' /statistics: get: operationId: handleStatistics produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/BoardStatistics' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Fetches the statistic of the server. /subscriptions: post: operationId: createSubscription parameters: - description: subscription definition in: body name: Body required: true schema: $ref: '#/definitions/Subscription' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/User' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Creates a subscription to a block for a user. The user will receive change notifications for the block. /subscriptions/{blockID}/{subscriberID}: delete: operationId: deleteSubscription parameters: - description: Block ID in: path name: blockID required: true type: string - description: Subscriber ID in: path name: subscriberID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Deletes a subscription a user has for a a block. The user will no longer receive change notifications for the block. /subscriptions/{subscriberID}: get: operationId: getSubscriptions parameters: - description: Subscriber ID in: path name: subscriberID required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/User' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Gets subscriptions for a user. /team/{teamID}/onboard: post: operationId: onboard parameters: - description: Team ID in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success schema: properties: boardID: description: Board ID type: string teamID: description: Team ID type: string type: object default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Onboards a user on Boards. /teams: get: description: Returns information of all the teams operationId: getTeams produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Team' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}: get: description: Returns information of the root team operationId: getTeam parameters: - description: Team ID in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Team' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/archive/export: get: operationId: archiveExportTeam parameters: - description: Id of team in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Exports an archive of all blocks for all the boards in a team. /teams/{teamID}/archive/import: post: consumes: - multipart/form-data operationId: archiveImport parameters: - description: Team ID in: path name: teamID required: true type: string - description: archive file to import in: formData name: file required: true type: file produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] summary: Import an archive of boards. /teams/{teamID}/boards: get: description: Returns team boards operationId: getBoards parameters: - description: Team ID in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Board' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/boards/{boardID}/files: post: consumes: - multipart/form-data description: Upload a binary file, attached to a root block operationId: uploadFile parameters: - description: ID of the team in: path name: teamID required: true type: string - description: Board ID in: path name: boardID required: true type: string - description: The file to upload in: formData name: uploaded file type: file produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/FileUploadResponse' "404": description: board not found default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/boards/insights: get: description: Returns team boards insights operationId: handleTeamBoardsInsights parameters: - description: Team ID in: path name: teamID required: true type: string - description: duration of data to calculate insights for in: query name: time_range required: true type: string - description: page offset for top boards in: query name: page required: true type: string - description: limit for boards in a page. in: query name: per_page required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/BoardInsight' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/boards/search: get: description: Returns the boards that match with a search term in the team operationId: searchBoards parameters: - description: Team ID in: path name: teamID required: true type: string - description: The search term. Must have at least one character in: query name: q required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Board' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/boards/search/linkable: get: description: |- Returns the boards that match with a search term in the team and the user has permission to manage members operationId: searchLinkableBoards parameters: - description: Team ID in: path name: teamID required: true type: string - description: The search term. Must have at least one character in: query name: q required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Board' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/categories: get: description: Gets the user's board categories operationId: getUserCategoryBoards parameters: - description: Team ID in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/CategoryBoards' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] post: description: Create a category for boards operationId: createCategory parameters: - description: Team ID in: path name: teamID required: true type: string - description: category to create in: body name: Body required: true schema: $ref: '#/definitions/Category' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Category' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/categories/{categoryID}: delete: description: Delete a category operationId: deleteCategory parameters: - description: Team ID in: path name: teamID required: true type: string - description: Category ID in: path name: categoryID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] put: description: Create a category for boards operationId: updateCategory parameters: - description: Team ID in: path name: teamID required: true type: string - description: Category ID in: path name: categoryID required: true type: string - description: category to update in: body name: Body required: true schema: $ref: '#/definitions/Category' produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Category' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/categories/{categoryID}/boards/{boardID}: post: description: Set the category of a board operationId: updateCategoryBoard parameters: - description: Team ID in: path name: teamID required: true type: string - description: Category ID in: path name: categoryID required: true type: string - description: Board ID in: path name: boardID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/channels: get: description: Returns the user available channels operationId: searchMyChannels parameters: - description: Team ID in: path name: teamID required: true type: string - description: string to filter channels list in: query name: search type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Channel' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/channels/{channelID}: get: description: Returns the requested channel operationId: getChannel parameters: - description: Team ID in: path name: teamID required: true type: string - description: Channel ID in: path name: channelID required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Channel' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/regenerate_signup_token: post: description: Regenerates the signup token for the root team operationId: regenerateSignupToken parameters: - description: Team ID in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/templates: get: description: Returns team templates operationId: getTemplates parameters: - description: Team ID in: path name: teamID required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/Board' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /teams/{teamID}/users: get: description: Returns team users operationId: getTeamUsers parameters: - description: Team ID in: path name: teamID required: true type: string - description: string to filter users list in: query name: search type: string - description: exclude bot users in: query name: exclude_bots type: boolean produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/User' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users: post: description: Returns a user[] operationId: getUsersList parameters: - description: User ID in: path name: userID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/User' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users/{userID}: get: description: Returns a user operationId: getUser parameters: - description: User ID in: path name: userID required: true type: string produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/User' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users/{userID}/changepassword: post: description: Change a user's password operationId: changePassword parameters: - description: User ID in: path name: userID required: true type: string - description: Change password request in: body name: body required: true schema: $ref: '#/definitions/ChangePasswordRequest' produces: - application/json responses: "200": description: success "400": description: invalid request schema: $ref: '#/definitions/ErrorResponse' "500": description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users/{userID}/config: patch: description: Updates user config operationId: updateUserConfig parameters: - description: User ID in: path name: userID required: true type: string - description: User config patch to apply in: body name: Body required: true schema: $ref: '#/definitions/UserPreferencesPatch' produces: - application/json responses: "200": description: success default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users/me: get: description: Returns the currently logged-in user operationId: getMe produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/User' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users/me/boards/insights: get: description: Returns user boards insights operationId: getUserBoardsInsights parameters: - description: Team ID in: path name: teamID required: true type: string - description: duration of data to calculate insights for in: query name: time_range required: true type: string - description: page offset for top boards in: query name: page required: true type: string - description: limit for boards in a page. in: query name: per_page required: true type: string produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/BoardInsight' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users/me/config: get: description: Returns an array of user preferences operationId: getUserConfig produces: - application/json responses: "200": description: success schema: $ref: '#/definitions/Preferences' default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] /users/me/memberships: get: description: Returns the currently users board memberships operationId: getMyMemberships produces: - application/json responses: "200": description: success schema: items: $ref: '#/definitions/BoardMember' type: array default: description: internal error schema: $ref: '#/definitions/ErrorResponse' security: - BearerAuth: [] produces: - application/json schemes: - http - https securityDefinitions: BearerAuth: description: 'Pass session token using Bearer authentication, e.g. set header "Authorization: Bearer "' in: header name: Authorization type: apiKey swagger: "2.0" ================================================ FILE: server/utils/callbackqueue.go ================================================ package utils import ( "context" "runtime/debug" "sync/atomic" "time" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // CallbackFunc is a func that can enqueued in the callback queue and will be // called when dequeued. type CallbackFunc func() error // CallbackQueue provides a simple thread pool for processing callbacks. Callbacks will // be executed in the order in which they are enqueued, but no guarantees are provided // regarding the order in which they finish (unless poolSize == 1). type CallbackQueue struct { name string poolSize int queue chan CallbackFunc done chan struct{} alive chan int idone uint32 logger mlog.LoggerIFace } // NewCallbackQueue creates a new CallbackQueue and starts a thread pool to service it. func NewCallbackQueue(name string, queueSize int, poolSize int, logger mlog.LoggerIFace) *CallbackQueue { cn := &CallbackQueue{ name: name, poolSize: poolSize, queue: make(chan CallbackFunc, queueSize), done: make(chan struct{}), alive: make(chan int, poolSize), logger: logger, } for i := 0; i < poolSize; i++ { go cn.loop(i) } return cn } // Shutdown stops accepting enqueues and exits all pool threads. This method waits // as long as the context allows for the threads to exit. // Returns true if the pool exited, false on timeout. func (cn *CallbackQueue) Shutdown(context context.Context) bool { if !atomic.CompareAndSwapUint32(&cn.idone, 0, 1) { // already shutdown return true } // signal threads to exit close(cn.done) // wait for the threads to exit or timeout count := 0 for count < cn.poolSize { select { case <-cn.alive: count++ case <-context.Done(): return false } } // try to drain any remaining callbacks for { select { case f := <-cn.queue: cn.exec(f) case <-context.Done(): return false default: return true } } } // Enqueue adds a callback to the queue. func (cn *CallbackQueue) Enqueue(f CallbackFunc) { if atomic.LoadUint32(&cn.idone) != 0 { cn.logger.Debug("CallbackQueue skipping enqueue, notifier is shutdown", mlog.String("name", cn.name)) return } select { case cn.queue <- f: default: start := time.Now() cn.queue <- f dur := time.Since(start) cn.logger.Warn("CallbackQueue queue backlog", mlog.String("name", cn.name), mlog.Duration("wait_time", dur)) } } func (cn *CallbackQueue) loop(id int) { defer func() { cn.logger.Trace("CallbackQueue thread exited", mlog.String("name", cn.name), mlog.Int("id", id)) cn.alive <- id }() for { select { case f := <-cn.queue: cn.exec(f) case <-cn.done: return } } } func (cn *CallbackQueue) exec(f CallbackFunc) { // don't let a panic in the callback exit the thread. defer func() { if r := recover(); r != nil { stack := debug.Stack() cn.logger.Error("CallbackQueue callback panic", mlog.String("name", cn.name), mlog.Any("panic", r), mlog.String("stack", string(stack)), ) } }() if err := f(); err != nil { cn.logger.Error("CallbackQueue callback error", mlog.String("name", cn.name), mlog.Err(err)) } } ================================================ FILE: server/utils/callbackqueue_test.go ================================================ package utils import ( "context" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func Test_newChangeNotifier(t *testing.T) { logger := mlog.CreateConsoleTestLogger(t) t.Run("startup, shutdown", func(t *testing.T) { cn := NewCallbackQueue("test1", 100, 5, logger) var callbackCount int32 callback := func() error { atomic.AddInt32(&callbackCount, 1) return nil } const loops = 500 for i := 0; i < loops; i++ { cn.Enqueue(callback) // don't peg the cpu if i%20 == 0 { time.Sleep(time.Millisecond * 1) } } ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() ok := cn.Shutdown(ctx) assert.True(t, ok, "shutdown should return true (no timeout)") assert.Equal(t, int32(loops), atomic.LoadInt32(&callbackCount)) }) t.Run("handle panic", func(t *testing.T) { cn := NewCallbackQueue("test2", 100, 5, logger) var callbackCount int32 callback := func() error { atomic.AddInt32(&callbackCount, 1) panic("oh no!") } const loops = 5 for i := 0; i < loops; i++ { cn.Enqueue(callback) } ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() ok := cn.Shutdown(ctx) assert.True(t, ok, "shutdown should return true (no timeout)") assert.Equal(t, int32(loops), atomic.LoadInt32(&callbackCount)) }) } ================================================ FILE: server/utils/debug.go ================================================ package utils import ( "os" "strings" ) // IsRunningUnitTests returns true if this instance of FocalBoard is running unit or integration tests. func IsRunningUnitTests() bool { testing := os.Getenv("FOCALBOARD_UNIT_TESTING") if testing == "" { return false } switch strings.ToLower(testing) { case "1", "t", "y", "true", "yes": return true } return false } ================================================ FILE: server/utils/links.go ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package utils import "fmt" // MakeCardLink creates fully qualified card links based on card id and parents. func MakeCardLink(serverRoot string, teamID string, boardID string, cardID string) string { return fmt.Sprintf("%s/team/%s/%s/0/%s", serverRoot, teamID, boardID, cardID) } func MakeBoardLink(serverRoot string, teamID string, board string) string { return fmt.Sprintf("%s/team/%s/%s", serverRoot, teamID, board) } ================================================ FILE: server/utils/testUtils.go ================================================ package utils import "github.com/stretchr/testify/mock" var Anything = mock.MatchedBy(func(interface{}) bool { return true }) ================================================ FILE: server/utils/utils.go ================================================ package utils import ( "encoding/json" "path" "reflect" "time" mmModel "github.com/mattermost/mattermost/server/public/model" ) type IDType byte const ( IDTypeNone IDType = '7' IDTypeTeam IDType = 't' IDTypeBoard IDType = 'b' IDTypeCard IDType = 'c' IDTypeView IDType = 'v' IDTypeSession IDType = 's' IDTypeUser IDType = 'u' IDTypeToken IDType = 'k' IDTypeBlock IDType = 'a' IDTypeAttachment IDType = 'i' ) // NewId is a globally unique identifier. It is a [A-Z0-9] string 27 // characters long. It is a UUID version 4 Guid that is zbased32 encoded // with the padding stripped off, and a one character alpha prefix indicating the // type of entity or a `7` if unknown type. func NewID(idType IDType) string { return string(idType) + mmModel.NewId() } // GetMillis is a convenience method to get milliseconds since epoch. func GetMillis() int64 { return mmModel.GetMillis() } // GetMillisForTime is a convenience method to get milliseconds since epoch for provided Time. func GetMillisForTime(thisTime time.Time) int64 { return mmModel.GetMillisForTime(thisTime) } // GetTimeForMillis is a convenience method to get time.Time for milliseconds since epoch. func GetTimeForMillis(millis int64) time.Time { return mmModel.GetTimeForMillis(millis) } // SecondsToMillis is a convenience method to convert seconds to milliseconds. func SecondsToMillis(seconds int64) int64 { return seconds * 1000 } func StructToMap(v interface{}) (m map[string]interface{}) { b, _ := json.Marshal(v) _ = json.Unmarshal(b, &m) return } func intersection(a []interface{}, b []interface{}) []interface{} { set := make([]interface{}, 0) hash := make(map[interface{}]bool) av := reflect.ValueOf(a) bv := reflect.ValueOf(b) for i := 0; i < av.Len(); i++ { el := av.Index(i).Interface() hash[el] = true } for i := 0; i < bv.Len(); i++ { el := bv.Index(i).Interface() if _, found := hash[el]; found { set = append(set, el) } } return set } func Intersection(x ...[]interface{}) []interface{} { if len(x) == 0 { return nil } if len(x) == 1 { return x[0] } result := x[0] i := 1 for i < len(x) { result = intersection(result, x[i]) i++ } return result } func IsCloudLicense(license *mmModel.License) bool { return license != nil && license.Features != nil && license.Features.Cloud != nil && *license.Features.Cloud } func DedupeStringArr(arr []string) []string { hashMap := map[string]bool{} for _, item := range arr { hashMap[item] = true } dedupedArr := make([]string, len(hashMap)) i := 0 for key := range hashMap { dedupedArr[i] = key i++ } return dedupedArr } func GetBaseFilePath() string { return path.Join("boards", time.Now().Format("20060102")) } ================================================ FILE: server/web/webserver.go ================================================ package web import ( "errors" "fmt" "net/http" "net/url" "os" "path" "path/filepath" "strings" "text/template" "github.com/gorilla/mux" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // RoutedService defines the interface that is needed for any service to // register themself in the web server to provide new endpoints. (see // AddRoutes). type RoutedService interface { RegisterRoutes(*mux.Router) } // Server is the structure responsible for managing our http web server. type Server struct { http.Server baseURL string rootPath string basePrefix string port int ssl bool logger mlog.LoggerIFace } // NewServer creates a new instance of the webserver. func NewServer(rootPath string, serverRoot string, port int, ssl, localOnly bool, logger mlog.LoggerIFace) *Server { r := mux.NewRouter() basePrefix := os.Getenv("FOCALBOARD_HTTP_SERVER_BASEPATH") if basePrefix != "" { r = r.PathPrefix(basePrefix).Subrouter() } var addr string if localOnly { addr = fmt.Sprintf(`localhost:%d`, port) } else { addr = fmt.Sprintf(`:%d`, port) } baseURL := "" url, err := url.Parse(serverRoot) if err != nil { logger.Error("Invalid ServerRoot setting", mlog.Err(err)) } baseURL = url.Path ws := &Server{ // (TODO: Add ReadHeaderTimeout) Server: http.Server{ //nolint:gosec Addr: addr, Handler: r, }, baseURL: baseURL, rootPath: rootPath, port: port, ssl: ssl, logger: logger, basePrefix: basePrefix, } return ws } func (ws *Server) Router() *mux.Router { return ws.Server.Handler.(*mux.Router) } // AddRoutes allows services to register themself in the webserver router and provide new endpoints. func (ws *Server) AddRoutes(rs RoutedService) { rs.RegisterRoutes(ws.Router()) } func (ws *Server) registerRoutes() { ws.Router().PathPrefix("/static").Handler(http.StripPrefix(ws.basePrefix+"/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static"))))) ws.Router().PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") indexTemplate, err := template.New("index").ParseFiles(path.Join(ws.rootPath, "index.html")) if err != nil { ws.logger.Log(errorOrWarn(), "Unable to serve the index.html file", mlog.Err(err)) w.WriteHeader(http.StatusInternalServerError) return } err = indexTemplate.ExecuteTemplate(w, "index.html", map[string]string{"BaseURL": ws.baseURL}) if err != nil { ws.logger.Log(errorOrWarn(), "Unable to serve the index.html file", mlog.Err(err)) w.WriteHeader(http.StatusInternalServerError) return } }) } // Start runs the web server and start listening for connections. func (ws *Server) Start() { ws.registerRoutes() if ws.port == -1 { ws.logger.Debug("server not bind to any port") return } isSSL := ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem") if isSSL { ws.logger.Info("https server started", mlog.Int("port", ws.port)) go func() { if err := ws.ListenAndServeTLS("./cert/cert.pem", "./cert/key.pem"); err != nil { ws.logger.Fatal("ListenAndServeTLS", mlog.Err(err)) } }() return } ws.logger.Info("http server started", mlog.Int("port", ws.port)) go func() { if err := ws.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { ws.logger.Fatal("ListenAndServeTLS", mlog.Err(err)) } ws.logger.Info("http server stopped") }() } func (ws *Server) Shutdown() error { return ws.Close() } // fileExists returns true if a file exists at the path. func fileExists(path string) bool { _, err := os.Stat(path) if os.IsNotExist(err) { return false } return err == nil } // errorOrWarn returns a `warn` level if this server instance is running unit tests, otherwise `error`. func errorOrWarn() mlog.Level { unitTesting := strings.ToLower(strings.TrimSpace(os.Getenv("FOCALBOARD_UNIT_TESTING"))) if unitTesting == "1" || unitTesting == "y" || unitTesting == "t" { return mlog.LvlWarn } return mlog.LvlError } ================================================ FILE: server/web/webserver_test.go ================================================ package web import ( "testing" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func Test_NewServer(t *testing.T) { tests := []struct { name string rootPath string serverRoot string ssl bool port int localOnly bool logger mlog.LoggerIFace expectedBaseURL string expectedServerAddr string }{ { name: "should return server with given properties", rootPath: "./test/path/to/root", serverRoot: "https://some-fake-server.com/fake-url", ssl: false, port: 9999, // fake port number localOnly: false, logger: &mlog.Logger{}, expectedBaseURL: "/fake-url", expectedServerAddr: ":9999", }, { name: "should return local server with given properties", rootPath: "./test/path/to/root", serverRoot: "https://some-fake-server.com/fake-url", ssl: false, port: 3000, // fake port number localOnly: true, logger: &mlog.Logger{}, expectedBaseURL: "/fake-url", expectedServerAddr: "localhost:3000", }, { name: "should match Server properties when ssl true", rootPath: "./test/path/to/root", serverRoot: "https://some-fake-server.com/fake-url", ssl: true, port: 8000, // fake port number localOnly: false, logger: &mlog.Logger{}, expectedBaseURL: "/fake-url", expectedServerAddr: ":8000", }, { name: "should return local server when ssl true", rootPath: "./test/path/to/root", serverRoot: "https://localhost:8080/fake-url", ssl: true, port: 9999, // fake port number localOnly: true, logger: &mlog.Logger{}, expectedBaseURL: "/fake-url", expectedServerAddr: "localhost:9999", }, { name: "should return '/' as base url is not good!", rootPath: "", serverRoot: "https://localhost:8080/#!@$@#@", ssl: true, port: 9999, // fake port number localOnly: true, logger: &mlog.Logger{}, expectedBaseURL: "/", expectedServerAddr: "localhost:9999", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { ws := NewServer(test.rootPath, test.serverRoot, test.port, test.ssl, test.localOnly, test.logger) require.NotNil(t, ws, "The webserver object is nil!") require.Equal(t, test.expectedBaseURL, ws.baseURL, "baseURL does not match") require.Equal(t, test.rootPath, ws.rootPath, "rootPath does not match") require.Equal(t, test.port, ws.port, "rootPath does not match") require.Equal(t, test.ssl, ws.ssl, "logger pointer does not match") require.Equal(t, test.logger, ws.logger, "logger pointer does not match") if test.localOnly == true { require.Equal(t, test.expectedServerAddr, ws.Server.Addr, "localhost address not as matching!") } else { require.Equal(t, test.expectedServerAddr, ws.Server.Addr, "server address not matching!") } }) } } ================================================ FILE: server/ws/adapter.go ================================================ //go:generate mockgen -destination=mocks/mockstore.go -package mocks . Store package ws import ( "github.com/mattermost/focalboard/server/model" ) const ( websocketActionAuth = "AUTH" websocketActionSubscribeTeam = "SUBSCRIBE_TEAM" websocketActionUnsubscribeTeam = "UNSUBSCRIBE_TEAM" websocketActionSubscribeBlocks = "SUBSCRIBE_BLOCKS" websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS" websocketActionUpdateBoard = "UPDATE_BOARD" websocketActionUpdateMember = "UPDATE_MEMBER" websocketActionDeleteMember = "DELETE_MEMBER" websocketActionUpdateBlock = "UPDATE_BLOCK" websocketActionUpdateConfig = "UPDATE_CLIENT_CONFIG" websocketActionUpdateCategory = "UPDATE_CATEGORY" websocketActionUpdateCategoryBoard = "UPDATE_BOARD_CATEGORY" websocketActionUpdateSubscription = "UPDATE_SUBSCRIPTION" websocketActionUpdateCardLimitTimestamp = "UPDATE_CARD_LIMIT_TIMESTAMP" websocketActionReorderCategories = "REORDER_CATEGORIES" websocketActionReorderCategoryBoards = "REORDER_CATEGORY_BOARDS" ) type Store interface { GetBlock(blockID string) (*model.Block, error) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) } type Adapter interface { BroadcastBlockChange(teamID string, block *model.Block) BroadcastBlockDelete(teamID, blockID, boardID string) BroadcastBoardChange(teamID string, board *model.Board) BroadcastBoardDelete(teamID, boardID string) BroadcastMemberChange(teamID, boardID string, member *model.BoardMember) BroadcastMemberDelete(teamID, boardID, userID string) BroadcastConfigChange(clientConfig model.ClientConfig) BroadcastCategoryChange(category model.Category) BroadcastCategoryBoardChange(teamID, userID string, blockCategory []*model.BoardCategoryWebsocketData) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string) } ================================================ FILE: server/ws/common.go ================================================ package ws import ( "github.com/mattermost/focalboard/server/model" ) // UpdateCategoryMessage is sent on block updates. type UpdateCategoryMessage struct { Action string `json:"action"` TeamID string `json:"teamId"` Category *model.Category `json:"category,omitempty"` BoardCategories []*model.BoardCategoryWebsocketData `json:"blockCategories,omitempty"` } // UpdateBlockMsg is sent on block updates. type UpdateBlockMsg struct { Action string `json:"action"` TeamID string `json:"teamId"` Block *model.Block `json:"block"` } // UpdateBoardMsg is sent on block updates. type UpdateBoardMsg struct { Action string `json:"action"` TeamID string `json:"teamId"` Board *model.Board `json:"board"` } // UpdateMemberMsg is sent on membership updates. type UpdateMemberMsg struct { Action string `json:"action"` TeamID string `json:"teamId"` Member *model.BoardMember `json:"member"` } // UpdateSubscription is sent on subscription updates. type UpdateSubscription struct { Action string `json:"action"` Subscription *model.Subscription `json:"subscription"` } // UpdateClientConfig is sent on block updates. type UpdateClientConfig struct { Action string `json:"action"` ClientConfig model.ClientConfig `json:"clientconfig"` } // UpdateClientConfig is sent on block updates. type UpdateCardLimitTimestamp struct { Action string `json:"action"` Timestamp int64 `json:"timestamp"` } // WebsocketCommand is an incoming command from the client. type WebsocketCommand struct { Action string `json:"action"` TeamID string `json:"teamId"` Token string `json:"token"` ReadToken string `json:"readToken"` BlockIDs []string `json:"blockIds"` } type CategoryReorderMessage struct { Action string `json:"action"` CategoryOrder []string `json:"categoryOrder"` TeamID string `json:"teamId"` } type CategoryBoardReorderMessage struct { Action string `json:"action"` CategoryID string `json:"CategoryId"` BoardOrder []string `json:"BoardOrder"` TeamID string `json:"teamId"` } ================================================ FILE: server/ws/helpers_test.go ================================================ package ws import ( "testing" authMocks "github.com/mattermost/focalboard/server/auth/mocks" wsMocks "github.com/mattermost/focalboard/server/ws/mocks" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/golang/mock/gomock" ) type TestHelper struct { api *wsMocks.MockAPI auth *authMocks.MockAuthInterface store *wsMocks.MockStore ctrl *gomock.Controller pa *PluginAdapter } func SetupTestHelper(t *testing.T) *TestHelper { ctrl := gomock.NewController(t) mockAPI := wsMocks.NewMockAPI(ctrl) mockAuth := authMocks.NewMockAuthInterface(ctrl) mockStore := wsMocks.NewMockStore(ctrl) mockAPI.EXPECT().LogDebug(gomock.Any(), gomock.Any()).AnyTimes() mockAPI.EXPECT().LogInfo(gomock.Any(), gomock.Any()).AnyTimes() mockAPI.EXPECT().LogError(gomock.Any(), gomock.Any()).AnyTimes() mockAPI.EXPECT().LogWarn(gomock.Any(), gomock.Any()).AnyTimes() return &TestHelper{ api: mockAPI, auth: mockAuth, store: mockStore, ctrl: ctrl, pa: NewPluginAdapter(mockAPI, mockAuth, mockStore, mlog.CreateConsoleTestLogger(t)), } } func (th *TestHelper) ReceiveWebSocketMessage(webConnID, userID, action string, data map[string]interface{}) { req := &mmModel.WebSocketRequest{Action: websocketMessagePrefix + action, Data: data} th.pa.WebSocketMessageHasBeenPosted(webConnID, userID, req) } func (th *TestHelper) SubscribeWebConnToTeam(webConnID, userID, teamID string) { th.auth.EXPECT(). DoesUserHaveTeamAccess(userID, teamID). Return(true) msgData := map[string]interface{}{"teamId": teamID} th.ReceiveWebSocketMessage(webConnID, userID, websocketActionSubscribeTeam, msgData) } func (th *TestHelper) UnsubscribeWebConnFromTeam(webConnID, userID, teamID string) { msgData := map[string]interface{}{"teamId": teamID} th.ReceiveWebSocketMessage(webConnID, userID, websocketActionUnsubscribeTeam, msgData) } ================================================ FILE: server/ws/mocks/mockpluginapi.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/mattermost-server/v6/plugin (interfaces: API) // Package mocks is a generated GoMock package. package mocks import ( io "io" http "net/http" reflect "reflect" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/mattermost/server/public/model" ) // MockAPI is a mock of API interface. type MockAPI struct { ctrl *gomock.Controller recorder *MockAPIMockRecorder } // MockAPIMockRecorder is the mock recorder for MockAPI. type MockAPIMockRecorder struct { mock *MockAPI } // NewMockAPI creates a new mock instance. func NewMockAPI(ctrl *gomock.Controller) *MockAPI { mock := &MockAPI{ctrl: ctrl} mock.recorder = &MockAPIMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockAPI) EXPECT() *MockAPIMockRecorder { return m.recorder } // AddChannelMember mocks base method. func (m *MockAPI) AddChannelMember(arg0, arg1 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddChannelMember", arg0, arg1) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // AddChannelMember indicates an expected call of AddChannelMember. func (mr *MockAPIMockRecorder) AddChannelMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddChannelMember", reflect.TypeOf((*MockAPI)(nil).AddChannelMember), arg0, arg1) } // AddReaction mocks base method. func (m *MockAPI) AddReaction(arg0 *model.Reaction) (*model.Reaction, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddReaction", arg0) ret0, _ := ret[0].(*model.Reaction) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // AddReaction indicates an expected call of AddReaction. func (mr *MockAPIMockRecorder) AddReaction(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddReaction", reflect.TypeOf((*MockAPI)(nil).AddReaction), arg0) } // AddUserToChannel mocks base method. func (m *MockAPI) AddUserToChannel(arg0, arg1, arg2 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddUserToChannel", arg0, arg1, arg2) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // AddUserToChannel indicates an expected call of AddUserToChannel. func (mr *MockAPIMockRecorder) AddUserToChannel(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUserToChannel", reflect.TypeOf((*MockAPI)(nil).AddUserToChannel), arg0, arg1, arg2) } // CopyFileInfos mocks base method. func (m *MockAPI) CopyFileInfos(arg0 string, arg1 []string) ([]string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CopyFileInfos", arg0, arg1) ret0, _ := ret[0].([]string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CopyFileInfos indicates an expected call of CopyFileInfos. func (mr *MockAPIMockRecorder) CopyFileInfos(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CopyFileInfos", reflect.TypeOf((*MockAPI)(nil).CopyFileInfos), arg0, arg1) } // CreateBot mocks base method. func (m *MockAPI) CreateBot(arg0 *model.Bot) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateBot", arg0) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateBot indicates an expected call of CreateBot. func (mr *MockAPIMockRecorder) CreateBot(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBot", reflect.TypeOf((*MockAPI)(nil).CreateBot), arg0) } // CreateChannel mocks base method. func (m *MockAPI) CreateChannel(arg0 *model.Channel) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateChannel indicates an expected call of CreateChannel. func (mr *MockAPIMockRecorder) CreateChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateChannel", reflect.TypeOf((*MockAPI)(nil).CreateChannel), arg0) } // CreateChannelSidebarCategory mocks base method. func (m *MockAPI) CreateChannelSidebarCategory(arg0, arg1 string, arg2 *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateChannelSidebarCategory", arg0, arg1, arg2) ret0, _ := ret[0].(*model.SidebarCategoryWithChannels) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateChannelSidebarCategory indicates an expected call of CreateChannelSidebarCategory. func (mr *MockAPIMockRecorder) CreateChannelSidebarCategory(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateChannelSidebarCategory", reflect.TypeOf((*MockAPI)(nil).CreateChannelSidebarCategory), arg0, arg1, arg2) } // CreateCommand mocks base method. func (m *MockAPI) CreateCommand(arg0 *model.Command) (*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateCommand", arg0) ret0, _ := ret[0].(*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateCommand indicates an expected call of CreateCommand. func (mr *MockAPIMockRecorder) CreateCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCommand", reflect.TypeOf((*MockAPI)(nil).CreateCommand), arg0) } // CreateOAuthApp mocks base method. func (m *MockAPI) CreateOAuthApp(arg0 *model.OAuthApp) (*model.OAuthApp, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateOAuthApp", arg0) ret0, _ := ret[0].(*model.OAuthApp) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateOAuthApp indicates an expected call of CreateOAuthApp. func (mr *MockAPIMockRecorder) CreateOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOAuthApp", reflect.TypeOf((*MockAPI)(nil).CreateOAuthApp), arg0) } // CreatePost mocks base method. func (m *MockAPI) CreatePost(arg0 *model.Post) (*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePost", arg0) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreatePost indicates an expected call of CreatePost. func (mr *MockAPIMockRecorder) CreatePost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePost", reflect.TypeOf((*MockAPI)(nil).CreatePost), arg0) } // CreateSession mocks base method. func (m *MockAPI) CreateSession(arg0 *model.Session) (*model.Session, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateSession", arg0) ret0, _ := ret[0].(*model.Session) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateSession indicates an expected call of CreateSession. func (mr *MockAPIMockRecorder) CreateSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockAPI)(nil).CreateSession), arg0) } // CreateTeam mocks base method. func (m *MockAPI) CreateTeam(arg0 *model.Team) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeam", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeam indicates an expected call of CreateTeam. func (mr *MockAPIMockRecorder) CreateTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeam", reflect.TypeOf((*MockAPI)(nil).CreateTeam), arg0) } // CreateTeamMember mocks base method. func (m *MockAPI) CreateTeamMember(arg0, arg1 string) (*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeamMember", arg0, arg1) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeamMember indicates an expected call of CreateTeamMember. func (mr *MockAPIMockRecorder) CreateTeamMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMember", reflect.TypeOf((*MockAPI)(nil).CreateTeamMember), arg0, arg1) } // CreateTeamMembers mocks base method. func (m *MockAPI) CreateTeamMembers(arg0 string, arg1 []string, arg2 string) ([]*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeamMembers", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeamMembers indicates an expected call of CreateTeamMembers. func (mr *MockAPIMockRecorder) CreateTeamMembers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMembers", reflect.TypeOf((*MockAPI)(nil).CreateTeamMembers), arg0, arg1, arg2) } // CreateTeamMembersGracefully mocks base method. func (m *MockAPI) CreateTeamMembersGracefully(arg0 string, arg1 []string, arg2 string) ([]*model.TeamMemberWithError, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateTeamMembersGracefully", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMemberWithError) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateTeamMembersGracefully indicates an expected call of CreateTeamMembersGracefully. func (mr *MockAPIMockRecorder) CreateTeamMembersGracefully(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMembersGracefully", reflect.TypeOf((*MockAPI)(nil).CreateTeamMembersGracefully), arg0, arg1, arg2) } // CreateUploadSession mocks base method. func (m *MockAPI) CreateUploadSession(arg0 *model.UploadSession) (*model.UploadSession, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateUploadSession", arg0) ret0, _ := ret[0].(*model.UploadSession) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateUploadSession indicates an expected call of CreateUploadSession. func (mr *MockAPIMockRecorder) CreateUploadSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUploadSession", reflect.TypeOf((*MockAPI)(nil).CreateUploadSession), arg0) } // CreateUser mocks base method. func (m *MockAPI) CreateUser(arg0 *model.User) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateUser indicates an expected call of CreateUser. func (mr *MockAPIMockRecorder) CreateUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockAPI)(nil).CreateUser), arg0) } // CreateUserAccessToken mocks base method. func (m *MockAPI) CreateUserAccessToken(arg0 *model.UserAccessToken) (*model.UserAccessToken, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateUserAccessToken", arg0) ret0, _ := ret[0].(*model.UserAccessToken) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // CreateUserAccessToken indicates an expected call of CreateUserAccessToken. func (mr *MockAPIMockRecorder) CreateUserAccessToken(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserAccessToken", reflect.TypeOf((*MockAPI)(nil).CreateUserAccessToken), arg0) } // DeleteChannel mocks base method. func (m *MockAPI) DeleteChannel(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteChannel", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteChannel indicates an expected call of DeleteChannel. func (mr *MockAPIMockRecorder) DeleteChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChannel", reflect.TypeOf((*MockAPI)(nil).DeleteChannel), arg0) } // DeleteChannelMember mocks base method. func (m *MockAPI) DeleteChannelMember(arg0, arg1 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteChannelMember", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteChannelMember indicates an expected call of DeleteChannelMember. func (mr *MockAPIMockRecorder) DeleteChannelMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChannelMember", reflect.TypeOf((*MockAPI)(nil).DeleteChannelMember), arg0, arg1) } // DeleteCommand mocks base method. func (m *MockAPI) DeleteCommand(arg0 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteCommand", arg0) ret0, _ := ret[0].(error) return ret0 } // DeleteCommand indicates an expected call of DeleteCommand. func (mr *MockAPIMockRecorder) DeleteCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCommand", reflect.TypeOf((*MockAPI)(nil).DeleteCommand), arg0) } // DeleteEphemeralPost mocks base method. func (m *MockAPI) DeleteEphemeralPost(arg0, arg1 string) { m.ctrl.T.Helper() m.ctrl.Call(m, "DeleteEphemeralPost", arg0, arg1) } // DeleteEphemeralPost indicates an expected call of DeleteEphemeralPost. func (mr *MockAPIMockRecorder) DeleteEphemeralPost(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteEphemeralPost", reflect.TypeOf((*MockAPI)(nil).DeleteEphemeralPost), arg0, arg1) } // DeleteOAuthApp mocks base method. func (m *MockAPI) DeleteOAuthApp(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteOAuthApp", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteOAuthApp indicates an expected call of DeleteOAuthApp. func (mr *MockAPIMockRecorder) DeleteOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOAuthApp", reflect.TypeOf((*MockAPI)(nil).DeleteOAuthApp), arg0) } // DeletePost mocks base method. func (m *MockAPI) DeletePost(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeletePost", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeletePost indicates an expected call of DeletePost. func (mr *MockAPIMockRecorder) DeletePost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePost", reflect.TypeOf((*MockAPI)(nil).DeletePost), arg0) } // DeletePreferencesForUser mocks base method. func (m *MockAPI) DeletePreferencesForUser(arg0 string, arg1 []model.Preference) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeletePreferencesForUser", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeletePreferencesForUser indicates an expected call of DeletePreferencesForUser. func (mr *MockAPIMockRecorder) DeletePreferencesForUser(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePreferencesForUser", reflect.TypeOf((*MockAPI)(nil).DeletePreferencesForUser), arg0, arg1) } // DeleteTeam mocks base method. func (m *MockAPI) DeleteTeam(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteTeam", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteTeam indicates an expected call of DeleteTeam. func (mr *MockAPIMockRecorder) DeleteTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTeam", reflect.TypeOf((*MockAPI)(nil).DeleteTeam), arg0) } // DeleteTeamMember mocks base method. func (m *MockAPI) DeleteTeamMember(arg0, arg1, arg2 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteTeamMember", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteTeamMember indicates an expected call of DeleteTeamMember. func (mr *MockAPIMockRecorder) DeleteTeamMember(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTeamMember", reflect.TypeOf((*MockAPI)(nil).DeleteTeamMember), arg0, arg1, arg2) } // DeleteUser mocks base method. func (m *MockAPI) DeleteUser(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteUser", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DeleteUser indicates an expected call of DeleteUser. func (mr *MockAPIMockRecorder) DeleteUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockAPI)(nil).DeleteUser), arg0) } // DisablePlugin mocks base method. func (m *MockAPI) DisablePlugin(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DisablePlugin", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // DisablePlugin indicates an expected call of DisablePlugin. func (mr *MockAPIMockRecorder) DisablePlugin(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisablePlugin", reflect.TypeOf((*MockAPI)(nil).DisablePlugin), arg0) } // EnablePlugin mocks base method. func (m *MockAPI) EnablePlugin(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "EnablePlugin", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // EnablePlugin indicates an expected call of EnablePlugin. func (mr *MockAPIMockRecorder) EnablePlugin(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnablePlugin", reflect.TypeOf((*MockAPI)(nil).EnablePlugin), arg0) } // EnsureBotUser mocks base method. func (m *MockAPI) EnsureBotUser(arg0 *model.Bot) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "EnsureBotUser", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // EnsureBotUser indicates an expected call of EnsureBotUser. func (mr *MockAPIMockRecorder) EnsureBotUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureBotUser", reflect.TypeOf((*MockAPI)(nil).EnsureBotUser), arg0) } // ExecuteSlashCommand mocks base method. func (m *MockAPI) ExecuteSlashCommand(arg0 *model.CommandArgs) (*model.CommandResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ExecuteSlashCommand", arg0) ret0, _ := ret[0].(*model.CommandResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // ExecuteSlashCommand indicates an expected call of ExecuteSlashCommand. func (mr *MockAPIMockRecorder) ExecuteSlashCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteSlashCommand", reflect.TypeOf((*MockAPI)(nil).ExecuteSlashCommand), arg0) } // ExtendSessionExpiry mocks base method. func (m *MockAPI) ExtendSessionExpiry(arg0 string, arg1 int64) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ExtendSessionExpiry", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // ExtendSessionExpiry indicates an expected call of ExtendSessionExpiry. func (mr *MockAPIMockRecorder) ExtendSessionExpiry(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendSessionExpiry", reflect.TypeOf((*MockAPI)(nil).ExtendSessionExpiry), arg0, arg1) } // GetBot mocks base method. func (m *MockAPI) GetBot(arg0 string, arg1 bool) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBot", arg0, arg1) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetBot indicates an expected call of GetBot. func (mr *MockAPIMockRecorder) GetBot(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBot", reflect.TypeOf((*MockAPI)(nil).GetBot), arg0, arg1) } // GetBots mocks base method. func (m *MockAPI) GetBots(arg0 *model.BotGetOptions) ([]*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBots", arg0) ret0, _ := ret[0].([]*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetBots indicates an expected call of GetBots. func (mr *MockAPIMockRecorder) GetBots(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBots", reflect.TypeOf((*MockAPI)(nil).GetBots), arg0) } // GetBundlePath mocks base method. func (m *MockAPI) GetBundlePath() (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBundlePath") ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBundlePath indicates an expected call of GetBundlePath. func (mr *MockAPIMockRecorder) GetBundlePath() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBundlePath", reflect.TypeOf((*MockAPI)(nil).GetBundlePath)) } // GetChannel mocks base method. func (m *MockAPI) GetChannel(arg0 string) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannel indicates an expected call of GetChannel. func (mr *MockAPIMockRecorder) GetChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannel", reflect.TypeOf((*MockAPI)(nil).GetChannel), arg0) } // GetChannelByName mocks base method. func (m *MockAPI) GetChannelByName(arg0, arg1 string, arg2 bool) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelByName", arg0, arg1, arg2) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelByName indicates an expected call of GetChannelByName. func (mr *MockAPIMockRecorder) GetChannelByName(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByName", reflect.TypeOf((*MockAPI)(nil).GetChannelByName), arg0, arg1, arg2) } // GetChannelByNameForTeamName mocks base method. func (m *MockAPI) GetChannelByNameForTeamName(arg0, arg1 string, arg2 bool) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelByNameForTeamName", arg0, arg1, arg2) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelByNameForTeamName indicates an expected call of GetChannelByNameForTeamName. func (mr *MockAPIMockRecorder) GetChannelByNameForTeamName(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByNameForTeamName", reflect.TypeOf((*MockAPI)(nil).GetChannelByNameForTeamName), arg0, arg1, arg2) } // GetChannelMember mocks base method. func (m *MockAPI) GetChannelMember(arg0, arg1 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMember", arg0, arg1) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMember indicates an expected call of GetChannelMember. func (mr *MockAPIMockRecorder) GetChannelMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMember", reflect.TypeOf((*MockAPI)(nil).GetChannelMember), arg0, arg1) } // GetChannelMembers mocks base method. func (m *MockAPI) GetChannelMembers(arg0 string, arg1, arg2 int) (model.ChannelMembers, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMembers", arg0, arg1, arg2) ret0, _ := ret[0].(model.ChannelMembers) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMembers indicates an expected call of GetChannelMembers. func (mr *MockAPIMockRecorder) GetChannelMembers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMembers", reflect.TypeOf((*MockAPI)(nil).GetChannelMembers), arg0, arg1, arg2) } // GetChannelMembersByIds mocks base method. func (m *MockAPI) GetChannelMembersByIds(arg0 string, arg1 []string) (model.ChannelMembers, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMembersByIds", arg0, arg1) ret0, _ := ret[0].(model.ChannelMembers) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMembersByIds indicates an expected call of GetChannelMembersByIds. func (mr *MockAPIMockRecorder) GetChannelMembersByIds(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMembersByIds", reflect.TypeOf((*MockAPI)(nil).GetChannelMembersByIds), arg0, arg1) } // GetChannelMembersForUser mocks base method. func (m *MockAPI) GetChannelMembersForUser(arg0, arg1 string, arg2, arg3 int) ([]*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelMembersForUser", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelMembersForUser indicates an expected call of GetChannelMembersForUser. func (mr *MockAPIMockRecorder) GetChannelMembersForUser(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMembersForUser", reflect.TypeOf((*MockAPI)(nil).GetChannelMembersForUser), arg0, arg1, arg2, arg3) } // GetChannelSidebarCategories mocks base method. func (m *MockAPI) GetChannelSidebarCategories(arg0, arg1 string) (*model.OrderedSidebarCategories, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelSidebarCategories", arg0, arg1) ret0, _ := ret[0].(*model.OrderedSidebarCategories) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelSidebarCategories indicates an expected call of GetChannelSidebarCategories. func (mr *MockAPIMockRecorder) GetChannelSidebarCategories(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelSidebarCategories", reflect.TypeOf((*MockAPI)(nil).GetChannelSidebarCategories), arg0, arg1) } // GetChannelStats mocks base method. func (m *MockAPI) GetChannelStats(arg0 string) (*model.ChannelStats, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelStats", arg0) ret0, _ := ret[0].(*model.ChannelStats) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelStats indicates an expected call of GetChannelStats. func (mr *MockAPIMockRecorder) GetChannelStats(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelStats", reflect.TypeOf((*MockAPI)(nil).GetChannelStats), arg0) } // GetChannelsForTeamForUser mocks base method. func (m *MockAPI) GetChannelsForTeamForUser(arg0, arg1 string, arg2 bool) ([]*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChannelsForTeamForUser", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetChannelsForTeamForUser indicates an expected call of GetChannelsForTeamForUser. func (mr *MockAPIMockRecorder) GetChannelsForTeamForUser(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelsForTeamForUser", reflect.TypeOf((*MockAPI)(nil).GetChannelsForTeamForUser), arg0, arg1, arg2) } // GetCommand mocks base method. func (m *MockAPI) GetCommand(arg0 string) (*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetCommand", arg0) ret0, _ := ret[0].(*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // GetCommand indicates an expected call of GetCommand. func (mr *MockAPIMockRecorder) GetCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCommand", reflect.TypeOf((*MockAPI)(nil).GetCommand), arg0) } // GetConfig mocks base method. func (m *MockAPI) GetConfig() *model.Config { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetConfig") ret0, _ := ret[0].(*model.Config) return ret0 } // GetConfig indicates an expected call of GetConfig. func (mr *MockAPIMockRecorder) GetConfig() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockAPI)(nil).GetConfig)) } // GetDiagnosticId mocks base method. func (m *MockAPI) GetDiagnosticId() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDiagnosticId") ret0, _ := ret[0].(string) return ret0 } // GetDiagnosticId indicates an expected call of GetDiagnosticId. func (mr *MockAPIMockRecorder) GetDiagnosticId() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDiagnosticId", reflect.TypeOf((*MockAPI)(nil).GetDiagnosticId)) } // GetDirectChannel mocks base method. func (m *MockAPI) GetDirectChannel(arg0, arg1 string) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDirectChannel", arg0, arg1) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetDirectChannel indicates an expected call of GetDirectChannel. func (mr *MockAPIMockRecorder) GetDirectChannel(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDirectChannel", reflect.TypeOf((*MockAPI)(nil).GetDirectChannel), arg0, arg1) } // GetEmoji mocks base method. func (m *MockAPI) GetEmoji(arg0 string) (*model.Emoji, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmoji", arg0) ret0, _ := ret[0].(*model.Emoji) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetEmoji indicates an expected call of GetEmoji. func (mr *MockAPIMockRecorder) GetEmoji(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmoji", reflect.TypeOf((*MockAPI)(nil).GetEmoji), arg0) } // GetEmojiByName mocks base method. func (m *MockAPI) GetEmojiByName(arg0 string) (*model.Emoji, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmojiByName", arg0) ret0, _ := ret[0].(*model.Emoji) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetEmojiByName indicates an expected call of GetEmojiByName. func (mr *MockAPIMockRecorder) GetEmojiByName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmojiByName", reflect.TypeOf((*MockAPI)(nil).GetEmojiByName), arg0) } // GetEmojiImage mocks base method. func (m *MockAPI) GetEmojiImage(arg0 string) ([]byte, string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmojiImage", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(string) ret2, _ := ret[2].(*model.AppError) return ret0, ret1, ret2 } // GetEmojiImage indicates an expected call of GetEmojiImage. func (mr *MockAPIMockRecorder) GetEmojiImage(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmojiImage", reflect.TypeOf((*MockAPI)(nil).GetEmojiImage), arg0) } // GetEmojiList mocks base method. func (m *MockAPI) GetEmojiList(arg0 string, arg1, arg2 int) ([]*model.Emoji, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEmojiList", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Emoji) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetEmojiList indicates an expected call of GetEmojiList. func (mr *MockAPIMockRecorder) GetEmojiList(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmojiList", reflect.TypeOf((*MockAPI)(nil).GetEmojiList), arg0, arg1, arg2) } // GetFile mocks base method. func (m *MockAPI) GetFile(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFile", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFile indicates an expected call of GetFile. func (mr *MockAPIMockRecorder) GetFile(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFile", reflect.TypeOf((*MockAPI)(nil).GetFile), arg0) } // GetFileInfo mocks base method. func (m *MockAPI) GetFileInfo(arg0 string) (*model.FileInfo, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileInfo", arg0) ret0, _ := ret[0].(*model.FileInfo) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFileInfo indicates an expected call of GetFileInfo. func (mr *MockAPIMockRecorder) GetFileInfo(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileInfo", reflect.TypeOf((*MockAPI)(nil).GetFileInfo), arg0) } // GetFileInfos mocks base method. func (m *MockAPI) GetFileInfos(arg0, arg1 int, arg2 *model.GetFileInfosOptions) ([]*model.FileInfo, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileInfos", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.FileInfo) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFileInfos indicates an expected call of GetFileInfos. func (mr *MockAPIMockRecorder) GetFileInfos(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileInfos", reflect.TypeOf((*MockAPI)(nil).GetFileInfos), arg0, arg1, arg2) } // GetFileLink mocks base method. func (m *MockAPI) GetFileLink(arg0 string) (string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetFileLink", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetFileLink indicates an expected call of GetFileLink. func (mr *MockAPIMockRecorder) GetFileLink(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileLink", reflect.TypeOf((*MockAPI)(nil).GetFileLink), arg0) } // GetGroup mocks base method. func (m *MockAPI) GetGroup(arg0 string) (*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroup", arg0) ret0, _ := ret[0].(*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroup indicates an expected call of GetGroup. func (mr *MockAPIMockRecorder) GetGroup(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockAPI)(nil).GetGroup), arg0) } // GetGroupByName mocks base method. func (m *MockAPI) GetGroupByName(arg0 string) (*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupByName", arg0) ret0, _ := ret[0].(*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupByName indicates an expected call of GetGroupByName. func (mr *MockAPIMockRecorder) GetGroupByName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockAPI)(nil).GetGroupByName), arg0) } // GetGroupChannel mocks base method. func (m *MockAPI) GetGroupChannel(arg0 []string) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupChannel indicates an expected call of GetGroupChannel. func (mr *MockAPIMockRecorder) GetGroupChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupChannel", reflect.TypeOf((*MockAPI)(nil).GetGroupChannel), arg0) } // GetGroupMemberUsers mocks base method. func (m *MockAPI) GetGroupMemberUsers(arg0 string, arg1, arg2 int) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupMemberUsers", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupMemberUsers indicates an expected call of GetGroupMemberUsers. func (mr *MockAPIMockRecorder) GetGroupMemberUsers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMemberUsers", reflect.TypeOf((*MockAPI)(nil).GetGroupMemberUsers), arg0, arg1, arg2) } // GetGroupsBySource mocks base method. func (m *MockAPI) GetGroupsBySource(arg0 model.GroupSource) ([]*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupsBySource", arg0) ret0, _ := ret[0].([]*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupsBySource indicates an expected call of GetGroupsBySource. func (mr *MockAPIMockRecorder) GetGroupsBySource(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsBySource", reflect.TypeOf((*MockAPI)(nil).GetGroupsBySource), arg0) } // GetGroupsForUser mocks base method. func (m *MockAPI) GetGroupsForUser(arg0 string) ([]*model.Group, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupsForUser", arg0) ret0, _ := ret[0].([]*model.Group) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetGroupsForUser indicates an expected call of GetGroupsForUser. func (mr *MockAPIMockRecorder) GetGroupsForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsForUser", reflect.TypeOf((*MockAPI)(nil).GetGroupsForUser), arg0) } // GetLDAPUserAttributes mocks base method. func (m *MockAPI) GetLDAPUserAttributes(arg0 string, arg1 []string) (map[string]string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLDAPUserAttributes", arg0, arg1) ret0, _ := ret[0].(map[string]string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetLDAPUserAttributes indicates an expected call of GetLDAPUserAttributes. func (mr *MockAPIMockRecorder) GetLDAPUserAttributes(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLDAPUserAttributes", reflect.TypeOf((*MockAPI)(nil).GetLDAPUserAttributes), arg0, arg1) } // GetLicense mocks base method. func (m *MockAPI) GetLicense() *model.License { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLicense") ret0, _ := ret[0].(*model.License) return ret0 } // GetLicense indicates an expected call of GetLicense. func (mr *MockAPIMockRecorder) GetLicense() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLicense", reflect.TypeOf((*MockAPI)(nil).GetLicense)) } // GetOAuthApp mocks base method. func (m *MockAPI) GetOAuthApp(arg0 string) (*model.OAuthApp, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOAuthApp", arg0) ret0, _ := ret[0].(*model.OAuthApp) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetOAuthApp indicates an expected call of GetOAuthApp. func (mr *MockAPIMockRecorder) GetOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthApp", reflect.TypeOf((*MockAPI)(nil).GetOAuthApp), arg0) } // GetPluginConfig mocks base method. func (m *MockAPI) GetPluginConfig() map[string]interface{} { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPluginConfig") ret0, _ := ret[0].(map[string]interface{}) return ret0 } // GetPluginConfig indicates an expected call of GetPluginConfig. func (mr *MockAPIMockRecorder) GetPluginConfig() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPluginConfig", reflect.TypeOf((*MockAPI)(nil).GetPluginConfig)) } // GetPluginStatus mocks base method. func (m *MockAPI) GetPluginStatus(arg0 string) (*model.PluginStatus, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPluginStatus", arg0) ret0, _ := ret[0].(*model.PluginStatus) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPluginStatus indicates an expected call of GetPluginStatus. func (mr *MockAPIMockRecorder) GetPluginStatus(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPluginStatus", reflect.TypeOf((*MockAPI)(nil).GetPluginStatus), arg0) } // GetPlugins mocks base method. func (m *MockAPI) GetPlugins() ([]*model.Manifest, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPlugins") ret0, _ := ret[0].([]*model.Manifest) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPlugins indicates an expected call of GetPlugins. func (mr *MockAPIMockRecorder) GetPlugins() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlugins", reflect.TypeOf((*MockAPI)(nil).GetPlugins)) } // GetPost mocks base method. func (m *MockAPI) GetPost(arg0 string) (*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPost", arg0) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPost indicates an expected call of GetPost. func (mr *MockAPIMockRecorder) GetPost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPost", reflect.TypeOf((*MockAPI)(nil).GetPost), arg0) } // GetPostThread mocks base method. func (m *MockAPI) GetPostThread(arg0 string) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostThread", arg0) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostThread indicates an expected call of GetPostThread. func (mr *MockAPIMockRecorder) GetPostThread(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostThread", reflect.TypeOf((*MockAPI)(nil).GetPostThread), arg0) } // GetPostsAfter mocks base method. func (m *MockAPI) GetPostsAfter(arg0, arg1 string, arg2, arg3 int) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsAfter", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsAfter indicates an expected call of GetPostsAfter. func (mr *MockAPIMockRecorder) GetPostsAfter(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsAfter", reflect.TypeOf((*MockAPI)(nil).GetPostsAfter), arg0, arg1, arg2, arg3) } // GetPostsBefore mocks base method. func (m *MockAPI) GetPostsBefore(arg0, arg1 string, arg2, arg3 int) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsBefore", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsBefore indicates an expected call of GetPostsBefore. func (mr *MockAPIMockRecorder) GetPostsBefore(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsBefore", reflect.TypeOf((*MockAPI)(nil).GetPostsBefore), arg0, arg1, arg2, arg3) } // GetPostsForChannel mocks base method. func (m *MockAPI) GetPostsForChannel(arg0 string, arg1, arg2 int) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsForChannel", arg0, arg1, arg2) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsForChannel indicates an expected call of GetPostsForChannel. func (mr *MockAPIMockRecorder) GetPostsForChannel(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsForChannel", reflect.TypeOf((*MockAPI)(nil).GetPostsForChannel), arg0, arg1, arg2) } // GetPostsSince mocks base method. func (m *MockAPI) GetPostsSince(arg0 string, arg1 int64) (*model.PostList, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPostsSince", arg0, arg1) ret0, _ := ret[0].(*model.PostList) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPostsSince indicates an expected call of GetPostsSince. func (mr *MockAPIMockRecorder) GetPostsSince(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsSince", reflect.TypeOf((*MockAPI)(nil).GetPostsSince), arg0, arg1) } // GetPreferencesForUser mocks base method. func (m *MockAPI) GetPreferencesForUser(arg0 string) ([]model.Preference, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPreferencesForUser", arg0) ret0, _ := ret[0].([]model.Preference) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPreferencesForUser indicates an expected call of GetPreferencesForUser. func (mr *MockAPIMockRecorder) GetPreferencesForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPreferencesForUser", reflect.TypeOf((*MockAPI)(nil).GetPreferencesForUser), arg0) } // GetProfileImage mocks base method. func (m *MockAPI) GetProfileImage(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProfileImage", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetProfileImage indicates an expected call of GetProfileImage. func (mr *MockAPIMockRecorder) GetProfileImage(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfileImage", reflect.TypeOf((*MockAPI)(nil).GetProfileImage), arg0) } // GetPublicChannelsForTeam mocks base method. func (m *MockAPI) GetPublicChannelsForTeam(arg0 string, arg1, arg2 int) ([]*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPublicChannelsForTeam", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetPublicChannelsForTeam indicates an expected call of GetPublicChannelsForTeam. func (mr *MockAPIMockRecorder) GetPublicChannelsForTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicChannelsForTeam", reflect.TypeOf((*MockAPI)(nil).GetPublicChannelsForTeam), arg0, arg1, arg2) } // GetReactions mocks base method. func (m *MockAPI) GetReactions(arg0 string) ([]*model.Reaction, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetReactions", arg0) ret0, _ := ret[0].([]*model.Reaction) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetReactions indicates an expected call of GetReactions. func (mr *MockAPIMockRecorder) GetReactions(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetReactions", reflect.TypeOf((*MockAPI)(nil).GetReactions), arg0) } // GetServerVersion mocks base method. func (m *MockAPI) GetServerVersion() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetServerVersion") ret0, _ := ret[0].(string) return ret0 } // GetServerVersion indicates an expected call of GetServerVersion. func (mr *MockAPIMockRecorder) GetServerVersion() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServerVersion", reflect.TypeOf((*MockAPI)(nil).GetServerVersion)) } // GetSession mocks base method. func (m *MockAPI) GetSession(arg0 string) (*model.Session, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSession", arg0) ret0, _ := ret[0].(*model.Session) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetSession indicates an expected call of GetSession. func (mr *MockAPIMockRecorder) GetSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSession", reflect.TypeOf((*MockAPI)(nil).GetSession), arg0) } // GetSystemInstallDate mocks base method. func (m *MockAPI) GetSystemInstallDate() (int64, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSystemInstallDate") ret0, _ := ret[0].(int64) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetSystemInstallDate indicates an expected call of GetSystemInstallDate. func (mr *MockAPIMockRecorder) GetSystemInstallDate() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemInstallDate", reflect.TypeOf((*MockAPI)(nil).GetSystemInstallDate)) } // GetTeam mocks base method. func (m *MockAPI) GetTeam(arg0 string) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeam", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeam indicates an expected call of GetTeam. func (mr *MockAPIMockRecorder) GetTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeam", reflect.TypeOf((*MockAPI)(nil).GetTeam), arg0) } // GetTeamByName mocks base method. func (m *MockAPI) GetTeamByName(arg0 string) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamByName", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamByName indicates an expected call of GetTeamByName. func (mr *MockAPIMockRecorder) GetTeamByName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamByName", reflect.TypeOf((*MockAPI)(nil).GetTeamByName), arg0) } // GetTeamIcon mocks base method. func (m *MockAPI) GetTeamIcon(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamIcon", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamIcon indicates an expected call of GetTeamIcon. func (mr *MockAPIMockRecorder) GetTeamIcon(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamIcon", reflect.TypeOf((*MockAPI)(nil).GetTeamIcon), arg0) } // GetTeamMember mocks base method. func (m *MockAPI) GetTeamMember(arg0, arg1 string) (*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMember", arg0, arg1) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamMember indicates an expected call of GetTeamMember. func (mr *MockAPIMockRecorder) GetTeamMember(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMember", reflect.TypeOf((*MockAPI)(nil).GetTeamMember), arg0, arg1) } // GetTeamMembers mocks base method. func (m *MockAPI) GetTeamMembers(arg0 string, arg1, arg2 int) ([]*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMembers", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamMembers indicates an expected call of GetTeamMembers. func (mr *MockAPIMockRecorder) GetTeamMembers(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMembers", reflect.TypeOf((*MockAPI)(nil).GetTeamMembers), arg0, arg1, arg2) } // GetTeamMembersForUser mocks base method. func (m *MockAPI) GetTeamMembersForUser(arg0 string, arg1, arg2 int) ([]*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMembersForUser", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamMembersForUser indicates an expected call of GetTeamMembersForUser. func (mr *MockAPIMockRecorder) GetTeamMembersForUser(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMembersForUser", reflect.TypeOf((*MockAPI)(nil).GetTeamMembersForUser), arg0, arg1, arg2) } // GetTeamStats mocks base method. func (m *MockAPI) GetTeamStats(arg0 string) (*model.TeamStats, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamStats", arg0) ret0, _ := ret[0].(*model.TeamStats) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamStats indicates an expected call of GetTeamStats. func (mr *MockAPIMockRecorder) GetTeamStats(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamStats", reflect.TypeOf((*MockAPI)(nil).GetTeamStats), arg0) } // GetTeams mocks base method. func (m *MockAPI) GetTeams() ([]*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeams") ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeams indicates an expected call of GetTeams. func (mr *MockAPIMockRecorder) GetTeams() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeams", reflect.TypeOf((*MockAPI)(nil).GetTeams)) } // GetTeamsForUser mocks base method. func (m *MockAPI) GetTeamsForUser(arg0 string) ([]*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamsForUser", arg0) ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamsForUser indicates an expected call of GetTeamsForUser. func (mr *MockAPIMockRecorder) GetTeamsForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamsForUser", reflect.TypeOf((*MockAPI)(nil).GetTeamsForUser), arg0) } // GetTeamsUnreadForUser mocks base method. func (m *MockAPI) GetTeamsUnreadForUser(arg0 string) ([]*model.TeamUnread, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamsUnreadForUser", arg0) ret0, _ := ret[0].([]*model.TeamUnread) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetTeamsUnreadForUser indicates an expected call of GetTeamsUnreadForUser. func (mr *MockAPIMockRecorder) GetTeamsUnreadForUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamsUnreadForUser", reflect.TypeOf((*MockAPI)(nil).GetTeamsUnreadForUser), arg0) } // GetTelemetryId mocks base method. func (m *MockAPI) GetTelemetryId() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTelemetryId") ret0, _ := ret[0].(string) return ret0 } // GetTelemetryId indicates an expected call of GetTelemetryId. func (mr *MockAPIMockRecorder) GetTelemetryId() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTelemetryId", reflect.TypeOf((*MockAPI)(nil).GetTelemetryId)) } // GetUnsanitizedConfig mocks base method. func (m *MockAPI) GetUnsanitizedConfig() *model.Config { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUnsanitizedConfig") ret0, _ := ret[0].(*model.Config) return ret0 } // GetUnsanitizedConfig indicates an expected call of GetUnsanitizedConfig. func (mr *MockAPIMockRecorder) GetUnsanitizedConfig() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnsanitizedConfig", reflect.TypeOf((*MockAPI)(nil).GetUnsanitizedConfig)) } // GetUploadSession mocks base method. func (m *MockAPI) GetUploadSession(arg0 string) (*model.UploadSession, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUploadSession", arg0) ret0, _ := ret[0].(*model.UploadSession) ret1, _ := ret[1].(error) return ret0, ret1 } // GetUploadSession indicates an expected call of GetUploadSession. func (mr *MockAPIMockRecorder) GetUploadSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUploadSession", reflect.TypeOf((*MockAPI)(nil).GetUploadSession), arg0) } // GetUser mocks base method. func (m *MockAPI) GetUser(arg0 string) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUser indicates an expected call of GetUser. func (mr *MockAPIMockRecorder) GetUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockAPI)(nil).GetUser), arg0) } // GetUserByEmail mocks base method. func (m *MockAPI) GetUserByEmail(arg0 string) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByEmail", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserByEmail indicates an expected call of GetUserByEmail. func (mr *MockAPIMockRecorder) GetUserByEmail(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockAPI)(nil).GetUserByEmail), arg0) } // GetUserByUsername mocks base method. func (m *MockAPI) GetUserByUsername(arg0 string) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByUsername", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserByUsername indicates an expected call of GetUserByUsername. func (mr *MockAPIMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockAPI)(nil).GetUserByUsername), arg0) } // GetUserStatus mocks base method. func (m *MockAPI) GetUserStatus(arg0 string) (*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserStatus", arg0) ret0, _ := ret[0].(*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserStatus indicates an expected call of GetUserStatus. func (mr *MockAPIMockRecorder) GetUserStatus(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatus", reflect.TypeOf((*MockAPI)(nil).GetUserStatus), arg0) } // GetUserStatusesByIds mocks base method. func (m *MockAPI) GetUserStatusesByIds(arg0 []string) ([]*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserStatusesByIds", arg0) ret0, _ := ret[0].([]*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUserStatusesByIds indicates an expected call of GetUserStatusesByIds. func (mr *MockAPIMockRecorder) GetUserStatusesByIds(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusesByIds", reflect.TypeOf((*MockAPI)(nil).GetUserStatusesByIds), arg0) } // GetUsers mocks base method. func (m *MockAPI) GetUsers(arg0 *model.UserGetOptions) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsers", arg0) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsers indicates an expected call of GetUsers. func (mr *MockAPIMockRecorder) GetUsers(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockAPI)(nil).GetUsers), arg0) } // GetUsersByUsernames mocks base method. func (m *MockAPI) GetUsersByUsernames(arg0 []string) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersByUsernames", arg0) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsersByUsernames indicates an expected call of GetUsersByUsernames. func (mr *MockAPIMockRecorder) GetUsersByUsernames(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByUsernames", reflect.TypeOf((*MockAPI)(nil).GetUsersByUsernames), arg0) } // GetUsersInChannel mocks base method. func (m *MockAPI) GetUsersInChannel(arg0, arg1 string, arg2, arg3 int) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersInChannel", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsersInChannel indicates an expected call of GetUsersInChannel. func (mr *MockAPIMockRecorder) GetUsersInChannel(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersInChannel", reflect.TypeOf((*MockAPI)(nil).GetUsersInChannel), arg0, arg1, arg2, arg3) } // GetUsersInTeam mocks base method. func (m *MockAPI) GetUsersInTeam(arg0 string, arg1, arg2 int) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUsersInTeam", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // GetUsersInTeam indicates an expected call of GetUsersInTeam. func (mr *MockAPIMockRecorder) GetUsersInTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersInTeam", reflect.TypeOf((*MockAPI)(nil).GetUsersInTeam), arg0, arg1, arg2) } // HasPermissionTo mocks base method. func (m *MockAPI) HasPermissionTo(arg0 string, arg1 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionTo", arg0, arg1) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionTo indicates an expected call of HasPermissionTo. func (mr *MockAPIMockRecorder) HasPermissionTo(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionTo", reflect.TypeOf((*MockAPI)(nil).HasPermissionTo), arg0, arg1) } // HasPermissionToChannel mocks base method. func (m *MockAPI) HasPermissionToChannel(arg0, arg1 string, arg2 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionToChannel", arg0, arg1, arg2) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionToChannel indicates an expected call of HasPermissionToChannel. func (mr *MockAPIMockRecorder) HasPermissionToChannel(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToChannel", reflect.TypeOf((*MockAPI)(nil).HasPermissionToChannel), arg0, arg1, arg2) } // HasPermissionToTeam mocks base method. func (m *MockAPI) HasPermissionToTeam(arg0, arg1 string, arg2 *model.Permission) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HasPermissionToTeam", arg0, arg1, arg2) ret0, _ := ret[0].(bool) return ret0 } // HasPermissionToTeam indicates an expected call of HasPermissionToTeam. func (mr *MockAPIMockRecorder) HasPermissionToTeam(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToTeam", reflect.TypeOf((*MockAPI)(nil).HasPermissionToTeam), arg0, arg1, arg2) } // InstallPlugin mocks base method. func (m *MockAPI) InstallPlugin(arg0 io.Reader, arg1 bool) (*model.Manifest, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InstallPlugin", arg0, arg1) ret0, _ := ret[0].(*model.Manifest) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // InstallPlugin indicates an expected call of InstallPlugin. func (mr *MockAPIMockRecorder) InstallPlugin(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallPlugin", reflect.TypeOf((*MockAPI)(nil).InstallPlugin), arg0, arg1) } // IsEnterpriseReady mocks base method. func (m *MockAPI) IsEnterpriseReady() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IsEnterpriseReady") ret0, _ := ret[0].(bool) return ret0 } // IsEnterpriseReady indicates an expected call of IsEnterpriseReady. func (mr *MockAPIMockRecorder) IsEnterpriseReady() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsEnterpriseReady", reflect.TypeOf((*MockAPI)(nil).IsEnterpriseReady)) } // KVCompareAndDelete mocks base method. func (m *MockAPI) KVCompareAndDelete(arg0 string, arg1 []byte) (bool, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVCompareAndDelete", arg0, arg1) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVCompareAndDelete indicates an expected call of KVCompareAndDelete. func (mr *MockAPIMockRecorder) KVCompareAndDelete(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVCompareAndDelete", reflect.TypeOf((*MockAPI)(nil).KVCompareAndDelete), arg0, arg1) } // KVCompareAndSet mocks base method. func (m *MockAPI) KVCompareAndSet(arg0 string, arg1, arg2 []byte) (bool, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVCompareAndSet", arg0, arg1, arg2) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVCompareAndSet indicates an expected call of KVCompareAndSet. func (mr *MockAPIMockRecorder) KVCompareAndSet(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVCompareAndSet", reflect.TypeOf((*MockAPI)(nil).KVCompareAndSet), arg0, arg1, arg2) } // KVDelete mocks base method. func (m *MockAPI) KVDelete(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVDelete", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // KVDelete indicates an expected call of KVDelete. func (mr *MockAPIMockRecorder) KVDelete(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVDelete", reflect.TypeOf((*MockAPI)(nil).KVDelete), arg0) } // KVDeleteAll mocks base method. func (m *MockAPI) KVDeleteAll() *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVDeleteAll") ret0, _ := ret[0].(*model.AppError) return ret0 } // KVDeleteAll indicates an expected call of KVDeleteAll. func (mr *MockAPIMockRecorder) KVDeleteAll() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVDeleteAll", reflect.TypeOf((*MockAPI)(nil).KVDeleteAll)) } // KVGet mocks base method. func (m *MockAPI) KVGet(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVGet", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVGet indicates an expected call of KVGet. func (mr *MockAPIMockRecorder) KVGet(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVGet", reflect.TypeOf((*MockAPI)(nil).KVGet), arg0) } // KVList mocks base method. func (m *MockAPI) KVList(arg0, arg1 int) ([]string, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVList", arg0, arg1) ret0, _ := ret[0].([]string) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVList indicates an expected call of KVList. func (mr *MockAPIMockRecorder) KVList(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVList", reflect.TypeOf((*MockAPI)(nil).KVList), arg0, arg1) } // KVSet mocks base method. func (m *MockAPI) KVSet(arg0 string, arg1 []byte) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVSet", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // KVSet indicates an expected call of KVSet. func (mr *MockAPIMockRecorder) KVSet(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVSet", reflect.TypeOf((*MockAPI)(nil).KVSet), arg0, arg1) } // KVSetWithExpiry mocks base method. func (m *MockAPI) KVSetWithExpiry(arg0 string, arg1 []byte, arg2 int64) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVSetWithExpiry", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // KVSetWithExpiry indicates an expected call of KVSetWithExpiry. func (mr *MockAPIMockRecorder) KVSetWithExpiry(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVSetWithExpiry", reflect.TypeOf((*MockAPI)(nil).KVSetWithExpiry), arg0, arg1, arg2) } // KVSetWithOptions mocks base method. func (m *MockAPI) KVSetWithOptions(arg0 string, arg1 []byte, arg2 model.PluginKVSetOptions) (bool, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "KVSetWithOptions", arg0, arg1, arg2) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // KVSetWithOptions indicates an expected call of KVSetWithOptions. func (mr *MockAPIMockRecorder) KVSetWithOptions(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KVSetWithOptions", reflect.TypeOf((*MockAPI)(nil).KVSetWithOptions), arg0, arg1, arg2) } // ListBuiltInCommands mocks base method. func (m *MockAPI) ListBuiltInCommands() ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListBuiltInCommands") ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListBuiltInCommands indicates an expected call of ListBuiltInCommands. func (mr *MockAPIMockRecorder) ListBuiltInCommands() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBuiltInCommands", reflect.TypeOf((*MockAPI)(nil).ListBuiltInCommands)) } // ListCommands mocks base method. func (m *MockAPI) ListCommands(arg0 string) ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListCommands", arg0) ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListCommands indicates an expected call of ListCommands. func (mr *MockAPIMockRecorder) ListCommands(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCommands", reflect.TypeOf((*MockAPI)(nil).ListCommands), arg0) } // ListCustomCommands mocks base method. func (m *MockAPI) ListCustomCommands(arg0 string) ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListCustomCommands", arg0) ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListCustomCommands indicates an expected call of ListCustomCommands. func (mr *MockAPIMockRecorder) ListCustomCommands(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCustomCommands", reflect.TypeOf((*MockAPI)(nil).ListCustomCommands), arg0) } // ListPluginCommands mocks base method. func (m *MockAPI) ListPluginCommands(arg0 string) ([]*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListPluginCommands", arg0) ret0, _ := ret[0].([]*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // ListPluginCommands indicates an expected call of ListPluginCommands. func (mr *MockAPIMockRecorder) ListPluginCommands(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPluginCommands", reflect.TypeOf((*MockAPI)(nil).ListPluginCommands), arg0) } // LoadPluginConfiguration mocks base method. func (m *MockAPI) LoadPluginConfiguration(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "LoadPluginConfiguration", arg0) ret0, _ := ret[0].(error) return ret0 } // LoadPluginConfiguration indicates an expected call of LoadPluginConfiguration. func (mr *MockAPIMockRecorder) LoadPluginConfiguration(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPluginConfiguration", reflect.TypeOf((*MockAPI)(nil).LoadPluginConfiguration), arg0) } // LogDebug mocks base method. func (m *MockAPI) LogDebug(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogDebug", varargs...) } // LogDebug indicates an expected call of LogDebug. func (mr *MockAPIMockRecorder) LogDebug(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogDebug", reflect.TypeOf((*MockAPI)(nil).LogDebug), varargs...) } // LogError mocks base method. func (m *MockAPI) LogError(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogError", varargs...) } // LogError indicates an expected call of LogError. func (mr *MockAPIMockRecorder) LogError(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogError", reflect.TypeOf((*MockAPI)(nil).LogError), varargs...) } // LogInfo mocks base method. func (m *MockAPI) LogInfo(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogInfo", varargs...) } // LogInfo indicates an expected call of LogInfo. func (mr *MockAPIMockRecorder) LogInfo(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogInfo", reflect.TypeOf((*MockAPI)(nil).LogInfo), varargs...) } // LogWarn mocks base method. func (m *MockAPI) LogWarn(arg0 string, arg1 ...interface{}) { m.ctrl.T.Helper() varargs := []interface{}{arg0} for _, a := range arg1 { varargs = append(varargs, a) } m.ctrl.Call(m, "LogWarn", varargs...) } // LogWarn indicates an expected call of LogWarn. func (mr *MockAPIMockRecorder) LogWarn(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0}, arg1...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogWarn", reflect.TypeOf((*MockAPI)(nil).LogWarn), varargs...) } // OpenInteractiveDialog mocks base method. func (m *MockAPI) OpenInteractiveDialog(arg0 model.OpenDialogRequest) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OpenInteractiveDialog", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // OpenInteractiveDialog indicates an expected call of OpenInteractiveDialog. func (mr *MockAPIMockRecorder) OpenInteractiveDialog(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenInteractiveDialog", reflect.TypeOf((*MockAPI)(nil).OpenInteractiveDialog), arg0) } // PatchBot mocks base method. func (m *MockAPI) PatchBot(arg0 string, arg1 *model.BotPatch) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PatchBot", arg0, arg1) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // PatchBot indicates an expected call of PatchBot. func (mr *MockAPIMockRecorder) PatchBot(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBot", reflect.TypeOf((*MockAPI)(nil).PatchBot), arg0, arg1) } // PermanentDeleteBot mocks base method. func (m *MockAPI) PermanentDeleteBot(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PermanentDeleteBot", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // PermanentDeleteBot indicates an expected call of PermanentDeleteBot. func (mr *MockAPIMockRecorder) PermanentDeleteBot(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeleteBot", reflect.TypeOf((*MockAPI)(nil).PermanentDeleteBot), arg0) } // PluginHTTP mocks base method. func (m *MockAPI) PluginHTTP(arg0 *http.Request) *http.Response { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PluginHTTP", arg0) ret0, _ := ret[0].(*http.Response) return ret0 } // PluginHTTP indicates an expected call of PluginHTTP. func (mr *MockAPIMockRecorder) PluginHTTP(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PluginHTTP", reflect.TypeOf((*MockAPI)(nil).PluginHTTP), arg0) } // PublishPluginClusterEvent mocks base method. func (m *MockAPI) PublishPluginClusterEvent(arg0 model.PluginClusterEvent, arg1 model.PluginClusterEventSendOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PublishPluginClusterEvent", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // PublishPluginClusterEvent indicates an expected call of PublishPluginClusterEvent. func (mr *MockAPIMockRecorder) PublishPluginClusterEvent(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishPluginClusterEvent", reflect.TypeOf((*MockAPI)(nil).PublishPluginClusterEvent), arg0, arg1) } // PublishUserTyping mocks base method. func (m *MockAPI) PublishUserTyping(arg0, arg1, arg2 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PublishUserTyping", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // PublishUserTyping indicates an expected call of PublishUserTyping. func (mr *MockAPIMockRecorder) PublishUserTyping(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishUserTyping", reflect.TypeOf((*MockAPI)(nil).PublishUserTyping), arg0, arg1, arg2) } // PublishWebSocketEvent mocks base method. func (m *MockAPI) PublishWebSocketEvent(arg0 string, arg1 map[string]interface{}, arg2 *model.WebsocketBroadcast) { m.ctrl.T.Helper() m.ctrl.Call(m, "PublishWebSocketEvent", arg0, arg1, arg2) } // PublishWebSocketEvent indicates an expected call of PublishWebSocketEvent. func (mr *MockAPIMockRecorder) PublishWebSocketEvent(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishWebSocketEvent", reflect.TypeOf((*MockAPI)(nil).PublishWebSocketEvent), arg0, arg1, arg2) } // ReadFile mocks base method. func (m *MockAPI) ReadFile(arg0 string) ([]byte, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ReadFile", arg0) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // ReadFile indicates an expected call of ReadFile. func (mr *MockAPIMockRecorder) ReadFile(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockAPI)(nil).ReadFile), arg0) } // RegisterCollectionAndTopic mocks base method. func (m *MockAPI) RegisterCollectionAndTopic(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RegisterCollectionAndTopic", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // RegisterCollectionAndTopic indicates an expected call of RegisterCollectionAndTopic. func (mr *MockAPIMockRecorder) RegisterCollectionAndTopic(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterCollectionAndTopic", reflect.TypeOf((*MockAPI)(nil).RegisterCollectionAndTopic), arg0, arg1) } // RegisterCommand mocks base method. func (m *MockAPI) RegisterCommand(arg0 *model.Command) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RegisterCommand", arg0) ret0, _ := ret[0].(error) return ret0 } // RegisterCommand indicates an expected call of RegisterCommand. func (mr *MockAPIMockRecorder) RegisterCommand(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterCommand", reflect.TypeOf((*MockAPI)(nil).RegisterCommand), arg0) } // RemovePlugin mocks base method. func (m *MockAPI) RemovePlugin(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemovePlugin", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemovePlugin indicates an expected call of RemovePlugin. func (mr *MockAPIMockRecorder) RemovePlugin(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePlugin", reflect.TypeOf((*MockAPI)(nil).RemovePlugin), arg0) } // RemoveReaction mocks base method. func (m *MockAPI) RemoveReaction(arg0 *model.Reaction) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveReaction", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemoveReaction indicates an expected call of RemoveReaction. func (mr *MockAPIMockRecorder) RemoveReaction(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveReaction", reflect.TypeOf((*MockAPI)(nil).RemoveReaction), arg0) } // RemoveTeamIcon mocks base method. func (m *MockAPI) RemoveTeamIcon(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveTeamIcon", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemoveTeamIcon indicates an expected call of RemoveTeamIcon. func (mr *MockAPIMockRecorder) RemoveTeamIcon(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveTeamIcon", reflect.TypeOf((*MockAPI)(nil).RemoveTeamIcon), arg0) } // RemoveUserCustomStatus mocks base method. func (m *MockAPI) RemoveUserCustomStatus(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveUserCustomStatus", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RemoveUserCustomStatus indicates an expected call of RemoveUserCustomStatus. func (mr *MockAPIMockRecorder) RemoveUserCustomStatus(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).RemoveUserCustomStatus), arg0) } // RequestTrialLicense mocks base method. func (m *MockAPI) RequestTrialLicense(arg0 string, arg1 int, arg2, arg3 bool) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RequestTrialLicense", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(*model.AppError) return ret0 } // RequestTrialLicense indicates an expected call of RequestTrialLicense. func (mr *MockAPIMockRecorder) RequestTrialLicense(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestTrialLicense", reflect.TypeOf((*MockAPI)(nil).RequestTrialLicense), arg0, arg1, arg2, arg3) } // RevokeSession mocks base method. func (m *MockAPI) RevokeSession(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RevokeSession", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RevokeSession indicates an expected call of RevokeSession. func (mr *MockAPIMockRecorder) RevokeSession(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeSession", reflect.TypeOf((*MockAPI)(nil).RevokeSession), arg0) } // RevokeUserAccessToken mocks base method. func (m *MockAPI) RevokeUserAccessToken(arg0 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RevokeUserAccessToken", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // RevokeUserAccessToken indicates an expected call of RevokeUserAccessToken. func (mr *MockAPIMockRecorder) RevokeUserAccessToken(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeUserAccessToken", reflect.TypeOf((*MockAPI)(nil).RevokeUserAccessToken), arg0) } // RolesGrantPermission mocks base method. func (m *MockAPI) RolesGrantPermission(arg0 []string, arg1 string) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RolesGrantPermission", arg0, arg1) ret0, _ := ret[0].(bool) return ret0 } // RolesGrantPermission indicates an expected call of RolesGrantPermission. func (mr *MockAPIMockRecorder) RolesGrantPermission(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RolesGrantPermission", reflect.TypeOf((*MockAPI)(nil).RolesGrantPermission), arg0, arg1) } // SaveConfig mocks base method. func (m *MockAPI) SaveConfig(arg0 *model.Config) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveConfig", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // SaveConfig indicates an expected call of SaveConfig. func (mr *MockAPIMockRecorder) SaveConfig(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveConfig", reflect.TypeOf((*MockAPI)(nil).SaveConfig), arg0) } // SavePluginConfig mocks base method. func (m *MockAPI) SavePluginConfig(arg0 map[string]interface{}) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SavePluginConfig", arg0) ret0, _ := ret[0].(*model.AppError) return ret0 } // SavePluginConfig indicates an expected call of SavePluginConfig. func (mr *MockAPIMockRecorder) SavePluginConfig(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePluginConfig", reflect.TypeOf((*MockAPI)(nil).SavePluginConfig), arg0) } // SearchChannels mocks base method. func (m *MockAPI) SearchChannels(arg0, arg1 string) ([]*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchChannels", arg0, arg1) ret0, _ := ret[0].([]*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchChannels indicates an expected call of SearchChannels. func (mr *MockAPIMockRecorder) SearchChannels(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchChannels", reflect.TypeOf((*MockAPI)(nil).SearchChannels), arg0, arg1) } // SearchPostsInTeam mocks base method. func (m *MockAPI) SearchPostsInTeam(arg0 string, arg1 []*model.SearchParams) ([]*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchPostsInTeam", arg0, arg1) ret0, _ := ret[0].([]*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchPostsInTeam indicates an expected call of SearchPostsInTeam. func (mr *MockAPIMockRecorder) SearchPostsInTeam(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchPostsInTeam", reflect.TypeOf((*MockAPI)(nil).SearchPostsInTeam), arg0, arg1) } // SearchPostsInTeamForUser mocks base method. func (m *MockAPI) SearchPostsInTeamForUser(arg0, arg1 string, arg2 model.SearchParameter) (*model.PostSearchResults, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchPostsInTeamForUser", arg0, arg1, arg2) ret0, _ := ret[0].(*model.PostSearchResults) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchPostsInTeamForUser indicates an expected call of SearchPostsInTeamForUser. func (mr *MockAPIMockRecorder) SearchPostsInTeamForUser(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchPostsInTeamForUser", reflect.TypeOf((*MockAPI)(nil).SearchPostsInTeamForUser), arg0, arg1, arg2) } // SearchTeams mocks base method. func (m *MockAPI) SearchTeams(arg0 string) ([]*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchTeams", arg0) ret0, _ := ret[0].([]*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchTeams indicates an expected call of SearchTeams. func (mr *MockAPIMockRecorder) SearchTeams(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchTeams", reflect.TypeOf((*MockAPI)(nil).SearchTeams), arg0) } // SearchUsers mocks base method. func (m *MockAPI) SearchUsers(arg0 *model.UserSearch) ([]*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SearchUsers", arg0) ret0, _ := ret[0].([]*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SearchUsers indicates an expected call of SearchUsers. func (mr *MockAPIMockRecorder) SearchUsers(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUsers", reflect.TypeOf((*MockAPI)(nil).SearchUsers), arg0) } // SendEphemeralPost mocks base method. func (m *MockAPI) SendEphemeralPost(arg0 string, arg1 *model.Post) *model.Post { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendEphemeralPost", arg0, arg1) ret0, _ := ret[0].(*model.Post) return ret0 } // SendEphemeralPost indicates an expected call of SendEphemeralPost. func (mr *MockAPIMockRecorder) SendEphemeralPost(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendEphemeralPost", reflect.TypeOf((*MockAPI)(nil).SendEphemeralPost), arg0, arg1) } // SendMail mocks base method. func (m *MockAPI) SendMail(arg0, arg1, arg2 string) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendMail", arg0, arg1, arg2) ret0, _ := ret[0].(*model.AppError) return ret0 } // SendMail indicates an expected call of SendMail. func (mr *MockAPIMockRecorder) SendMail(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMail", reflect.TypeOf((*MockAPI)(nil).SendMail), arg0, arg1, arg2) } // SetProfileImage mocks base method. func (m *MockAPI) SetProfileImage(arg0 string, arg1 []byte) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetProfileImage", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // SetProfileImage indicates an expected call of SetProfileImage. func (mr *MockAPIMockRecorder) SetProfileImage(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetProfileImage", reflect.TypeOf((*MockAPI)(nil).SetProfileImage), arg0, arg1) } // SetTeamIcon mocks base method. func (m *MockAPI) SetTeamIcon(arg0 string, arg1 []byte) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetTeamIcon", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // SetTeamIcon indicates an expected call of SetTeamIcon. func (mr *MockAPIMockRecorder) SetTeamIcon(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTeamIcon", reflect.TypeOf((*MockAPI)(nil).SetTeamIcon), arg0, arg1) } // SetUserStatusTimedDND mocks base method. func (m *MockAPI) SetUserStatusTimedDND(arg0 string, arg1 int64) (*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetUserStatusTimedDND", arg0, arg1) ret0, _ := ret[0].(*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // SetUserStatusTimedDND indicates an expected call of SetUserStatusTimedDND. func (mr *MockAPIMockRecorder) SetUserStatusTimedDND(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUserStatusTimedDND", reflect.TypeOf((*MockAPI)(nil).SetUserStatusTimedDND), arg0, arg1) } // UnregisterCommand mocks base method. func (m *MockAPI) UnregisterCommand(arg0, arg1 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnregisterCommand", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UnregisterCommand indicates an expected call of UnregisterCommand. func (mr *MockAPIMockRecorder) UnregisterCommand(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterCommand", reflect.TypeOf((*MockAPI)(nil).UnregisterCommand), arg0, arg1) } // UpdateBotActive mocks base method. func (m *MockAPI) UpdateBotActive(arg0 string, arg1 bool) (*model.Bot, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateBotActive", arg0, arg1) ret0, _ := ret[0].(*model.Bot) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateBotActive indicates an expected call of UpdateBotActive. func (mr *MockAPIMockRecorder) UpdateBotActive(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateBotActive", reflect.TypeOf((*MockAPI)(nil).UpdateBotActive), arg0, arg1) } // UpdateChannel mocks base method. func (m *MockAPI) UpdateChannel(arg0 *model.Channel) (*model.Channel, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannel", arg0) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannel indicates an expected call of UpdateChannel. func (mr *MockAPIMockRecorder) UpdateChannel(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannel", reflect.TypeOf((*MockAPI)(nil).UpdateChannel), arg0) } // UpdateChannelMemberNotifications mocks base method. func (m *MockAPI) UpdateChannelMemberNotifications(arg0, arg1 string, arg2 map[string]string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannelMemberNotifications", arg0, arg1, arg2) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannelMemberNotifications indicates an expected call of UpdateChannelMemberNotifications. func (mr *MockAPIMockRecorder) UpdateChannelMemberNotifications(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannelMemberNotifications", reflect.TypeOf((*MockAPI)(nil).UpdateChannelMemberNotifications), arg0, arg1, arg2) } // UpdateChannelMemberRoles mocks base method. func (m *MockAPI) UpdateChannelMemberRoles(arg0, arg1, arg2 string) (*model.ChannelMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannelMemberRoles", arg0, arg1, arg2) ret0, _ := ret[0].(*model.ChannelMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannelMemberRoles indicates an expected call of UpdateChannelMemberRoles. func (mr *MockAPIMockRecorder) UpdateChannelMemberRoles(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannelMemberRoles", reflect.TypeOf((*MockAPI)(nil).UpdateChannelMemberRoles), arg0, arg1, arg2) } // UpdateChannelSidebarCategories mocks base method. func (m *MockAPI) UpdateChannelSidebarCategories(arg0, arg1 string, arg2 []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChannelSidebarCategories", arg0, arg1, arg2) ret0, _ := ret[0].([]*model.SidebarCategoryWithChannels) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateChannelSidebarCategories indicates an expected call of UpdateChannelSidebarCategories. func (mr *MockAPIMockRecorder) UpdateChannelSidebarCategories(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannelSidebarCategories", reflect.TypeOf((*MockAPI)(nil).UpdateChannelSidebarCategories), arg0, arg1, arg2) } // UpdateCommand mocks base method. func (m *MockAPI) UpdateCommand(arg0 string, arg1 *model.Command) (*model.Command, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateCommand", arg0, arg1) ret0, _ := ret[0].(*model.Command) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateCommand indicates an expected call of UpdateCommand. func (mr *MockAPIMockRecorder) UpdateCommand(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCommand", reflect.TypeOf((*MockAPI)(nil).UpdateCommand), arg0, arg1) } // UpdateEphemeralPost mocks base method. func (m *MockAPI) UpdateEphemeralPost(arg0 string, arg1 *model.Post) *model.Post { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateEphemeralPost", arg0, arg1) ret0, _ := ret[0].(*model.Post) return ret0 } // UpdateEphemeralPost indicates an expected call of UpdateEphemeralPost. func (mr *MockAPIMockRecorder) UpdateEphemeralPost(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEphemeralPost", reflect.TypeOf((*MockAPI)(nil).UpdateEphemeralPost), arg0, arg1) } // UpdateOAuthApp mocks base method. func (m *MockAPI) UpdateOAuthApp(arg0 *model.OAuthApp) (*model.OAuthApp, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateOAuthApp", arg0) ret0, _ := ret[0].(*model.OAuthApp) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateOAuthApp indicates an expected call of UpdateOAuthApp. func (mr *MockAPIMockRecorder) UpdateOAuthApp(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOAuthApp", reflect.TypeOf((*MockAPI)(nil).UpdateOAuthApp), arg0) } // UpdatePost mocks base method. func (m *MockAPI) UpdatePost(arg0 *model.Post) (*model.Post, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdatePost", arg0) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdatePost indicates an expected call of UpdatePost. func (mr *MockAPIMockRecorder) UpdatePost(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePost", reflect.TypeOf((*MockAPI)(nil).UpdatePost), arg0) } // UpdatePreferencesForUser mocks base method. func (m *MockAPI) UpdatePreferencesForUser(arg0 string, arg1 []model.Preference) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdatePreferencesForUser", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // UpdatePreferencesForUser indicates an expected call of UpdatePreferencesForUser. func (mr *MockAPIMockRecorder) UpdatePreferencesForUser(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePreferencesForUser", reflect.TypeOf((*MockAPI)(nil).UpdatePreferencesForUser), arg0, arg1) } // UpdateTeam mocks base method. func (m *MockAPI) UpdateTeam(arg0 *model.Team) (*model.Team, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateTeam", arg0) ret0, _ := ret[0].(*model.Team) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateTeam indicates an expected call of UpdateTeam. func (mr *MockAPIMockRecorder) UpdateTeam(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeam", reflect.TypeOf((*MockAPI)(nil).UpdateTeam), arg0) } // UpdateTeamMemberRoles mocks base method. func (m *MockAPI) UpdateTeamMemberRoles(arg0, arg1, arg2 string) (*model.TeamMember, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateTeamMemberRoles", arg0, arg1, arg2) ret0, _ := ret[0].(*model.TeamMember) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateTeamMemberRoles indicates an expected call of UpdateTeamMemberRoles. func (mr *MockAPIMockRecorder) UpdateTeamMemberRoles(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeamMemberRoles", reflect.TypeOf((*MockAPI)(nil).UpdateTeamMemberRoles), arg0, arg1, arg2) } // UpdateUser mocks base method. func (m *MockAPI) UpdateUser(arg0 *model.User) (*model.User, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUser", arg0) ret0, _ := ret[0].(*model.User) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateUser indicates an expected call of UpdateUser. func (mr *MockAPIMockRecorder) UpdateUser(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockAPI)(nil).UpdateUser), arg0) } // UpdateUserActive mocks base method. func (m *MockAPI) UpdateUserActive(arg0 string, arg1 bool) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserActive", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // UpdateUserActive indicates an expected call of UpdateUserActive. func (mr *MockAPIMockRecorder) UpdateUserActive(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserActive", reflect.TypeOf((*MockAPI)(nil).UpdateUserActive), arg0, arg1) } // UpdateUserCustomStatus mocks base method. func (m *MockAPI) UpdateUserCustomStatus(arg0 string, arg1 *model.CustomStatus) *model.AppError { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserCustomStatus", arg0, arg1) ret0, _ := ret[0].(*model.AppError) return ret0 } // UpdateUserCustomStatus indicates an expected call of UpdateUserCustomStatus. func (mr *MockAPIMockRecorder) UpdateUserCustomStatus(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserCustomStatus), arg0, arg1) } // UpdateUserStatus mocks base method. func (m *MockAPI) UpdateUserStatus(arg0, arg1 string) (*model.Status, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserStatus", arg0, arg1) ret0, _ := ret[0].(*model.Status) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UpdateUserStatus indicates an expected call of UpdateUserStatus. func (mr *MockAPIMockRecorder) UpdateUserStatus(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserStatus), arg0, arg1) } // UploadData mocks base method. func (m *MockAPI) UploadData(arg0 *model.UploadSession, arg1 io.Reader) (*model.FileInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UploadData", arg0, arg1) ret0, _ := ret[0].(*model.FileInfo) ret1, _ := ret[1].(error) return ret0, ret1 } // UploadData indicates an expected call of UploadData. func (mr *MockAPIMockRecorder) UploadData(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadData", reflect.TypeOf((*MockAPI)(nil).UploadData), arg0, arg1) } // UploadFile mocks base method. func (m *MockAPI) UploadFile(arg0 []byte, arg1, arg2 string) (*model.FileInfo, *model.AppError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UploadFile", arg0, arg1, arg2) ret0, _ := ret[0].(*model.FileInfo) ret1, _ := ret[1].(*model.AppError) return ret0, ret1 } // UploadFile indicates an expected call of UploadFile. func (mr *MockAPIMockRecorder) UploadFile(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadFile", reflect.TypeOf((*MockAPI)(nil).UploadFile), arg0, arg1, arg2) } ================================================ FILE: server/ws/mocks/mockstore.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/mattermost/focalboard/server/ws (interfaces: Store) // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/focalboard/server/model" ) // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller recorder *MockStoreMockRecorder } // MockStoreMockRecorder is the mock recorder for MockStore. type MockStoreMockRecorder struct { mock *MockStore } // NewMockStore creates a new mock instance. func NewMockStore(ctrl *gomock.Controller) *MockStore { mock := &MockStore{ctrl: ctrl} mock.recorder = &MockStoreMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStore) EXPECT() *MockStoreMockRecorder { return m.recorder } // GetBlock mocks base method. func (m *MockStore) GetBlock(arg0 string) (*model.Block, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBlock", arg0) ret0, _ := ret[0].(*model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetBlock indicates an expected call of GetBlock. func (mr *MockStoreMockRecorder) GetBlock(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlock", reflect.TypeOf((*MockStore)(nil).GetBlock), arg0) } // GetMembersForBoard mocks base method. func (m *MockStore) GetMembersForBoard(arg0 string) ([]*model.BoardMember, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetMembersForBoard", arg0) ret0, _ := ret[0].([]*model.BoardMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetMembersForBoard indicates an expected call of GetMembersForBoard. func (mr *MockStoreMockRecorder) GetMembersForBoard(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMembersForBoard", reflect.TypeOf((*MockStore)(nil).GetMembersForBoard), arg0) } ================================================ FILE: server/ws/plugin_adapter.go ================================================ //go:generate mockgen -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API package ws import ( "fmt" "strings" "sync" "sync/atomic" "time" "github.com/mattermost/focalboard/server/auth" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) const websocketMessagePrefix = "custom_focalboard_" var errMissingTeamInCommand = fmt.Errorf("command doesn't contain teamId") type PluginAdapterInterface interface { Adapter OnWebSocketConnect(webConnID, userID string) OnWebSocketDisconnect(webConnID, userID string) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest) BroadcastConfigChange(clientConfig model.ClientConfig) BroadcastBlockChange(teamID string, block *model.Block) BroadcastBlockDelete(teamID, blockID, parentID string) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) HandleClusterEvent(ev mmModel.PluginClusterEvent) } type PluginAdapter struct { api servicesAPI auth auth.AuthInterface staleThreshold time.Duration store Store logger mlog.LoggerIFace listenersMU sync.RWMutex listeners map[string]*PluginAdapterClient listenersByUserID map[string][]*PluginAdapterClient subscriptionsMU sync.RWMutex listenersByTeam map[string][]*PluginAdapterClient listenersByBlock map[string][]*PluginAdapterClient } // servicesAPI is the interface required by the PluginAdapter to interact with // the mattermost-server. type servicesAPI interface { PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mmModel.WebsocketBroadcast) PublishPluginClusterEvent(ev mmModel.PluginClusterEvent, opts mmModel.PluginClusterEventSendOptions) error } func NewPluginAdapter(api servicesAPI, auth auth.AuthInterface, store Store, logger mlog.LoggerIFace) *PluginAdapter { return &PluginAdapter{ api: api, auth: auth, store: store, staleThreshold: 5 * time.Minute, logger: logger, listeners: make(map[string]*PluginAdapterClient), listenersByUserID: make(map[string][]*PluginAdapterClient), listenersByTeam: make(map[string][]*PluginAdapterClient), listenersByBlock: make(map[string][]*PluginAdapterClient), listenersMU: sync.RWMutex{}, subscriptionsMU: sync.RWMutex{}, } } func (pa *PluginAdapter) GetListenerByWebConnID(webConnID string) (pac *PluginAdapterClient, ok bool) { pa.listenersMU.RLock() defer pa.listenersMU.RUnlock() pac, ok = pa.listeners[webConnID] return } func (pa *PluginAdapter) GetListenersByUserID(userID string) []*PluginAdapterClient { pa.listenersMU.RLock() defer pa.listenersMU.RUnlock() return pa.listenersByUserID[userID] } func (pa *PluginAdapter) GetListenersByTeam(teamID string) []*PluginAdapterClient { pa.subscriptionsMU.RLock() defer pa.subscriptionsMU.RUnlock() return pa.listenersByTeam[teamID] } func (pa *PluginAdapter) GetListenersByBlock(blockID string) []*PluginAdapterClient { pa.subscriptionsMU.RLock() defer pa.subscriptionsMU.RUnlock() return pa.listenersByBlock[blockID] } func (pa *PluginAdapter) addListener(pac *PluginAdapterClient) { pa.listenersMU.Lock() defer pa.listenersMU.Unlock() pa.listeners[pac.webConnID] = pac pa.listenersByUserID[pac.userID] = append(pa.listenersByUserID[pac.userID], pac) } func (pa *PluginAdapter) removeListener(pac *PluginAdapterClient) { pa.listenersMU.Lock() defer pa.listenersMU.Unlock() // team subscriptions for _, team := range pac.teams { pa.removeListenerFromTeam(pac, team) } // block subscriptions for _, block := range pac.blocks { pa.removeListenerFromBlock(pac, block) } // user ID list newUserListeners := []*PluginAdapterClient{} for _, listener := range pa.listenersByUserID[pac.userID] { if listener.webConnID != pac.webConnID { newUserListeners = append(newUserListeners, listener) } } pa.listenersByUserID[pac.userID] = newUserListeners delete(pa.listeners, pac.webConnID) } func (pa *PluginAdapter) removeExpiredForUserID(userID string) { for _, pac := range pa.GetListenersByUserID(userID) { if !pac.isActive() && pac.hasExpired(pa.staleThreshold) { pa.removeListener(pac) } } } func (pa *PluginAdapter) removeListenerFromTeam(pac *PluginAdapterClient, teamID string) { newTeamListeners := []*PluginAdapterClient{} for _, listener := range pa.GetListenersByTeam(teamID) { if listener.webConnID != pac.webConnID { newTeamListeners = append(newTeamListeners, listener) } } pa.subscriptionsMU.Lock() pa.listenersByTeam[teamID] = newTeamListeners pa.subscriptionsMU.Unlock() pac.unsubscribeFromTeam(teamID) } func (pa *PluginAdapter) removeListenerFromBlock(pac *PluginAdapterClient, blockID string) { newBlockListeners := []*PluginAdapterClient{} for _, listener := range pa.GetListenersByBlock(blockID) { if listener.webConnID != pac.webConnID { newBlockListeners = append(newBlockListeners, listener) } } pa.subscriptionsMU.Lock() pa.listenersByBlock[blockID] = newBlockListeners pa.subscriptionsMU.Unlock() pac.unsubscribeFromBlock(blockID) } func (pa *PluginAdapter) subscribeListenerToTeam(pac *PluginAdapterClient, teamID string) { if pac.isSubscribedToTeam(teamID) { return } pa.subscriptionsMU.Lock() pa.listenersByTeam[teamID] = append(pa.listenersByTeam[teamID], pac) pa.subscriptionsMU.Unlock() pac.subscribeToTeam(teamID) } func (pa *PluginAdapter) unsubscribeListenerFromTeam(pac *PluginAdapterClient, teamID string) { if !pac.isSubscribedToTeam(teamID) { return } pa.removeListenerFromTeam(pac, teamID) } func (pa *PluginAdapter) getUserIDsForTeam(teamID string) []string { userMap := map[string]bool{} for _, pac := range pa.GetListenersByTeam(teamID) { if pac.isActive() { userMap[pac.userID] = true } } userIDs := []string{} for userID := range userMap { if pa.auth.DoesUserHaveTeamAccess(userID, teamID) { userIDs = append(userIDs, userID) } } return userIDs } func (pa *PluginAdapter) getUserIDsForTeamAndBoard(teamID, boardID string, ensureUserIDs ...string) []string { userMap := map[string]bool{} for _, pac := range pa.GetListenersByTeam(teamID) { if pac.isActive() { userMap[pac.userID] = true } } members, err := pa.store.GetMembersForBoard(boardID) if err != nil { pa.logger.Error("error getting members for board", mlog.String("method", "getUserIDsForTeamAndBoard"), mlog.String("teamID", teamID), mlog.String("boardID", boardID), ) return nil } // the list of users would be the intersection between the ones // that are connected to the team and the board members that need // to see the updates userIDs := []string{} for _, member := range members { for userID := range userMap { if userID == member.UserID && pa.auth.DoesUserHaveTeamAccess(userID, teamID) { userIDs = append(userIDs, userID) } } } // if we don't have to make sure that some IDs are included, we // can return at this point if len(ensureUserIDs) == 0 { return userIDs } completeUserMap := map[string]bool{} for _, id := range userIDs { completeUserMap[id] = true } for _, id := range ensureUserIDs { completeUserMap[id] = true } completeUserIDs := []string{} for id := range completeUserMap { completeUserIDs = append(completeUserIDs, id) } return completeUserIDs } //nolint:unused func (pa *PluginAdapter) unsubscribeListenerFromBlocks(pac *PluginAdapterClient, blockIDs []string) { for _, blockID := range blockIDs { if pac.isSubscribedToBlock(blockID) { pa.removeListenerFromBlock(pac, blockID) } } } func (pa *PluginAdapter) OnWebSocketConnect(webConnID, userID string) { if existingPAC, ok := pa.GetListenerByWebConnID(webConnID); ok { pa.logger.Debug("inactive connection found for webconn, reusing", mlog.String("webConnID", webConnID), mlog.String("userID", userID), ) atomic.StoreInt64(&existingPAC.inactiveAt, 0) return } newPAC := &PluginAdapterClient{ inactiveAt: 0, webConnID: webConnID, userID: userID, teams: []string{}, blocks: []string{}, } pa.addListener(newPAC) pa.removeExpiredForUserID(userID) } func (pa *PluginAdapter) OnWebSocketDisconnect(webConnID, userID string) { pac, ok := pa.GetListenerByWebConnID(webConnID) if !ok { pa.logger.Debug("received a disconnect for an unregistered webconn", mlog.String("webConnID", webConnID), mlog.String("userID", userID), ) return } atomic.StoreInt64(&pac.inactiveAt, mmModel.GetMillis()) } func commandFromRequest(req *mmModel.WebSocketRequest) (*WebsocketCommand, error) { c := &WebsocketCommand{Action: strings.TrimPrefix(req.Action, websocketMessagePrefix)} if teamID, ok := req.Data["teamId"]; ok { c.TeamID = teamID.(string) } else { return nil, errMissingTeamInCommand } if readToken, ok := req.Data["readToken"]; ok { c.ReadToken = readToken.(string) } if blockIDs, ok := req.Data["blockIds"]; ok { c.BlockIDs = blockIDs.([]string) } return c, nil } func (pa *PluginAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest) { pac, ok := pa.GetListenerByWebConnID(webConnID) if !ok { pa.logger.Debug("received a message for an unregistered webconn", mlog.String("webConnID", webConnID), mlog.String("userID", userID), mlog.String("action", req.Action), ) return } // only process messages using the plugin actions if !strings.HasPrefix(req.Action, websocketMessagePrefix) { return } command, err := commandFromRequest(req) if err != nil { pa.logger.Error("error getting command from request", mlog.String("action", req.Action), mlog.String("webConnID", webConnID), mlog.String("userID", userID), mlog.Err(err), ) return } switch command.Action { // The block-related commands are not implemented in the adapter // as there is no such thing as unauthenticated websocket // connections in plugin mode. Only a debug line is logged case websocketActionSubscribeBlocks, websocketActionUnsubscribeBlocks: pa.logger.Debug(`Command not implemented in plugin mode`, mlog.String("command", command.Action), mlog.String("webConnID", webConnID), mlog.String("userID", userID), mlog.String("teamID", command.TeamID), ) case websocketActionSubscribeTeam: pa.logger.Debug(`Command not implemented in plugin mode`, mlog.String("command", command.Action), mlog.String("webConnID", webConnID), mlog.String("userID", userID), mlog.String("teamID", command.TeamID), ) if !pa.auth.DoesUserHaveTeamAccess(userID, command.TeamID) { return } pa.subscribeListenerToTeam(pac, command.TeamID) case websocketActionUnsubscribeTeam: pa.logger.Debug(`Command: UNSUBSCRIBE_WORKSPACE`, mlog.String("webConnID", webConnID), mlog.String("userID", userID), mlog.String("teamID", command.TeamID), ) pa.unsubscribeListenerFromTeam(pac, command.TeamID) } } // sendMessageToAll will send a websocket message to all clients on all nodes. func (pa *PluginAdapter) sendMessageToAll(event string, payload map[string]interface{}) { // Empty &mmModel.WebsocketBroadcast will send to all users pa.api.PublishWebSocketEvent(event, payload, &mmModel.WebsocketBroadcast{}) } func (pa *PluginAdapter) BroadcastConfigChange(pluginConfig model.ClientConfig) { pa.sendMessageToAll(websocketActionUpdateConfig, utils.StructToMap(pluginConfig)) } // sendUserMessageSkipCluster sends the message to specific users. func (pa *PluginAdapter) sendUserMessageSkipCluster(event string, payload map[string]interface{}, userIDs ...string) { for _, userID := range userIDs { pa.api.PublishWebSocketEvent(event, payload, &mmModel.WebsocketBroadcast{UserId: userID}) } } // sendTeamMessageSkipCluster sends a message to all the users // with a websocket client subscribed to a given team. func (pa *PluginAdapter) sendTeamMessageSkipCluster(event, teamID string, payload map[string]interface{}) { userIDs := pa.getUserIDsForTeam(teamID) pa.sendUserMessageSkipCluster(event, payload, userIDs...) } // sendTeamMessage sends and propagates a message that is aimed // for all the users that are subscribed to a given team. func (pa *PluginAdapter) sendTeamMessage(event, teamID string, payload map[string]interface{}, ensureUserIDs ...string) { go func() { clusterMessage := &ClusterMessage{ TeamID: teamID, Payload: payload, EnsureUsers: ensureUserIDs, } pa.sendMessageToCluster(clusterMessage) }() pa.sendTeamMessageSkipCluster(event, teamID, payload) } // sendBoardMessageSkipCluster sends a message to all the users // subscribed to a given team that belong to one of its boards. func (pa *PluginAdapter) sendBoardMessageSkipCluster(teamID, boardID string, payload map[string]interface{}, ensureUserIDs ...string) { userIDs := pa.getUserIDsForTeamAndBoard(teamID, boardID, ensureUserIDs...) pa.sendUserMessageSkipCluster(websocketActionUpdateBoard, payload, userIDs...) } // sendBoardMessage sends and propagates a message that is aimed for // all the users that are subscribed to the board's team and are // members of it too. func (pa *PluginAdapter) sendBoardMessage(teamID, boardID string, payload map[string]interface{}, ensureUserIDs ...string) { go func() { clusterMessage := &ClusterMessage{ TeamID: teamID, BoardID: boardID, Payload: payload, EnsureUsers: ensureUserIDs, } pa.sendMessageToCluster(clusterMessage) }() pa.sendBoardMessageSkipCluster(teamID, boardID, payload, ensureUserIDs...) } func (pa *PluginAdapter) BroadcastBlockChange(teamID string, block *model.Block) { pa.logger.Trace("BroadcastingBlockChange", mlog.String("teamID", teamID), mlog.String("boardID", block.BoardID), mlog.String("blockID", block.ID), ) message := UpdateBlockMsg{ Action: websocketActionUpdateBlock, TeamID: teamID, Block: block, } pa.sendBoardMessage(teamID, block.BoardID, utils.StructToMap(message)) } func (pa *PluginAdapter) BroadcastCategoryChange(category model.Category) { pa.logger.Debug("BroadcastCategoryChange", mlog.String("userID", category.UserID), mlog.String("teamID", category.TeamID), mlog.String("categoryID", category.ID), ) message := UpdateCategoryMessage{ Action: websocketActionUpdateCategory, TeamID: category.TeamID, Category: &category, } payload := utils.StructToMap(message) go func() { clusterMessage := &ClusterMessage{ Payload: payload, UserID: category.UserID, } pa.sendMessageToCluster(clusterMessage) }() pa.sendUserMessageSkipCluster(websocketActionUpdateCategory, payload, category.UserID) } func (pa *PluginAdapter) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) { pa.logger.Debug("BroadcastCategoryReorder", mlog.String("userID", userID), mlog.String("teamID", teamID), ) message := CategoryReorderMessage{ Action: websocketActionReorderCategories, CategoryOrder: categoryOrder, TeamID: teamID, } payload := utils.StructToMap(message) go func() { clusterMessage := &ClusterMessage{ Payload: payload, UserID: userID, } pa.sendMessageToCluster(clusterMessage) }() pa.sendUserMessageSkipCluster(message.Action, payload, userID) } func (pa *PluginAdapter) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string) { pa.logger.Debug("BroadcastCategoryBoardsReorder", mlog.String("userID", userID), mlog.String("teamID", teamID), mlog.String("categoryID", categoryID), ) message := CategoryBoardReorderMessage{ Action: websocketActionReorderCategoryBoards, CategoryID: categoryID, BoardOrder: boardsOrder, TeamID: teamID, } payload := utils.StructToMap(message) go func() { clusterMessage := &ClusterMessage{ Payload: payload, UserID: userID, } pa.sendMessageToCluster(clusterMessage) }() pa.sendUserMessageSkipCluster(message.Action, payload, userID) } func (pa *PluginAdapter) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) { pa.logger.Debug( "BroadcastCategoryBoardChange", mlog.String("userID", userID), mlog.String("teamID", teamID), mlog.Int("numEntries", len(boardCategories)), ) message := UpdateCategoryMessage{ Action: websocketActionUpdateCategoryBoard, TeamID: teamID, BoardCategories: boardCategories, } payload := utils.StructToMap(message) go func() { clusterMessage := &ClusterMessage{ Payload: payload, UserID: userID, } pa.sendMessageToCluster(clusterMessage) }() pa.sendUserMessageSkipCluster(websocketActionUpdateCategoryBoard, utils.StructToMap(message), userID) } func (pa *PluginAdapter) BroadcastBlockDelete(teamID, blockID, boardID string) { now := utils.GetMillis() block := &model.Block{} block.ID = blockID block.BoardID = boardID block.UpdateAt = now block.DeleteAt = now pa.BroadcastBlockChange(teamID, block) } func (pa *PluginAdapter) BroadcastBoardChange(teamID string, board *model.Board) { pa.logger.Debug("BroadcastingBoardChange", mlog.String("teamID", teamID), mlog.String("boardID", board.ID), ) message := UpdateBoardMsg{ Action: websocketActionUpdateBoard, TeamID: teamID, Board: board, } pa.sendBoardMessage(teamID, board.ID, utils.StructToMap(message)) } func (pa *PluginAdapter) BroadcastBoardDelete(teamID, boardID string) { now := utils.GetMillis() board := &model.Board{} board.ID = boardID board.TeamID = teamID board.UpdateAt = now board.DeleteAt = now pa.BroadcastBoardChange(teamID, board) } func (pa *PluginAdapter) BroadcastMemberChange(teamID, boardID string, member *model.BoardMember) { pa.logger.Debug("BroadcastingMemberChange", mlog.String("teamID", teamID), mlog.String("boardID", boardID), mlog.String("userID", member.UserID), ) message := UpdateMemberMsg{ Action: websocketActionUpdateMember, TeamID: teamID, Member: member, } pa.sendBoardMessage(teamID, boardID, utils.StructToMap(message), member.UserID) } func (pa *PluginAdapter) BroadcastMemberDelete(teamID, boardID, userID string) { pa.logger.Debug("BroadcastingMemberDelete", mlog.String("teamID", teamID), mlog.String("boardID", boardID), mlog.String("userID", userID), ) message := UpdateMemberMsg{ Action: websocketActionDeleteMember, TeamID: teamID, Member: &model.BoardMember{UserID: userID, BoardID: boardID}, } // when fetching the members of the board that should receive the // member deletion message, the deleted member will not be one of // them, so we need to ensure they receive the message pa.sendBoardMessage(teamID, boardID, utils.StructToMap(message), userID) } func (pa *PluginAdapter) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) { pa.logger.Debug("BroadcastingSubscriptionChange", mlog.String("TeamID", teamID), mlog.String("blockID", subscription.BlockID), mlog.String("subscriberID", subscription.SubscriberID), ) message := UpdateSubscription{ Action: websocketActionUpdateSubscription, Subscription: subscription, } pa.sendTeamMessage(websocketActionUpdateSubscription, teamID, utils.StructToMap(message)) } func (pa *PluginAdapter) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) { pa.logger.Debug("BroadcastCardLimitTimestampChange", mlog.Int("cardLimitTimestamp", cardLimitTimestamp), ) message := UpdateCardLimitTimestamp{ Action: websocketActionUpdateCardLimitTimestamp, Timestamp: cardLimitTimestamp, } pa.sendMessageToAll(websocketActionUpdateCardLimitTimestamp, utils.StructToMap(message)) } ================================================ FILE: server/ws/plugin_adapter_client.go ================================================ package ws import ( "sync" "sync/atomic" "time" mmModel "github.com/mattermost/mattermost/server/public/model" ) type PluginAdapterClient struct { inactiveAt int64 webConnID string userID string teams []string blocks []string mu sync.RWMutex } func (pac *PluginAdapterClient) isActive() bool { return atomic.LoadInt64(&pac.inactiveAt) == 0 } func (pac *PluginAdapterClient) hasExpired(threshold time.Duration) bool { return !mmModel.GetTimeForMillis(atomic.LoadInt64(&pac.inactiveAt)).Add(threshold).After(time.Now()) } func (pac *PluginAdapterClient) subscribeToTeam(teamID string) { pac.mu.Lock() defer pac.mu.Unlock() pac.teams = append(pac.teams, teamID) } func (pac *PluginAdapterClient) unsubscribeFromTeam(teamID string) { pac.mu.Lock() defer pac.mu.Unlock() newClientTeams := []string{} for _, id := range pac.teams { if id != teamID { newClientTeams = append(newClientTeams, id) } } pac.teams = newClientTeams } func (pac *PluginAdapterClient) unsubscribeFromBlock(blockID string) { pac.mu.Lock() defer pac.mu.Unlock() newClientBlocks := []string{} for _, id := range pac.blocks { if id != blockID { newClientBlocks = append(newClientBlocks, id) } } pac.blocks = newClientBlocks } func (pac *PluginAdapterClient) isSubscribedToTeam(teamID string) bool { pac.mu.RLock() defer pac.mu.RUnlock() for _, id := range pac.teams { if id == teamID { return true } } return false } //nolint:unused func (pac *PluginAdapterClient) isSubscribedToBlock(blockID string) bool { pac.mu.RLock() defer pac.mu.RUnlock() for _, id := range pac.blocks { if id == blockID { return true } } return false } ================================================ FILE: server/ws/plugin_adapter_cluster.go ================================================ package ws import ( "encoding/json" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) type ClusterMessage struct { TeamID string BoardID string UserID string Payload map[string]interface{} EnsureUsers []string } func (pa *PluginAdapter) sendMessageToCluster(clusterMessage *ClusterMessage) { const id = "websocket_message" b, err := json.Marshal(clusterMessage) if err != nil { pa.logger.Error("couldn't get JSON bytes from cluster message", mlog.String("id", id), mlog.Err(err), ) return } event := mmModel.PluginClusterEvent{Id: id, Data: b} opts := mmModel.PluginClusterEventSendOptions{ SendType: mmModel.PluginClusterEventSendTypeReliable, } if err := pa.api.PublishPluginClusterEvent(event, opts); err != nil { pa.logger.Error("error publishing cluster event", mlog.String("id", id), mlog.Err(err), ) } } func (pa *PluginAdapter) HandleClusterEvent(ev mmModel.PluginClusterEvent) { pa.logger.Debug("received cluster event", mlog.String("id", ev.Id)) var clusterMessage ClusterMessage if err := json.Unmarshal(ev.Data, &clusterMessage); err != nil { pa.logger.Error("cannot unmarshal cluster message data", mlog.String("id", ev.Id), mlog.Err(err), ) return } if clusterMessage.BoardID != "" { pa.sendBoardMessageSkipCluster(clusterMessage.TeamID, clusterMessage.BoardID, clusterMessage.Payload, clusterMessage.EnsureUsers...) return } var action string if actionRaw, ok := clusterMessage.Payload["action"]; ok { if s, ok := actionRaw.(string); ok { action = s } } if action == "" { // no action was specified in the event; assume block change and warn. pa.logger.Warn("cannot determine action from cluster message data", mlog.String("id", ev.Id), mlog.Map("payload", clusterMessage.Payload), ) return } if clusterMessage.UserID != "" { pa.sendUserMessageSkipCluster(action, clusterMessage.Payload, clusterMessage.UserID) return } pa.sendTeamMessageSkipCluster(action, clusterMessage.TeamID, clusterMessage.Payload) } ================================================ FILE: server/ws/plugin_adapter_test.go ================================================ package ws import ( "sync" "testing" "github.com/mattermost/focalboard/server/model" mmModel "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/require" ) func TestPluginAdapterTeamSubscription(t *testing.T) { th := SetupTestHelper(t) webConnID := mmModel.NewId() userID := mmModel.NewId() teamID := mmModel.NewId() var pac *PluginAdapterClient t.Run("Should correctly add a connection", func(t *testing.T) { require.Empty(t, th.pa.listeners) require.Empty(t, th.pa.listenersByTeam) th.pa.OnWebSocketConnect(webConnID, userID) require.Len(t, th.pa.listeners, 1) var ok bool pac, ok = th.pa.listeners[webConnID] require.True(t, ok) require.NotNil(t, pac) require.Equal(t, userID, pac.userID) require.Empty(t, th.pa.listenersByTeam) }) t.Run("Should correctly subscribe to a team", func(t *testing.T) { require.False(t, pac.isSubscribedToTeam(teamID)) th.SubscribeWebConnToTeam(pac.webConnID, pac.userID, teamID) require.Len(t, th.pa.listenersByTeam[teamID], 1) require.Contains(t, th.pa.listenersByTeam[teamID], pac) require.Len(t, pac.teams, 1) require.Contains(t, pac.teams, teamID) require.True(t, pac.isSubscribedToTeam(teamID)) }) t.Run("Subscribing again to a subscribed team would have no effect", func(t *testing.T) { require.True(t, pac.isSubscribedToTeam(teamID)) th.SubscribeWebConnToTeam(pac.webConnID, pac.userID, teamID) require.Len(t, th.pa.listenersByTeam[teamID], 1) require.Contains(t, th.pa.listenersByTeam[teamID], pac) require.Len(t, pac.teams, 1) require.Contains(t, pac.teams, teamID) require.True(t, pac.isSubscribedToTeam(teamID)) }) t.Run("Should correctly unsubscribe to a team", func(t *testing.T) { require.True(t, pac.isSubscribedToTeam(teamID)) th.UnsubscribeWebConnFromTeam(pac.webConnID, pac.userID, teamID) require.Empty(t, th.pa.listenersByTeam[teamID]) require.Empty(t, pac.teams) require.False(t, pac.isSubscribedToTeam(teamID)) }) t.Run("Unsubscribing again to an unsubscribed team would have no effect", func(t *testing.T) { require.False(t, pac.isSubscribedToTeam(teamID)) th.UnsubscribeWebConnFromTeam(pac.webConnID, pac.userID, teamID) require.Empty(t, th.pa.listenersByTeam[teamID]) require.Empty(t, pac.teams) require.False(t, pac.isSubscribedToTeam(teamID)) }) t.Run("Should correctly be marked as inactive if disconnected", func(t *testing.T) { require.Len(t, th.pa.listeners, 1) require.True(t, th.pa.listeners[webConnID].isActive()) th.pa.OnWebSocketDisconnect(webConnID, userID) require.Len(t, th.pa.listeners, 1) require.False(t, th.pa.listeners[webConnID].isActive()) }) t.Run("Should be marked back as active if reconnect", func(t *testing.T) { require.Len(t, th.pa.listeners, 1) require.False(t, th.pa.listeners[webConnID].isActive()) th.pa.OnWebSocketConnect(webConnID, userID) require.Len(t, th.pa.listeners, 1) require.True(t, th.pa.listeners[webConnID].isActive()) }) } func TestPluginAdapterClientReconnect(t *testing.T) { th := SetupTestHelper(t) webConnID := mmModel.NewId() userID := mmModel.NewId() teamID := mmModel.NewId() var pac *PluginAdapterClient t.Run("A user should be able to reconnect within the accepted threshold and keep their subscriptions", func(t *testing.T) { // create the connection require.Len(t, th.pa.listeners, 0) require.Len(t, th.pa.listenersByUserID[userID], 0) th.pa.OnWebSocketConnect(webConnID, userID) require.Len(t, th.pa.listeners, 1) require.Len(t, th.pa.listenersByUserID[userID], 1) var ok bool pac, ok = th.pa.listeners[webConnID] require.True(t, ok) require.NotNil(t, pac) th.SubscribeWebConnToTeam(pac.webConnID, pac.userID, teamID) require.True(t, pac.isSubscribedToTeam(teamID)) // disconnect th.pa.OnWebSocketDisconnect(webConnID, userID) require.False(t, pac.isActive()) require.Len(t, th.pa.listeners, 1) require.Len(t, th.pa.listenersByUserID[userID], 1) // reconnect right away. The connection should still be subscribed th.pa.OnWebSocketConnect(webConnID, userID) require.Len(t, th.pa.listeners, 1) require.Len(t, th.pa.listenersByUserID[userID], 1) require.True(t, pac.isActive()) require.True(t, pac.isSubscribedToTeam(teamID)) }) t.Run("Should remove old inactive connection when user connects with a different ID", func(t *testing.T) { // we set the stale threshold to zero so inactive connections always get deleted oldStaleThreshold := th.pa.staleThreshold th.pa.staleThreshold = 0 defer func() { th.pa.staleThreshold = oldStaleThreshold }() th.pa.OnWebSocketDisconnect(webConnID, userID) require.Len(t, th.pa.listeners, 1) require.Len(t, th.pa.listenersByUserID[userID], 1) require.Equal(t, webConnID, th.pa.listenersByUserID[userID][0].webConnID) newWebConnID := mmModel.NewId() th.pa.OnWebSocketConnect(newWebConnID, userID) require.Len(t, th.pa.listeners, 1) require.Len(t, th.pa.listenersByUserID[userID], 1) require.Contains(t, th.pa.listeners, newWebConnID) require.NotContains(t, th.pa.listeners, webConnID) require.Equal(t, newWebConnID, th.pa.listenersByUserID[userID][0].webConnID) // if the same ID connects again, it should have no subscriptions th.pa.OnWebSocketConnect(webConnID, userID) require.Len(t, th.pa.listeners, 2) require.Len(t, th.pa.listenersByUserID[userID], 2) reconnectedPAC, ok := th.pa.listeners[webConnID] require.True(t, ok) require.False(t, reconnectedPAC.isSubscribedToTeam(teamID)) }) t.Run("Should not remove active connections when user connects with a different ID", func(t *testing.T) { // we set the stale threshold to zero so inactive connections always get deleted oldStaleThreshold := th.pa.staleThreshold th.pa.staleThreshold = 0 defer func() { th.pa.staleThreshold = oldStaleThreshold }() // currently we have two listeners for userID, both active require.Len(t, th.pa.listeners, 2) // a new user connects th.pa.OnWebSocketConnect(mmModel.NewId(), userID) // and we should have three connections, all of them active require.Len(t, th.pa.listeners, 3) for _, listener := range th.pa.listeners { require.True(t, listener.isActive()) } }) } func TestGetUserIDsForTeam(t *testing.T) { th := SetupTestHelper(t) // we have two teams teamID1 := mmModel.NewId() teamID2 := mmModel.NewId() // user 1 has two connections userID1 := mmModel.NewId() webConnID1 := mmModel.NewId() webConnID2 := mmModel.NewId() // user 2 has one connection userID2 := mmModel.NewId() webConnID3 := mmModel.NewId() wg := new(sync.WaitGroup) wg.Add(3) go func(wg *sync.WaitGroup) { th.pa.OnWebSocketConnect(webConnID1, userID1) th.SubscribeWebConnToTeam(webConnID1, userID1, teamID1) wg.Done() }(wg) go func(wg *sync.WaitGroup) { th.pa.OnWebSocketConnect(webConnID2, userID1) th.SubscribeWebConnToTeam(webConnID2, userID1, teamID2) wg.Done() }(wg) go func(wg *sync.WaitGroup) { th.pa.OnWebSocketConnect(webConnID3, userID2) th.SubscribeWebConnToTeam(webConnID3, userID2, teamID2) wg.Done() }(wg) wg.Wait() t.Run("should find that only user1 is connected to team 1", func(t *testing.T) { th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID1). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeam(teamID1) require.ElementsMatch(t, []string{userID1}, userIDs) }) t.Run("should find that both users are connected to team 2", func(t *testing.T) { th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID2, teamID2). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeam(teamID2) require.ElementsMatch(t, []string{userID1, userID2}, userIDs) }) t.Run("should ignore user1 if webConn 2 inactive when getting team 2 user ids", func(t *testing.T) { th.pa.OnWebSocketDisconnect(webConnID2, userID1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID2, teamID2). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeam(teamID2) require.ElementsMatch(t, []string{userID2}, userIDs) }) t.Run("should still find user 1 in team 1 after the webConn 2 disconnection", func(t *testing.T) { th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID1). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeam(teamID1) require.ElementsMatch(t, []string{userID1}, userIDs) }) t.Run("should find again both users if the webConn 2 comes back", func(t *testing.T) { th.pa.OnWebSocketConnect(webConnID2, userID1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID2, teamID2). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeam(teamID2) require.ElementsMatch(t, []string{userID1, userID2}, userIDs) }) t.Run("should only find user 1 if user 2 has an active connection but is not a team member anymore", func(t *testing.T) { th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) // userID2 does not have team access th.auth.EXPECT(). DoesUserHaveTeamAccess(userID2, teamID2). Return(false). Times(1) userIDs := th.pa.getUserIDsForTeam(teamID2) require.ElementsMatch(t, []string{userID1}, userIDs) }) } func TestGetUserIDsForTeamAndBoard(t *testing.T) { th := SetupTestHelper(t) // we have two teams teamID1 := mmModel.NewId() boardID1 := mmModel.NewId() teamID2 := mmModel.NewId() boardID2 := mmModel.NewId() // user 1 has two connections userID1 := mmModel.NewId() webConnID1 := mmModel.NewId() webConnID2 := mmModel.NewId() // user 2 has one connection userID2 := mmModel.NewId() webConnID3 := mmModel.NewId() wg := new(sync.WaitGroup) wg.Add(3) go func(wg *sync.WaitGroup) { th.pa.OnWebSocketConnect(webConnID1, userID1) th.SubscribeWebConnToTeam(webConnID1, userID1, teamID1) wg.Done() }(wg) go func(wg *sync.WaitGroup) { th.pa.OnWebSocketConnect(webConnID2, userID1) th.SubscribeWebConnToTeam(webConnID2, userID1, teamID2) wg.Done() }(wg) go func(wg *sync.WaitGroup) { th.pa.OnWebSocketConnect(webConnID3, userID2) th.SubscribeWebConnToTeam(webConnID3, userID2, teamID2) wg.Done() }(wg) wg.Wait() t.Run("should find that only user1 is connected to team 1 and board 1", func(t *testing.T) { mockedMembers := []*model.BoardMember{{UserID: userID1}} th.store.EXPECT(). GetMembersForBoard(boardID1). Return(mockedMembers, nil). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID1). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeamAndBoard(teamID1, boardID1) require.ElementsMatch(t, []string{userID1}, userIDs) }) t.Run("should find that both users are connected to team 2 and board 2", func(t *testing.T) { mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}} th.store.EXPECT(). GetMembersForBoard(boardID2). Return(mockedMembers, nil). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID2, teamID2). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2) require.ElementsMatch(t, []string{userID1, userID2}, userIDs) }) t.Run("should find that only one user is connected to team 2 and board 2 if there is only one membership with both connected", func(t *testing.T) { mockedMembers := []*model.BoardMember{{UserID: userID1}} th.store.EXPECT(). GetMembersForBoard(boardID2). Return(mockedMembers, nil). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2) require.ElementsMatch(t, []string{userID1}, userIDs) }) t.Run("should find only one if the other is inactive", func(t *testing.T) { th.pa.OnWebSocketDisconnect(webConnID3, userID2) defer th.pa.OnWebSocketConnect(webConnID3, userID2) mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}} th.store.EXPECT(). GetMembersForBoard(boardID2). Return(mockedMembers, nil). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2) require.ElementsMatch(t, []string{userID1}, userIDs) }) t.Run("should include a user that is not present if it's ensured", func(t *testing.T) { userID3 := mmModel.NewId() mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}} th.store.EXPECT(). GetMembersForBoard(boardID2). Return(mockedMembers, nil). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID2, teamID2). Return(true). Times(1) userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2, userID3) require.ElementsMatch(t, []string{userID1, userID2, userID3}, userIDs) }) t.Run("should not include a user that, although present, has no team access anymore", func(t *testing.T) { mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}} th.store.EXPECT(). GetMembersForBoard(boardID2). Return(mockedMembers, nil). Times(1) th.auth.EXPECT(). DoesUserHaveTeamAccess(userID1, teamID2). Return(true). Times(1) // userID2 has no team access th.auth.EXPECT(). DoesUserHaveTeamAccess(userID2, teamID2). Return(false). Times(1) userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2) require.ElementsMatch(t, []string{userID1}, userIDs) }) } func TestParallelSubscriptionsOnMultipleConnections(t *testing.T) { th := SetupTestHelper(t) teamID1 := mmModel.NewId() teamID2 := mmModel.NewId() teamID3 := mmModel.NewId() teamID4 := mmModel.NewId() userID := mmModel.NewId() webConnID1 := mmModel.NewId() webConnID2 := mmModel.NewId() th.pa.OnWebSocketConnect(webConnID1, userID) pac1, ok := th.pa.GetListenerByWebConnID(webConnID1) require.True(t, ok) th.pa.OnWebSocketConnect(webConnID2, userID) pac2, ok := th.pa.GetListenerByWebConnID(webConnID2) require.True(t, ok) wg := new(sync.WaitGroup) wg.Add(4) go func(wg *sync.WaitGroup) { th.SubscribeWebConnToTeam(webConnID1, userID, teamID1) require.True(t, pac1.isSubscribedToTeam(teamID1)) th.SubscribeWebConnToTeam(webConnID2, userID, teamID1) require.True(t, pac2.isSubscribedToTeam(teamID1)) th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID1) require.False(t, pac1.isSubscribedToTeam(teamID1)) th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID1) require.False(t, pac2.isSubscribedToTeam(teamID1)) wg.Done() }(wg) go func(wg *sync.WaitGroup) { th.SubscribeWebConnToTeam(webConnID1, userID, teamID2) require.True(t, pac1.isSubscribedToTeam(teamID2)) th.SubscribeWebConnToTeam(webConnID2, userID, teamID2) require.True(t, pac2.isSubscribedToTeam(teamID2)) th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID2) require.False(t, pac1.isSubscribedToTeam(teamID2)) th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID2) require.False(t, pac2.isSubscribedToTeam(teamID2)) wg.Done() }(wg) go func(wg *sync.WaitGroup) { th.SubscribeWebConnToTeam(webConnID1, userID, teamID3) require.True(t, pac1.isSubscribedToTeam(teamID3)) th.SubscribeWebConnToTeam(webConnID2, userID, teamID3) require.True(t, pac2.isSubscribedToTeam(teamID3)) th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID3) require.False(t, pac1.isSubscribedToTeam(teamID3)) th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID3) require.False(t, pac2.isSubscribedToTeam(teamID3)) wg.Done() }(wg) go func(wg *sync.WaitGroup) { th.SubscribeWebConnToTeam(webConnID1, userID, teamID4) require.True(t, pac1.isSubscribedToTeam(teamID4)) th.SubscribeWebConnToTeam(webConnID2, userID, teamID4) require.True(t, pac2.isSubscribedToTeam(teamID4)) th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID4) require.False(t, pac1.isSubscribedToTeam(teamID4)) th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID4) require.False(t, pac2.isSubscribedToTeam(teamID4)) wg.Done() }(wg) wg.Wait() } ================================================ FILE: server/ws/server.go ================================================ package ws import ( "encoding/json" "net/http" "sync" "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/mattermost/focalboard/server/auth" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost/server/public/shared/mlog" ) func (wss *websocketSession) WriteJSON(v interface{}) error { wss.mu.Lock() defer wss.mu.Unlock() err := wss.conn.WriteJSON(v) return err } func (wss *websocketSession) isSubscribedToTeam(teamID string) bool { for _, id := range wss.teams { if id == teamID { return true } } return false } func (wss *websocketSession) isSubscribedToBlock(blockID string) bool { for _, id := range wss.blocks { if id == blockID { return true } } return false } // Server is a WebSocket server. type Server struct { upgrader websocket.Upgrader listeners map[*websocketSession]bool listenersByTeam map[string][]*websocketSession listenersByBlock map[string][]*websocketSession mu sync.RWMutex auth *auth.Auth singleUserToken string isMattermostAuth bool logger mlog.LoggerIFace store Store } type websocketSession struct { conn *websocket.Conn userID string mu sync.Mutex teams []string blocks []string } func (wss *websocketSession) isAuthenticated() bool { return wss.userID != "" } // NewServer creates a new Server. func NewServer(auth *auth.Auth, singleUserToken string, isMattermostAuth bool, logger mlog.LoggerIFace, store Store) *Server { return &Server{ listeners: make(map[*websocketSession]bool), listenersByTeam: make(map[string][]*websocketSession), listenersByBlock: make(map[string][]*websocketSession), upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, }, auth: auth, singleUserToken: singleUserToken, isMattermostAuth: isMattermostAuth, logger: logger, store: store, } } // RegisterRoutes registers routes. func (ws *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/ws", ws.handleWebSocket) } func (ws *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { // Upgrade initial GET request to a websocket client, err := ws.upgrader.Upgrade(w, r, nil) if err != nil { ws.logger.Error("ERROR upgrading to websocket", mlog.Err(err)) return } // create an empty session with websocket client wsSession := &websocketSession{ conn: client, userID: "", mu: sync.Mutex{}, teams: []string{}, blocks: []string{}, } if ws.isMattermostAuth { wsSession.userID = r.Header.Get("Mattermost-User-Id") } ws.addListener(wsSession) // Make sure we close the connection when the function returns defer func() { ws.logger.Debug("DISCONNECT WebSocket", mlog.Stringer("client", wsSession.conn.RemoteAddr())) // Remove session from listeners ws.removeListener(wsSession) wsSession.conn.Close() }() // Simple message handling loop for { _, p, err := wsSession.conn.ReadMessage() if err != nil { ws.logger.Error("ERROR WebSocket", mlog.Stringer("client", wsSession.conn.RemoteAddr()), mlog.Err(err), ) ws.removeListener(wsSession) break } var command WebsocketCommand err = json.Unmarshal(p, &command) if err != nil { // handle this error ws.logger.Error(`ERROR webSocket parsing command`, mlog.String("json", string(p))) continue } if command.Action == websocketActionAuth { ws.logger.Debug(`Command: AUTH`, mlog.Stringer("client", wsSession.conn.RemoteAddr())) ws.authenticateListener(wsSession, command.Token) continue } // if the client wants to subscribe to a set of blocks and it // is sending a read token, we don't need to check for // authentication if command.Action == websocketActionSubscribeBlocks { ws.logger.Debug(`Command: SUBSCRIBE_BLOCKS`, mlog.String("teamID", command.TeamID), mlog.Stringer("client", wsSession.conn.RemoteAddr()), ) if !ws.isCommandReadTokenValid(command) { ws.logger.Error(`Rejected invalid read token`, mlog.Stringer("client", wsSession.conn.RemoteAddr()), mlog.String("action", command.Action), mlog.String("readToken", command.ReadToken), ) continue } ws.subscribeListenerToBlocks(wsSession, command.BlockIDs) continue } if command.Action == websocketActionUnsubscribeBlocks { ws.logger.Debug(`Command: UNSUBSCRIBE_BLOCKS`, mlog.String("teamID", command.TeamID), mlog.Stringer("client", wsSession.conn.RemoteAddr()), ) if !ws.isCommandReadTokenValid(command) { ws.logger.Error(`Rejected invalid read token`, mlog.Stringer("client", wsSession.conn.RemoteAddr()), mlog.String("action", command.Action), mlog.String("readToken", command.ReadToken), ) continue } ws.unsubscribeListenerFromBlocks(wsSession, command.BlockIDs) continue } // if the command is not authenticated at this point, it will // not be processed if !wsSession.isAuthenticated() { ws.logger.Error(`Rejected unauthenticated message`, mlog.Stringer("client", wsSession.conn.RemoteAddr()), mlog.String("action", command.Action), ) continue } switch command.Action { case websocketActionSubscribeTeam: ws.logger.Debug(`Command: SUBSCRIBE_TEAM`, mlog.String("teamID", command.TeamID), mlog.Stringer("client", wsSession.conn.RemoteAddr()), ) // if single user mode, check that the userID is valid and // assume that the user has permission if so if len(ws.singleUserToken) != 0 { if wsSession.userID != model.SingleUser { continue } // if not in single user mode validate that the session // has permissions to the team } else { ws.logger.Debug("Not single user mode") if !ws.auth.DoesUserHaveTeamAccess(wsSession.userID, command.TeamID) { ws.logger.Error("WS user doesn't have team access", mlog.String("teamID", command.TeamID), mlog.String("userID", wsSession.userID)) continue } } ws.subscribeListenerToTeam(wsSession, command.TeamID) case websocketActionUnsubscribeTeam: ws.logger.Debug(`Command: UNSUBSCRIBE_TEAM`, mlog.String("teamID", command.TeamID), mlog.Stringer("client", wsSession.conn.RemoteAddr()), ) ws.unsubscribeListenerFromTeam(wsSession, command.TeamID) default: ws.logger.Error(`ERROR webSocket command, invalid action`, mlog.String("action", command.Action)) } } } // isCommandReadTokenValid ensures that a command contains a read // token and a set of block ids that said token is valid for. func (ws *Server) isCommandReadTokenValid(command WebsocketCommand) bool { if len(command.TeamID) == 0 { return false } boardID := "" // all the blocks must be part of the same board for _, blockID := range command.BlockIDs { block, err := ws.store.GetBlock(blockID) if err != nil { return false } if boardID == "" { boardID = block.BoardID continue } if boardID != block.BoardID { return false } } // the read token must be valid for the board isValid, err := ws.auth.IsValidReadToken(boardID, command.ReadToken) if err != nil { ws.logger.Error(`ERROR when checking token validity`, mlog.String("teamID", command.TeamID), mlog.Err(err), ) return false } return isValid } // addListener adds a listener to the websocket server. The listener // should not receive any update from the server until it subscribes // itself to some entity changes. Adding a listener to the server // doesn't mean that it's authenticated in any way. func (ws *Server) addListener(listener *websocketSession) { ws.mu.Lock() defer ws.mu.Unlock() ws.listeners[listener] = true } // removeListener removes a listener and all its subscriptions, if // any, from the websockets server. func (ws *Server) removeListener(listener *websocketSession) { ws.mu.Lock() defer ws.mu.Unlock() // remove the listener from its subscriptions, if any // team subscriptions for _, team := range listener.teams { ws.removeListenerFromTeam(listener, team) } // block subscriptions for _, block := range listener.blocks { ws.removeListenerFromBlock(listener, block) } delete(ws.listeners, listener) } // subscribeListenerToTeam safely modifies the listener and the // server to subscribe the listener to a given team updates. func (ws *Server) subscribeListenerToTeam(listener *websocketSession, teamID string) { if listener.isSubscribedToTeam(teamID) { return } ws.mu.Lock() defer ws.mu.Unlock() ws.listenersByTeam[teamID] = append(ws.listenersByTeam[teamID], listener) listener.teams = append(listener.teams, teamID) } // unsubscribeListenerFromTeam safely modifies the listener and // the server data structures to remove the link between the listener // and a given team ID. func (ws *Server) unsubscribeListenerFromTeam(listener *websocketSession, teamID string) { if !listener.isSubscribedToTeam(teamID) { return } ws.mu.Lock() defer ws.mu.Unlock() ws.removeListenerFromTeam(listener, teamID) } // subscribeListenerToBlocks safely modifies the listener and the // server to subscribe the listener to a given set of block updates. func (ws *Server) subscribeListenerToBlocks(listener *websocketSession, blockIDs []string) { ws.mu.Lock() defer ws.mu.Unlock() for _, blockID := range blockIDs { if listener.isSubscribedToBlock(blockID) { continue } ws.listenersByBlock[blockID] = append(ws.listenersByBlock[blockID], listener) listener.blocks = append(listener.blocks, blockID) } } // unsubscribeListenerFromBlocks safely modifies the listener and the // server data structures to remove the link between the listener and // a given set of block IDs. func (ws *Server) unsubscribeListenerFromBlocks(listener *websocketSession, blockIDs []string) { ws.mu.Lock() defer ws.mu.Unlock() for _, blockID := range blockIDs { if listener.isSubscribedToBlock(blockID) { ws.removeListenerFromBlock(listener, blockID) } } } // removeListenerFromTeam removes the listener from both its own // block subscribed list and the server listeners by team map. func (ws *Server) removeListenerFromTeam(listener *websocketSession, teamID string) { // we remove the listener from the team index newTeamListeners := []*websocketSession{} for _, l := range ws.listenersByTeam[teamID] { if l != listener { newTeamListeners = append(newTeamListeners, l) } } ws.listenersByTeam[teamID] = newTeamListeners // we remove the team from the listener subscription list newListenerTeams := []string{} for _, id := range listener.teams { if id != teamID { newListenerTeams = append(newListenerTeams, id) } } listener.teams = newListenerTeams } // removeListenerFromBlock removes the listener from both its own // block subscribed list and the server listeners by block map. func (ws *Server) removeListenerFromBlock(listener *websocketSession, blockID string) { // we remove the listener from the block index newBlockListeners := []*websocketSession{} for _, l := range ws.listenersByBlock[blockID] { if l != listener { newBlockListeners = append(newBlockListeners, l) } } ws.listenersByBlock[blockID] = newBlockListeners // we remove the block from the listener subscription list newListenerBlocks := []string{} for _, id := range listener.blocks { if id != blockID { newListenerBlocks = append(newListenerBlocks, id) } } listener.blocks = newListenerBlocks } func (ws *Server) getUserIDForToken(token string) string { if len(ws.singleUserToken) > 0 { if token == ws.singleUserToken { return model.SingleUser } else { return "" } } session, err := ws.auth.GetSession(token) if session == nil || err != nil { return "" } return session.UserID } func (ws *Server) authenticateListener(wsSession *websocketSession, token string) { ws.logger.Debug("authenticateListener", mlog.String("token", token), mlog.String("wsSession.userID", wsSession.userID), ) if wsSession.isAuthenticated() { // Do not allow multiple auth calls (for security) ws.logger.Debug( "authenticateListener: Ignoring already authenticated session", mlog.String("userID", wsSession.userID), mlog.Stringer("client", wsSession.conn.RemoteAddr()), ) return } // Authenticate session userID := ws.getUserIDForToken(token) if userID == "" { wsSession.conn.Close() return } // Authenticated wsSession.userID = userID ws.logger.Debug("authenticateListener: Authenticated", mlog.String("userID", userID), mlog.Stringer("client", wsSession.conn.RemoteAddr())) } // getListenersForBlock returns the listeners subscribed to a // block changes. func (ws *Server) getListenersForBlock(blockID string) []*websocketSession { return ws.listenersByBlock[blockID] } // getListenersForUser returns the listener for a user subscribed to a // team changes. func (ws *Server) getListenerForUser(teamID, userID string) *websocketSession { for _, listener := range ws.listenersByTeam[teamID] { if listener.userID == userID { return listener } } return nil } // getListenersForTeamAndBoard returns the listeners subscribed to a // team changes and members of a given board. func (ws *Server) getListenersForTeamAndBoard(teamID, boardID string, ensureUsers ...string) []*websocketSession { members, err := ws.store.GetMembersForBoard(boardID) if err != nil { ws.logger.Error("error getting members for board", mlog.String("method", "getListenersForTeamAndBoard"), mlog.String("teamID", teamID), mlog.String("boardID", boardID), ) return nil } memberMap := map[string]bool{} for _, member := range members { memberMap[member.UserID] = true } for _, id := range ensureUsers { memberMap[id] = true } memberIDs := []string{} for id := range memberMap { memberIDs = append(memberIDs, id) } listeners := []*websocketSession{} for _, memberID := range memberIDs { for _, listener := range ws.listenersByTeam[teamID] { if listener.userID == memberID { listeners = append(listeners, listener) } } } return listeners } // BroadcastBlockDelete broadcasts delete messages to clients. func (ws *Server) BroadcastBlockDelete(teamID, blockID, boardID string) { now := utils.GetMillis() block := &model.Block{} block.ID = blockID block.BoardID = boardID block.UpdateAt = now block.DeleteAt = now ws.BroadcastBlockChange(teamID, block) } // BroadcastBlockChange broadcasts update messages to clients. func (ws *Server) BroadcastBlockChange(teamID string, block *model.Block) { blockIDsToNotify := []string{block.ID, block.ParentID} message := UpdateBlockMsg{ Action: websocketActionUpdateBlock, TeamID: teamID, Block: block, } listeners := ws.getListenersForTeamAndBoard(teamID, block.BoardID) ws.logger.Trace("listener(s) for teamID", mlog.Int("listener_count", len(listeners)), mlog.String("teamID", teamID), mlog.String("boardID", block.BoardID), ) for _, blockID := range blockIDsToNotify { listeners = append(listeners, ws.getListenersForBlock(blockID)...) ws.logger.Trace("listener(s) for blockID", mlog.Int("listener_count", len(listeners)), mlog.String("blockID", blockID), ) } for _, listener := range listeners { ws.logger.Debug("Broadcast block change", mlog.String("teamID", teamID), mlog.String("blockID", block.ID), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) err := listener.WriteJSON(message) if err != nil { ws.logger.Error("broadcast error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastCategoryChange(category model.Category) { message := UpdateCategoryMessage{ Action: websocketActionUpdateCategory, TeamID: category.TeamID, Category: &category, } listener := ws.getListenerForUser(category.TeamID, category.UserID) if listener != nil { ws.logger.Debug("Broadcast category change", mlog.String("userID", category.UserID), mlog.String("teamID", category.TeamID), mlog.String("categoryID", category.ID), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) if err := listener.WriteJSON(message); err != nil { ws.logger.Error("broadcast category change error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) { message := CategoryReorderMessage{ Action: websocketActionReorderCategories, CategoryOrder: categoryOrder, TeamID: teamID, } listener := ws.getListenerForUser(teamID, userID) if listener != nil { ws.logger.Debug("Broadcast category order change", mlog.String("userID", userID), mlog.String("teamID", teamID), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) if err := listener.WriteJSON(message); err != nil { ws.logger.Error("broadcast category order change error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardOrder []string) { message := CategoryBoardReorderMessage{ Action: websocketActionReorderCategoryBoards, CategoryID: categoryID, BoardOrder: boardOrder, TeamID: teamID, } listener := ws.getListenerForUser(teamID, userID) if listener != nil { ws.logger.Debug("Broadcast board category order change", mlog.String("userID", userID), mlog.String("teamID", teamID), mlog.String("categoryID", categoryID), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) if err := listener.WriteJSON(message); err != nil { ws.logger.Error("broadcast category boards order change error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) { message := UpdateCategoryMessage{ Action: websocketActionUpdateCategoryBoard, TeamID: teamID, BoardCategories: boardCategories, } listener := ws.getListenerForUser(teamID, userID) if listener != nil { ws.logger.Debug("Broadcast category board change", mlog.String("userID", userID), mlog.String("teamID", teamID), mlog.Int("numEntries", len(boardCategories)), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) if err := listener.WriteJSON(message); err != nil { ws.logger.Error("broadcast category change error", mlog.Err(err)) listener.conn.Close() } } } // BroadcastConfigChange broadcasts update messages to clients. func (ws *Server) BroadcastConfigChange(clientConfig model.ClientConfig) { message := UpdateClientConfig{ Action: websocketActionUpdateConfig, ClientConfig: clientConfig, } listeners := ws.listeners ws.logger.Debug("broadcasting config change to listener(s)", mlog.Int("listener_count", len(listeners)), ) for listener := range listeners { ws.logger.Debug("Broadcast Config change", mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) err := listener.WriteJSON(message) if err != nil { ws.logger.Error("broadcast error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastBoardChange(teamID string, board *model.Board) { message := UpdateBoardMsg{ Action: websocketActionUpdateBoard, TeamID: teamID, Board: board, } listeners := ws.getListenersForTeamAndBoard(teamID, board.ID) ws.logger.Trace("listener(s) for teamID and boardID", mlog.Int("listener_count", len(listeners)), mlog.String("teamID", teamID), mlog.String("boardID", board.ID), ) for _, listener := range listeners { ws.logger.Debug("Broadcast board change", mlog.String("teamID", teamID), mlog.String("boardID", board.ID), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) err := listener.WriteJSON(message) if err != nil { ws.logger.Error("broadcast error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastBoardDelete(teamID, boardID string) { now := utils.GetMillis() board := &model.Board{} board.ID = boardID board.TeamID = teamID board.UpdateAt = now board.DeleteAt = now ws.BroadcastBoardChange(teamID, board) } func (ws *Server) BroadcastMemberChange(teamID, boardID string, member *model.BoardMember) { message := UpdateMemberMsg{ Action: websocketActionUpdateMember, TeamID: teamID, Member: member, } listeners := ws.getListenersForTeamAndBoard(teamID, boardID) ws.logger.Trace("listener(s) for teamID and boardID", mlog.Int("listener_count", len(listeners)), mlog.String("teamID", teamID), mlog.String("boardID", boardID), ) for _, listener := range listeners { ws.logger.Debug("Broadcast member change", mlog.String("teamID", teamID), mlog.String("boardID", boardID), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) err := listener.WriteJSON(message) if err != nil { ws.logger.Error("broadcast error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastMemberDelete(teamID, boardID, userID string) { message := UpdateMemberMsg{ Action: websocketActionDeleteMember, TeamID: teamID, Member: &model.BoardMember{UserID: userID, BoardID: boardID}, } // when fetching the members of the board that should receive the // member deletion message, the deleted member will not be one of // them, so we need to ensure they receive the message listeners := ws.getListenersForTeamAndBoard(teamID, boardID, userID) ws.logger.Trace("listener(s) for teamID and boardID", mlog.Int("listener_count", len(listeners)), mlog.String("teamID", teamID), mlog.String("boardID", boardID), ) for _, listener := range listeners { ws.logger.Debug("Broadcast member removal", mlog.String("teamID", teamID), mlog.String("boardID", boardID), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) err := listener.WriteJSON(message) if err != nil { ws.logger.Error("broadcast error", mlog.Err(err)) listener.conn.Close() } } } func (ws *Server) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { // not implemented for standalone server. } func (ws *Server) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) { // not implemented for standalone server. } ================================================ FILE: server/ws/server_test.go ================================================ package ws import ( "sync" "testing" "github.com/mattermost/focalboard/server/auth" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/gorilla/websocket" "github.com/stretchr/testify/require" ) func TestTeamSubscription(t *testing.T) { server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{}, nil) session := &websocketSession{ conn: &websocket.Conn{}, mu: sync.Mutex{}, teams: []string{}, blocks: []string{}, } teamID := "fake-team-id" t.Run("Should correctly add a session", func(t *testing.T) { server.addListener(session) require.Len(t, server.listeners, 1) require.Empty(t, server.listenersByTeam) require.Empty(t, session.teams) }) t.Run("Should correctly subscribe to a team", func(t *testing.T) { require.False(t, session.isSubscribedToTeam(teamID)) server.subscribeListenerToTeam(session, teamID) require.Len(t, server.listenersByTeam[teamID], 1) require.Contains(t, server.listenersByTeam[teamID], session) require.Len(t, session.teams, 1) require.Contains(t, session.teams, teamID) require.True(t, session.isSubscribedToTeam(teamID)) }) t.Run("Subscribing again to a subscribed team would have no effect", func(t *testing.T) { require.True(t, session.isSubscribedToTeam(teamID)) server.subscribeListenerToTeam(session, teamID) require.Len(t, server.listenersByTeam[teamID], 1) require.Contains(t, server.listenersByTeam[teamID], session) require.Len(t, session.teams, 1) require.Contains(t, session.teams, teamID) require.True(t, session.isSubscribedToTeam(teamID)) }) t.Run("Should correctly unsubscribe to a team", func(t *testing.T) { require.True(t, session.isSubscribedToTeam(teamID)) server.unsubscribeListenerFromTeam(session, teamID) require.Empty(t, server.listenersByTeam[teamID]) require.Empty(t, session.teams) require.False(t, session.isSubscribedToTeam(teamID)) }) t.Run("Unsubscribing again to an unsubscribed team would have no effect", func(t *testing.T) { require.False(t, session.isSubscribedToTeam(teamID)) server.unsubscribeListenerFromTeam(session, teamID) require.Empty(t, server.listenersByTeam[teamID]) require.Empty(t, session.teams) require.False(t, session.isSubscribedToTeam(teamID)) }) t.Run("Should correctly be removed from the server", func(t *testing.T) { server.removeListener(session) require.Empty(t, server.listeners) }) t.Run("If subscribed to teams and removed, should be removed from the teams subscription list", func(t *testing.T) { teamID2 := "other-fake-team-id" server.addListener(session) server.subscribeListenerToTeam(session, teamID) server.subscribeListenerToTeam(session, teamID2) require.Len(t, server.listeners, 1) require.Contains(t, server.listenersByTeam[teamID], session) require.Contains(t, server.listenersByTeam[teamID2], session) server.removeListener(session) require.Empty(t, server.listeners) require.Empty(t, server.listenersByTeam[teamID]) require.Empty(t, server.listenersByTeam[teamID2]) }) t.Run("Subscribe users to team retrieve by user", func(t *testing.T) { userID1 := "fake-user-id" userSession1 := &websocketSession{ conn: &websocket.Conn{}, mu: sync.Mutex{}, userID: userID1, teams: []string{}, blocks: []string{}, } userID2 := "fake-user-id2" userSession2 := &websocketSession{ conn: &websocket.Conn{}, mu: sync.Mutex{}, userID: userID2, teams: []string{}, blocks: []string{}, } teamID := "fake-team-id" server.addListener(session) server.subscribeListenerToTeam(session, teamID) server.addListener(userSession1) server.subscribeListenerToTeam(userSession1, teamID) server.addListener(userSession2) server.subscribeListenerToTeam(userSession2, teamID) require.Len(t, server.listeners, 3) require.Len(t, server.listenersByTeam[teamID], 3) listener := server.getListenerForUser(teamID, userID1) require.NotNil(t, listener) require.Equal(t, listener.userID, userID1) server.removeListener(session) server.removeListener(userSession1) server.removeListener(userSession2) require.Empty(t, server.listeners) require.Empty(t, server.listenersByTeam[teamID]) require.Empty(t, server.getListenerForUser(teamID, userID1)) }) } func TestBlocksSubscription(t *testing.T) { server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{}, nil) session := &websocketSession{ conn: &websocket.Conn{}, mu: sync.Mutex{}, teams: []string{}, blocks: []string{}, } blockID1 := "block1" blockID2 := "block2" blockID3 := "block3" blockIDs := []string{blockID1, blockID2, blockID3} t.Run("Should correctly add a session", func(t *testing.T) { server.addListener(session) require.Len(t, server.listeners, 1) require.Empty(t, server.listenersByTeam) require.Empty(t, session.teams) }) t.Run("Should correctly subscribe to a set of blocks", func(t *testing.T) { require.False(t, session.isSubscribedToBlock(blockID1)) require.False(t, session.isSubscribedToBlock(blockID2)) require.False(t, session.isSubscribedToBlock(blockID3)) server.subscribeListenerToBlocks(session, blockIDs) require.Len(t, server.listenersByBlock[blockID1], 1) require.Contains(t, server.listenersByBlock[blockID1], session) require.Len(t, server.listenersByBlock[blockID2], 1) require.Contains(t, server.listenersByBlock[blockID2], session) require.Len(t, server.listenersByBlock[blockID3], 1) require.Contains(t, server.listenersByBlock[blockID3], session) require.Len(t, session.blocks, 3) require.ElementsMatch(t, blockIDs, session.blocks) require.True(t, session.isSubscribedToBlock(blockID1)) require.True(t, session.isSubscribedToBlock(blockID2)) require.True(t, session.isSubscribedToBlock(blockID3)) t.Run("Subscribing again to a subscribed block would have no effect", func(t *testing.T) { require.True(t, session.isSubscribedToBlock(blockID1)) require.True(t, session.isSubscribedToBlock(blockID2)) require.True(t, session.isSubscribedToBlock(blockID3)) server.subscribeListenerToBlocks(session, blockIDs) require.Len(t, server.listenersByBlock[blockID1], 1) require.Contains(t, server.listenersByBlock[blockID1], session) require.Len(t, server.listenersByBlock[blockID2], 1) require.Contains(t, server.listenersByBlock[blockID2], session) require.Len(t, server.listenersByBlock[blockID3], 1) require.Contains(t, server.listenersByBlock[blockID3], session) require.Len(t, session.blocks, 3) require.ElementsMatch(t, blockIDs, session.blocks) require.True(t, session.isSubscribedToBlock(blockID1)) require.True(t, session.isSubscribedToBlock(blockID2)) require.True(t, session.isSubscribedToBlock(blockID3)) }) }) t.Run("Should correctly unsubscribe to a set of blocks", func(t *testing.T) { require.True(t, session.isSubscribedToBlock(blockID1)) require.True(t, session.isSubscribedToBlock(blockID2)) require.True(t, session.isSubscribedToBlock(blockID3)) server.unsubscribeListenerFromBlocks(session, blockIDs) require.Empty(t, server.listenersByBlock[blockID1]) require.Empty(t, server.listenersByBlock[blockID2]) require.Empty(t, server.listenersByBlock[blockID3]) require.Empty(t, session.blocks) require.False(t, session.isSubscribedToBlock(blockID1)) require.False(t, session.isSubscribedToBlock(blockID2)) require.False(t, session.isSubscribedToBlock(blockID3)) }) t.Run("Unsubscribing again to an unsubscribed block would have no effect", func(t *testing.T) { require.False(t, session.isSubscribedToBlock(blockID1)) server.unsubscribeListenerFromBlocks(session, []string{blockID1}) require.Empty(t, server.listenersByBlock[blockID1]) require.Empty(t, session.blocks) require.False(t, session.isSubscribedToBlock(blockID1)) }) t.Run("Should correctly be removed from the server", func(t *testing.T) { server.removeListener(session) require.Empty(t, server.listeners) }) t.Run("If subscribed to blocks and removed, should be removed from the blocks subscription list", func(t *testing.T) { server.addListener(session) server.subscribeListenerToBlocks(session, blockIDs) require.Len(t, server.listeners, 1) require.Len(t, server.listenersByBlock[blockID1], 1) require.Contains(t, server.listenersByBlock[blockID1], session) require.Len(t, server.listenersByBlock[blockID2], 1) require.Contains(t, server.listenersByBlock[blockID2], session) require.Len(t, server.listenersByBlock[blockID3], 1) require.Contains(t, server.listenersByBlock[blockID3], session) require.Len(t, session.blocks, 3) require.ElementsMatch(t, blockIDs, session.blocks) server.removeListener(session) require.Empty(t, server.listeners) require.Empty(t, server.listenersByBlock[blockID1]) require.Empty(t, server.listenersByBlock[blockID2]) require.Empty(t, server.listenersByBlock[blockID3]) }) } func TestGetUserIDForTokenInSingleUserMode(t *testing.T) { singleUserToken := "single-user-token" server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{}, nil) server.singleUserToken = singleUserToken t.Run("Should return nothing if the token is empty", func(t *testing.T) { require.Empty(t, server.getUserIDForToken("")) }) t.Run("Should return nothing if the token is invalid", func(t *testing.T) { require.Empty(t, server.getUserIDForToken("invalid-token")) }) t.Run("Should return the single user ID if the token is correct", func(t *testing.T) { require.Equal(t, model.SingleUser, server.getUserIDForToken(singleUserToken)) }) } ================================================ FILE: server-config.json ================================================ { "serverRoot": "http://localhost:8000", "port": 8000, "dbtype": "sqlite3", "dbconfig": "./focalboard.db", "postgres_dbconfig": "dbname=focalboard sslmode=disable", "useSSL": false, "webpath": "./pack", "filespath": "./files", "telemetry": true, "prometheusaddress": ":9092", "session_expire_time": 2592000, "session_refresh_time": 18000, "localOnly": false, "enableLocalMode": true, "localModeSocketLocation": "/var/tmp/focalboard_local.socket" } ================================================ FILE: webapp/.eslintignore ================================================ node_modules/ ================================================ FILE: webapp/.eslintrc.json ================================================ { "extends": [ "plugin:mattermost/react", "plugin:cypress/recommended", "plugin:jquery/deprecated" ], "plugins": [ "react", "babel", "mattermost", "import", "cypress", "jquery", "no-only-tests" ], "parser": "@typescript-eslint/parser", "env": { "jest": true, "cypress/globals": true }, "settings": { "import/resolver": "webpack", "react": { "pragma": "React", "version": "detect" } }, "rules": { "max-lines": "off", "no-unused-expressions": 0, "babel/no-unused-expressions": [2, {"allowShortCircuit": true}], "eol-last": ["error", "always"], "import/no-unresolved": 2, "import/order": [ 2, { "newlines-between": "always-and-inside-groups", "groups": [ "builtin", "external", [ "internal", "parent" ], "sibling", "index" ] } ], "no-undefined": 0, "react/jsx-filename-extension": 0, "react/prop-types": [ 2, { "ignore": [ "location", "history", "component" ] } ], "react/no-string-refs": 2, "no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}], "max-nested-callbacks": ["error", {"max": 5}], "no-shadow": "off", "@typescript-eslint/no-shadow": "error" }, "overrides": [ { "files": ["**/*.tsx", "**/*.ts"], "extends": [ "plugin:@typescript-eslint/recommended" ], "rules": { "import/no-unresolved": 0, // ts handles this better "camelcase": 0, "semi": "off", "@typescript-eslint/naming-convention": [ 2, { "selector": "function", "format": ["camelCase", "PascalCase"] }, { "selector": "variable", "format": ["camelCase", "PascalCase", "UPPER_CASE"] }, { "selector": "parameter", "format": ["camelCase", "PascalCase"], "leadingUnderscore": "allow" }, { "selector": "typeLike", "format": ["PascalCase"] } ], "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-unused-vars": [ 2, { "vars": "all", "args": "after-used" } ], "@typescript-eslint/member-delimiter-style": [2, {"multiline": {"delimiter": "none"}, "singleline": {"delimiter": "comma"}}], "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/prefer-interface": 0, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/semi": [2, "never"], "@typescript-eslint/indent": [ 2, 4, { "SwitchCase": 0 } ], "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": [ 2, { "classes": false, "functions": false, "variables": false } ], "no-useless-constructor": 0, "@typescript-eslint/no-useless-constructor": 2, "react/jsx-filename-extension": 0 } }, { "files": ["tests/**", "**/*.test.*"], "env": { "jest": true }, "rules": { "func-names": 0, "global-require": 0, "new-cap": 0, "prefer-arrow-callback": 0, "no-import-assign": 0 } }, { "files": ["cypress/**"], "rules": { "cypress/no-unnecessary-waiting": 0, "func-names": 0, "import/no-unresolved": 0, "max-nested-callbacks": 0, "no-process-env": 0, "no-unused-expressions": 0 } } ] } ================================================ FILE: webapp/.nvmrc ================================================ 20.11 ================================================ FILE: webapp/.prettierignore ================================================ # Ignore all js/ts files: *.ts *.tsx *.js ================================================ FILE: webapp/.prettierrc.json ================================================ { "tabWidth": 4 } ================================================ FILE: webapp/.stylelintrc.json ================================================ { "extends": "stylelint-config-sass-guidelines", "rules": { "indentation": 4, "selector-class-pattern": "[a-zA-Z_-]+", "max-nesting-depth": 4, "selector-max-compound-selectors": 6, "selector-max-id": 1, "selector-no-qualifying-type": null, "order/properties-alphabetical-order": null, "declaration-block-no-duplicate-properties": true, "property-disallowed-list": ["z-index"] } } ================================================ FILE: webapp/NOTICE.txt ================================================ Focalboard © 2015-present Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information. NOTICES: -------- This document includes a list of open source components used in Focalboard web app, including those that have been modified. ----- The following software may be included in this product: @babel/code-frame, @babel/helper-module-imports, @babel/helper-validator-identifier, @babel/highlight, @babel/runtime, @babel/types. A copy of the source code may be downloaded from https://github.com/babel/babel.git (@babel/code-frame), https://github.com/babel/babel.git (@babel/helper-module-imports), https://github.com/babel/babel.git (@babel/helper-validator-identifier), https://github.com/babel/babel.git (@babel/highlight), https://github.com/babel/babel.git (@babel/runtime), https://github.com/babel/babel.git (@babel/types). This software contains the following license and notice below: MIT License Copyright (c) 2014-present Sebastian McKenzie and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: @cypress/listr-verbose-renderer, listr, listr-silent-renderer, listr-update-renderer, listr-verbose-renderer. A copy of the source code may be downloaded from https://github.com/SamVerschueren/listr-verbose-renderer.git (@cypress/listr-verbose-renderer), https://github.com/SamVerschueren/listr.git (listr), https://github.com/SamVerschueren/listr-silent-renderer.git (listr-silent-renderer), https://github.com/SamVerschueren/listr-update-renderer.git (listr-update-renderer), https://github.com/SamVerschueren/listr-verbose-renderer.git (listr-verbose-renderer). This software contains the following license and notice below: The MIT License (MIT) Copyright (c) Sam Verschueren (github.com/SamVerschueren) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: @cypress/request, aws-sign2, forever-agent, oauth-sign, tunnel-agent. A copy of the source code may be downloaded from https://github.com/cypress-io/request.git (@cypress/request), https://github.com/mikeal/aws-sign (aws-sign2), https://github.com/mikeal/forever-agent (forever-agent), https://github.com/mikeal/oauth-sign (oauth-sign), https://github.com/mikeal/tunnel-agent (tunnel-agent). This software contains the following license and notice below: Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ----- The following software may be included in this product: @cypress/xvfb. A copy of the source code may be downloaded from https://github.com/cypress-io/xvfb.git. This software contains the following license and notice below: Original Work Copyright (C) 2012 ProxV, Inc. Modified Work Copyright (c) 2015 Cypress.io, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: @emotion/cache, @emotion/core, @emotion/css, @emotion/hash, @emotion/memoize, @emotion/serialize, @emotion/sheet, @emotion/stylis, @emotion/unitless, @emotion/utils, @emotion/weak-memoize, babel-plugin-emotion. A copy of the source code may be downloaded from https://github.com/emotion-js/emotion/tree/master/packages/cache (@emotion/cache), https://github.com/emotion-js/emotion/tree/master/packages/core (@emotion/core), https://github.com/emotion-js/emotion/tree/master/packages/css (@emotion/css), https://github.com/emotion-js/emotion/tree/master/packages/hash (@emotion/hash), https://github.com/emotion-js/emotion/tree/master/packages/memoize (@emotion/memoize), https://github.com/emotion-js/emotion/tree/master/packages/serialize (@emotion/serialize), https://github.com/emotion-js/emotion/tree/master/packages/sheet (@emotion/sheet), https://github.com/emotion-js/emotion/tree/master/packages/stylis (@emotion/stylis), https://github.com/emotion-js/emotion/tree/master/packages/unitless (@emotion/unitless), https://github.com/emotion-js/emotion/tree/master/packages/serialize (@emotion/utils), https://github.com/emotion-js/emotion/tree/master/packages/weak-memoize (@emotion/weak-memoize), https://github.com/emotion-js/emotion/tree/master/packages/babel-plugin-emotion (babel-plugin-emotion). This software contains the following license and notice below: MIT License Copyright (c) Emotion team and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: @formatjs/ecma402-abstract, @formatjs/intl, @formatjs/intl-datetimeformat, @formatjs/intl-displaynames, @formatjs/intl-listformat, @formatjs/intl-relativetimeformat. A copy of the source code may be downloaded from git@github.com:formatjs/formatjs.git (@formatjs/ecma402-abstract), git@github.com:formatjs/formatjs.git (@formatjs/intl), git+https://github.com/formatjs/formatjs.git (@formatjs/intl-datetimeformat), git+https://github.com/formatjs/formatjs.git (@formatjs/intl-displaynames), git@github.com:formatjs/formatjs.git (@formatjs/intl-listformat), git@github.com:formatjs/formatjs.git (@formatjs/intl-relativetimeformat). This software contains the following license and notice below: MIT License Copyright (c) 2019 FormatJS Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: @samverschueren/stream-to-observable. A copy of the source code may be downloaded from https://github.com/SamVerschueren/stream-to-observable.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) James Talmage (github.com/jamestalmage), Sam Verschueren (github.com/SamVerschueren) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: @types/codemirror, @types/hoist-non-react-statics, @types/parse-json, @types/prop-types, @types/sizzle, @types/tern. A copy of the source code may be downloaded from https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/codemirror), https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/hoist-non-react-statics), https://www.github.com/DefinitelyTyped/DefinitelyTyped.git (@types/parse-json), https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/prop-types), https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/sizzle), https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/tern). This software contains the following license and notice below: MIT License Copyright (c) Microsoft Corporation. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ----- The following software may be included in this product: @types/estree, @types/marked, @types/react, @types/sinonjs__fake-timers. A copy of the source code may be downloaded from https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/estree), https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/marked), https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/react), https://github.com/DefinitelyTyped/DefinitelyTyped.git (@types/sinonjs__fake-timers). This software contains the following license and notice below: MIT License Copyright (c) Microsoft Corporation. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ----- The following software may be included in this product: ajv. A copy of the source code may be downloaded from https://github.com/ajv-validator/ajv.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015-2017 Evgeny Poberezkin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: ansi-escapes, ansi-regex, ansi-styles, any-observable, callsites, chalk, has-flag, indent-string, is-fullwidth-code-point, is-installed-globally, is-observable, is-path-inside, is-stream, log-update, mimic-fn, npm-run-path, p-map, parent-module, path-key, path-type, resolve-from, shebang-regex, string-width, strip-ansi, strip-final-newline, supports-color, untildify, wrap-ansi. A copy of the source code may be downloaded from https://github.com/sindresorhus/ansi-escapes.git (ansi-escapes), https://github.com/chalk/ansi-regex.git (ansi-regex), https://github.com/chalk/ansi-styles.git (ansi-styles), https://github.com/sindresorhus/any-observable.git (any-observable), https://github.com/sindresorhus/callsites.git (callsites), https://github.com/chalk/chalk.git (chalk), https://github.com/sindresorhus/has-flag.git (has-flag), https://github.com/sindresorhus/indent-string.git (indent-string), https://github.com/sindresorhus/is-fullwidth-code-point.git (is-fullwidth-code-point), https://github.com/sindresorhus/is-installed-globally.git (is-installed-globally), https://github.com/sindresorhus/is-observable.git (is-observable), https://github.com/sindresorhus/is-path-inside.git (is-path-inside), https://github.com/sindresorhus/is-stream.git (is-stream), https://github.com/sindresorhus/log-update.git (log-update), https://github.com/sindresorhus/mimic-fn.git (mimic-fn), https://github.com/sindresorhus/npm-run-path.git (npm-run-path), https://github.com/sindresorhus/p-map.git (p-map), https://github.com/sindresorhus/parent-module.git (parent-module), https://github.com/sindresorhus/path-key.git (path-key), https://github.com/sindresorhus/path-type.git (path-type), https://github.com/sindresorhus/resolve-from.git (resolve-from), https://github.com/sindresorhus/shebang-regex.git (shebang-regex), https://github.com/sindresorhus/string-width.git (string-width), https://github.com/chalk/strip-ansi.git (strip-ansi), https://github.com/sindresorhus/strip-final-newline.git (strip-final-newline), https://github.com/chalk/supports-color.git (supports-color), https://github.com/sindresorhus/untildify.git (untildify), https://github.com/chalk/wrap-ansi.git (wrap-ansi). This software contains the following license and notice below: MIT License Copyright (c) Sindre Sorhus (sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: ansi-regex, ansi-styles, chalk, cli-cursor, cli-truncate, code-point-at, elegant-spinner, escape-string-regexp, figures, has-ansi, is-fullwidth-code-point, is-stream, log-symbols, number-is-nan, object-assign, onetime, path-is-absolute, pify, restore-cursor, string-width, strip-ansi, supports-color. A copy of the source code may be downloaded from https://github.com/chalk/ansi-regex.git (ansi-regex), https://github.com/chalk/ansi-styles.git (ansi-styles), https://github.com/chalk/chalk.git (chalk), https://github.com/sindresorhus/cli-cursor.git (cli-cursor), https://github.com/sindresorhus/cli-truncate.git (cli-truncate), https://github.com/sindresorhus/code-point-at.git (code-point-at), https://github.com/sindresorhus/elegant-spinner.git (elegant-spinner), https://github.com/sindresorhus/escape-string-regexp.git (escape-string-regexp), https://github.com/sindresorhus/figures.git (figures), https://github.com/sindresorhus/has-ansi.git (has-ansi), https://github.com/sindresorhus/is-fullwidth-code-point.git (is-fullwidth-code-point), https://github.com/sindresorhus/is-stream.git (is-stream), https://github.com/sindresorhus/log-symbols.git (log-symbols), https://github.com/sindresorhus/number-is-nan.git (number-is-nan), https://github.com/sindresorhus/object-assign.git (object-assign), https://github.com/sindresorhus/onetime.git (onetime), https://github.com/sindresorhus/path-is-absolute.git (path-is-absolute), https://github.com/sindresorhus/pify.git (pify), https://github.com/sindresorhus/restore-cursor.git (restore-cursor), https://github.com/sindresorhus/string-width.git (string-width), https://github.com/chalk/strip-ansi.git (strip-ansi), https://github.com/chalk/supports-color.git (supports-color). This software contains the following license and notice below: The MIT License (MIT) Copyright (c) Sindre Sorhus (sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: arch. A copy of the source code may be downloaded from git://github.com/feross/arch.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) Feross Aboukhadijeh Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: asn1. A copy of the source code may be downloaded from git://github.com/joyent/node-asn1.git. This software contains the following license and notice below: Copyright (c) 2011 Mark Cavage, All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ----- The following software may be included in this product: async. A copy of the source code may be downloaded from https://github.com/caolan/async.git. This software contains the following license and notice below: Copyright (c) 2010-2018 Caolan McMahon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: asynckit. A copy of the source code may be downloaded from git+https://github.com/alexindigo/asynckit.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2016 Alex Indigo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: at-least-node. A copy of the source code may be downloaded from git+https://github.com/RyanZim/at-least-node.git. This software contains the following license and notice below: The ISC License Copyright (c) 2020 Ryan Zimmerman Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: aws4. A copy of the source code may be downloaded from https://github.com/mhart/aws4.git. This software contains the following license and notice below: Copyright 2013 Michael Hart (michael.hart.au@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: babel-plugin-macros. A copy of the source code may be downloaded from https://github.com/kentcdodds/babel-plugin-macros.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2017 Kent C. Dodds Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: balanced-match. A copy of the source code may be downloaded from git://github.com/juliangruber/balanced-match.git. This software contains the following license and notice below: (MIT) Copyright (c) 2013 Julian Gruber <julian@juliangruber.com> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: bcrypt-pbkdf. A copy of the source code may be downloaded from git://github.com/joyent/node-bcrypt-pbkdf.git. This software contains the following license and notice below: The Blowfish portions are under the following license: Blowfish block cipher for OpenBSD Copyright 1997 Niels Provos All rights reserved. Implementation advice by David Mazieres . Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The bcrypt_pbkdf portions are under the following license: Copyright (c) 2013 Ted Unangst Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. Performance improvements (Javascript-specific): Copyright 2016, Joyent Inc Author: Alex Wilson Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: blob-util. A copy of the source code may be downloaded from git://github.com/nolanlawson/blob-util.git. This software contains the following license and notice below: Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ----- The following software may be included in this product: bluebird. A copy of the source code may be downloaded from git://github.com/petkaantonov/bluebird.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2013-2018 Petka Antonov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: brace-expansion. A copy of the source code may be downloaded from git://github.com/juliangruber/brace-expansion.git. This software contains the following license and notice below: MIT License Copyright (c) 2013 Julian Gruber Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: buffer-crc32. A copy of the source code may be downloaded from git://github.com/brianloveswords/buffer-crc32.git. This software contains the following license and notice below: The MIT License Copyright (c) 2013 Brian J. Brennan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: buffer-from. A copy of the source code may be downloaded from https://github.com/LinusU/buffer-from.git. This software contains the following license and notice below: MIT License Copyright (c) 2016, 2018 Linus Unnebäck Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: cachedir. A copy of the source code may be downloaded from https://github.com/LinusU/node-cachedir.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2013-2014, 2016, 2018 Linus Unnebäck Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: caseless. A copy of the source code may be downloaded from https://github.com/mikeal/caseless. This software contains the following license and notice below: Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ----- The following software may be included in this product: check-more-types. A copy of the source code may be downloaded from https://github.com/kensho/check-more-types.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Kensho Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: ci-info, is-ci. A copy of the source code may be downloaded from https://github.com/watson/ci-info.git (ci-info), https://github.com/watson/is-ci.git (is-ci). This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2016-2018 Thomas Watson Steen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: cli-table3. A copy of the source code may be downloaded from https://github.com/cli-table/cli-table3.git. This software contains the following license and notice below: MIT License Copyright (c) 2014 James Talmage Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: codemirror. A copy of the source code may be downloaded from https://github.com/codemirror/CodeMirror.git. This software contains the following license and notice below: MIT License Copyright (C) 2017 by Marijn Haverbeke and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: codemirror-spell-checker. A copy of the source code may be downloaded from https://github.com/NextStepWebs/codemirror-spell-checker. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 Wes Cossick Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: color-convert. A copy of the source code may be downloaded from https://github.com/Qix-/color-convert.git. This software contains the following license and notice below: Copyright (c) 2011-2016 Heather Arthur Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: color-name. A copy of the source code may be downloaded from git@github.com:colorjs/color-name.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 Dmitry Ivanov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: colors. A copy of the source code may be downloaded from http://github.com/Marak/colors.js.git. This software contains the following license and notice below: MIT License Original Library - Copyright (c) Marak Squires Additional Functionality - Copyright (c) Sindre Sorhus (sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: combined-stream, delayed-stream. A copy of the source code may be downloaded from git://github.com/felixge/node-combined-stream.git (combined-stream), git://github.com/felixge/node-delayed-stream.git (delayed-stream). This software contains the following license and notice below: Copyright (c) 2011 Debuggable Limited Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: commander. A copy of the source code may be downloaded from https://github.com/tj/commander.js.git. This software contains the following license and notice below: (The MIT License) Copyright (c) 2011 TJ Holowaychuk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: common-tags. A copy of the source code may be downloaded from https://github.com/declandewet/common-tags. This software contains the following license and notice below: License (MIT) ------------- Copyright © Declan de Wet Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: concat-map, is-typedarray, minimist. A copy of the source code may be downloaded from git://github.com/substack/node-concat-map.git (concat-map), git://github.com/hughsk/is-typedarray.git (is-typedarray), git://github.com/substack/minimist.git (minimist). This software contains the following license and notice below: This software is released under the MIT license: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: concat-stream. A copy of the source code may be downloaded from http://github.com/maxogden/concat-stream.git. This software contains the following license and notice below: The MIT License Copyright (c) 2013 Max Ogden Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: convert-source-map. A copy of the source code may be downloaded from git://github.com/thlorenz/convert-source-map.git. This software contains the following license and notice below: Copyright 2013 Thorsten Lorenz. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: core-util-is. A copy of the source code may be downloaded from git://github.com/isaacs/core-util-is. This software contains the following license and notice below: Copyright Node.js contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: cosmiconfig. A copy of the source code may be downloaded from git+https://github.com/davidtheclark/cosmiconfig.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 David Clark Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: cross-spawn. A copy of the source code may be downloaded from git@github.com:moxystudio/node-cross-spawn.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2018 Made With MOXY Lda Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: csstype. A copy of the source code may be downloaded from https://github.com/frenic/csstype. This software contains the following license and notice below: Copyright (c) 2017-2018 Fredrik Nicol Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: dashdash. A copy of the source code may be downloaded from git://github.com/trentm/node-dashdash.git. This software contains the following license and notice below: # This is the MIT license Copyright (c) 2013 Trent Mick. All rights reserved. Copyright (c) 2013 Joyent Inc. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: date-fns. A copy of the source code may be downloaded from https://github.com/date-fns/date-fns. This software contains the following license and notice below: # License date-fns is licensed under the [MIT license](http://kossnocorp.mit-license.org). Read more about MIT at [TLDRLegal](https://tldrlegal.com/license/mit-license). ----- The following software may be included in this product: debug. A copy of the source code may be downloaded from git://github.com/visionmedia/debug.git. This software contains the following license and notice below: (The MIT License) Copyright (c) 2014 TJ Holowaychuk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: dom-helpers. A copy of the source code may be downloaded from git+https://github.com/react-bootstrap/dom-helpers.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 Jason Quense Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: easymde. A copy of the source code may be downloaded from https://github.com/Ionaru/easy-markdown-editor.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 Sparksuite, Inc. Copyright (c) 2017 Jeroen Akkerman. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: ecc-jsbn. A copy of the source code may be downloaded from https://github.com/quartzjer/ecc-jsbn.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Jeremie Miller Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: emoji-mart. A copy of the source code may be downloaded from git@github.com:missive/emoji-mart.git. This software contains the following license and notice below: Copyright (c) 2016, Missive All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: end-of-stream, pump. A copy of the source code may be downloaded from git://github.com/mafintosh/end-of-stream.git (end-of-stream), git://github.com/mafintosh/pump.git (pump). This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Mathias Buus Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: error-ex, is-arrayish. A copy of the source code may be downloaded from https://github.com/qix-/node-error-ex.git (error-ex), https://github.com/qix-/node-is-arrayish.git (is-arrayish). This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 JD Ballard Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: eventemitter2. A copy of the source code may be downloaded from git://github.com/hij1nx/EventEmitter2.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2016 Paolo Fragomeni and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: execa, get-stream, global-dirs, import-fresh, log-symbols, onetime, parse-json, pretty-bytes. A copy of the source code may be downloaded from https://github.com/sindresorhus/execa.git (execa), https://github.com/sindresorhus/get-stream.git (get-stream), https://github.com/sindresorhus/global-dirs.git (global-dirs), https://github.com/sindresorhus/import-fresh.git (import-fresh), https://github.com/sindresorhus/log-symbols.git (log-symbols), https://github.com/sindresorhus/onetime.git (onetime), https://github.com/sindresorhus/parse-json.git (parse-json), https://github.com/sindresorhus/pretty-bytes.git (pretty-bytes). This software contains the following license and notice below: MIT License Copyright (c) Sindre Sorhus (https://sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: executable. A copy of the source code may be downloaded from https://github.com/kevva/executable.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) Kevin Mårtensson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: extend. A copy of the source code may be downloaded from https://github.com/justmoon/node-extend.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Stefan Thomas Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: extract-zip. A copy of the source code may be downloaded from https://github.com/maxogden/extract-zip.git. This software contains the following license and notice below: Copyright (c) 2014 Max Ogden and other contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: extsprintf, jsprim. A copy of the source code may be downloaded from git://github.com/davepacheco/node-extsprintf.git (extsprintf), git://github.com/joyent/node-jsprim.git (jsprim). This software contains the following license and notice below: Copyright (c) 2012, Joyent, Inc. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ----- The following software may be included in this product: fast-deep-equal, json-schema-traverse. A copy of the source code may be downloaded from git+https://github.com/epoberezkin/fast-deep-equal.git (fast-deep-equal), git+https://github.com/epoberezkin/json-schema-traverse.git (json-schema-traverse). This software contains the following license and notice below: MIT License Copyright (c) 2017 Evgeny Poberezkin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: fast-json-stable-stringify. A copy of the source code may be downloaded from git://github.com/epoberezkin/fast-json-stable-stringify.git. This software contains the following license and notice below: This software is released under the MIT license: Copyright (c) 2017 Evgeny Poberezkin Copyright (c) 2013 James Halliday Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: fast-memoize. A copy of the source code may be downloaded from git+https://github.com/caiogondim/fast-memoize.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2016 Caio Gondim Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: fd-slicer. A copy of the source code may be downloaded from git://github.com/andrewrk/node-fd-slicer.git. This software contains the following license and notice below: Copyright (c) 2014 Andrew Kelley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: find-root. A copy of the source code may be downloaded from git@github.com:js-n/find-root.git. This software contains the following license and notice below: Copyright © 2017 jsdnxx Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: form-data. A copy of the source code may be downloaded from git://github.com/form-data/form-data.git. This software contains the following license and notice below: Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: fs-extra. A copy of the source code may be downloaded from https://github.com/jprichardson/node-fs-extra. This software contains the following license and notice below: (The MIT License) Copyright (c) 2011-2017 JP Richardson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: fs.realpath. A copy of the source code may be downloaded from git+https://github.com/isaacs/fs.realpath.git. This software contains the following license and notice below: The ISC License Copyright (c) Isaac Z. Schlueter and Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ---- This library bundles a version of the `fs.realpath` and `fs.realpathSync` methods from Node.js v0.10 under the terms of the Node.js MIT license. Node's license follows, also included at the header of `old.js` which contains the licensed code: Copyright Joyent, Inc. and other Node contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: function-bind. A copy of the source code may be downloaded from git://github.com/Raynos/function-bind.git. This software contains the following license and notice below: Copyright (c) 2013 Raynos. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: getos. A copy of the source code may be downloaded from https://github.com/retrohacker/getos.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2016 William Blankenship Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: getpass, http-signature, sshpk. A copy of the source code may be downloaded from https://github.com/arekinath/node-getpass.git (getpass), git://github.com/joyent/node-http-signature.git (http-signature), git+https://github.com/joyent/node-sshpk.git (sshpk). This software contains the following license and notice below: Copyright Joyent, Inc. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: glob. A copy of the source code may be downloaded from git://github.com/isaacs/node-glob.git. This software contains the following license and notice below: The ISC License Copyright (c) Isaac Z. Schlueter and Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ## Glob Logo Glob's logo created by Tanya Brassie , licensed under a Creative Commons Attribution-ShareAlike 4.0 International License https://creativecommons.org/licenses/by-sa/4.0/ ----- The following software may be included in this product: graceful-fs. A copy of the source code may be downloaded from https://github.com/isaacs/node-graceful-fs. This software contains the following license and notice below: The ISC License Copyright (c) Isaac Z. Schlueter, Ben Noordhuis, and Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: har-schema. A copy of the source code may be downloaded from https://github.com/ahmadnassri/har-schema.git. This software contains the following license and notice below: Copyright (c) 2015, Ahmad Nassri Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: har-validator. A copy of the source code may be downloaded from https://github.com/ahmadnassri/node-har-validator.git. This software contains the following license and notice below: MIT License Copyright (c) 2018 Ahmad Nassri Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: history, react-router, react-router-dom. A copy of the source code may be downloaded from https://github.com/ReactTraining/history.git (history), https://github.com/ReactTraining/react-router.git (react-router), https://github.com/ReactTraining/react-router.git (react-router-dom). This software contains the following license and notice below: MIT License Copyright (c) React Training 2016-2018 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: hoist-non-react-statics. A copy of the source code may be downloaded from git://github.com/mridgway/hoist-non-react-statics.git. This software contains the following license and notice below: Software License Agreement (BSD License) ======================================== Copyright (c) 2015, Yahoo! Inc. All rights reserved. ---------------------------------------------------- Redistribution and use of this software in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Yahoo! Inc. nor the names of YUI's contributors may be used to endorse or promote products derived from this software without specific prior written permission of Yahoo! Inc. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: human-signals. A copy of the source code may be downloaded from https://github.com/ehmicky/human-signals.git. This software contains the following license and notice below: Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2019 ehmicky Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ----- The following software may be included in this product: inflight. A copy of the source code may be downloaded from https://github.com/npm/inflight.git. This software contains the following license and notice below: The ISC License Copyright (c) Isaac Z. Schlueter Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: inherits. A copy of the source code may be downloaded from git://github.com/isaacs/inherits. This software contains the following license and notice below: The ISC License Copyright (c) Isaac Z. Schlueter Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: ini, isexe, json-stringify-safe, minimatch, once, rimraf, which, wrappy. A copy of the source code may be downloaded from git://github.com/isaacs/ini.git (ini), git+https://github.com/isaacs/isexe.git (isexe), git://github.com/isaacs/json-stringify-safe (json-stringify-safe), git://github.com/isaacs/minimatch.git (minimatch), git://github.com/isaacs/once (once), git://github.com/isaacs/rimraf.git (rimraf), git://github.com/isaacs/node-which.git (which), https://github.com/npm/wrappy (wrappy). This software contains the following license and notice below: The ISC License Copyright (c) Isaac Z. Schlueter and Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: intl-messageformat, intl-messageformat-parser. A copy of the source code may be downloaded from git@github.com:formatjs/formatjs.git (intl-messageformat), git://github.com/formatjs/formatjs.git (intl-messageformat-parser). This software contains the following license and notice below: Copyright (c) 2019, Oath Inc. Licensed under the terms of the New BSD license. See below for terms. Redistribution and use of this software in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - Neither the name of Oath Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission of Oath Inc. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: is-core-module. A copy of the source code may be downloaded from git+https://github.com/inspect-js/is-core-module.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Dave Justice Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: is-promise. A copy of the source code may be downloaded from https://github.com/then/is-promise.git. This software contains the following license and notice below: Copyright (c) 2014 Forbes Lindesay Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: isstream. A copy of the source code may be downloaded from https://github.com/rvagg/isstream.git. This software contains the following license and notice below: The MIT License (MIT) ===================== Copyright (c) 2015 Rod Vagg --------------------------- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: js-tokens. A copy of the source code may be downloaded from https://github.com/lydell/js-tokens.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: jsbn. A copy of the source code may be downloaded from https://github.com/andyperlitch/jsbn.git. This software contains the following license and notice below: Licensing --------- This software is covered under the following copyright: /* * Copyright (c) 2003-2005 Tom Wu * All Rights Reserved. * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. * * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER * RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF * THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. * * In addition, the following condition applies: * * All redistributions must retain an intact copy of this copyright notice * and disclaimer. */ Address all questions regarding this license to: Tom Wu tjw@cs.Stanford.EDU ----- The following software may be included in this product: json-parse-even-better-errors. A copy of the source code may be downloaded from https://github.com/npm/json-parse-even-better-errors. This software contains the following license and notice below: Copyright 2017 Kat Marchán Copyright npm, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- This library is a fork of 'better-json-errors' by Kat Marchán, extended and distributed under the terms of the MIT license above. ----- The following software may be included in this product: jsonfile. A copy of the source code may be downloaded from git@github.com:jprichardson/node-jsonfile.git. This software contains the following license and notice below: (The MIT License) Copyright (c) 2012-2015, JP Richardson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: lazy-ass. A copy of the source code may be downloaded from https://github.com/bahmutov/lazy-ass.git. This software contains the following license and notice below: Copyright (c) 2014 Gleb Bahmutov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: lines-and-columns. A copy of the source code may be downloaded from https://github.com/eventualbuddha/lines-and-columns.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 Brian Donovan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: lodash. A copy of the source code may be downloaded from https://github.com/lodash/lodash.git. This software contains the following license and notice below: Copyright OpenJS Foundation and other contributors Based on Underscore.js, copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors This software consists of voluntary contributions made by many individuals. For exact contribution history, see the revision history available at https://github.com/lodash/lodash The following license applies to all parts of this software except as documented below: ==== Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ==== Copyright and related rights for sample code are waived via CC0. Sample code is defined as all source code displayed within the prose of the documentation. CC0: http://creativecommons.org/publicdomain/zero/1.0/ ==== Files located in the node_modules and vendor directories are externally maintained libraries used by this software which have their own licenses; we recommend you read them, as their terms may differ from the terms above. ----- The following software may be included in this product: lodash.once. A copy of the source code may be downloaded from https://github.com/lodash/lodash.git. This software contains the following license and notice below: Copyright jQuery Foundation and other contributors Based on Underscore.js, copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors This software consists of voluntary contributions made by many individuals. For exact contribution history, see the revision history available at https://github.com/lodash/lodash The following license applies to all parts of this software except as documented below: ==== Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ==== Copyright and related rights for sample code are waived via CC0. Sample code is defined as all source code displayed within the prose of the documentation. CC0: http://creativecommons.org/publicdomain/zero/1.0/ ==== Files located in the node_modules and vendor directories are externally maintained libraries used by this software which have their own licenses; we recommend you read them, as their terms may differ from the terms above. ----- The following software may be included in this product: loose-envify. A copy of the source code may be downloaded from git://github.com/zertosh/loose-envify.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 Andres Suarez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: marked. A copy of the source code may be downloaded from git://github.com/markedjs/marked.git. This software contains the following license and notice below: # License information ## Contribution License Agreement If you contribute code to this project, you are implicitly allowing your code to be distributed under the MIT license. You are also implicitly verifying that all code is your original work. `` ## Marked Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ## Markdown Copyright © 2004, John Gruber http://daringfireball.net/ All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage. ----- The following software may be included in this product: memoize-one, tiny-invariant, tiny-warning. A copy of the source code may be downloaded from https://github.com/alexreardon/memoize-one.git (memoize-one), https://github.com/alexreardon/tiny-invariant.git (tiny-invariant), https://github.com/alexreardon/tiny-warning.git (tiny-warning). This software contains the following license and notice below: MIT License Copyright (c) 2019 Alexander Reardon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: merge-stream. A copy of the source code may be downloaded from https://github.com/grncdr/merge-stream.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) Stephen Sugden (stephensugden.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: mime-db. A copy of the source code may be downloaded from https://github.com/jshttp/mime-db.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Jonathan Ong me@jongleberry.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: mime-types. A copy of the source code may be downloaded from https://github.com/jshttp/mime-types.git. This software contains the following license and notice below: (The MIT License) Copyright (c) 2014 Jonathan Ong Copyright (c) 2015 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: mini-create-react-context. A copy of the source code may be downloaded from https://github.com/StringEpsilon/mini-create-react-context. This software contains the following license and notice below: Copyright (c) 2019-present StringEpsilon Copyright (c) 2017-2019 James Kyle Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: mkdirp. A copy of the source code may be downloaded from https://github.com/substack/node-mkdirp.git. This software contains the following license and notice below: Copyright 2010 James Halliday (mail@substack.net) This project is free software released under the MIT/X11 license: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: moment. A copy of the source code may be downloaded from https://github.com/moment/moment.git. This software contains the following license and notice below: Copyright (c) JS Foundation and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: ms. A copy of the source code may be downloaded from https://github.com/vercel/ms.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2020 Vercel, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: ms. A copy of the source code may be downloaded from https://github.com/zeit/ms.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2016 Zeit, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: nanoevents. A copy of the source code may be downloaded from https://github.com/ai/nanoevents.git. This software contains the following license and notice below: The MIT License (MIT) Copyright 2016 Andrey Sitnik Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: path-parse. A copy of the source code may be downloaded from https://github.com/jbgutierrez/path-parse.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2015 Javier Blanco Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: path-to-regexp. A copy of the source code may be downloaded from https://github.com/pillarjs/path-to-regexp.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: pend. A copy of the source code may be downloaded from git://github.com/andrewrk/node-pend.git. This software contains the following license and notice below: The MIT License (Expat) Copyright (c) 2014 Andrew Kelley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: performance-now. A copy of the source code may be downloaded from git://github.com/braveg1rl/performance-now.git. This software contains the following license and notice below: Copyright (c) 2013 Braveg1rl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: process-nextick-args. A copy of the source code may be downloaded from https://github.com/calvinmetcalf/process-nextick-args.git. This software contains the following license and notice below: # Copyright (c) 2015 Calvin Metcalf Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. **THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.** ----- The following software may be included in this product: prop-types. A copy of the source code may be downloaded from https://github.com/facebook/prop-types.git. This software contains the following license and notice below: MIT License Copyright (c) 2013-present, Facebook, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: psl. A copy of the source code may be downloaded from git@github.com:lupomontero/psl.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2017 Lupo Montero lupomontero@gmail.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: qs. A copy of the source code may be downloaded from https://github.com/ljharb/qs.git. This software contains the following license and notice below: Copyright (c) 2014 Nathan LaFreniere and other contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * * * The complete list of contributors can be found at: https://github.com/hapijs/qs/graphs/contributors ----- The following software may be included in this product: querystring. A copy of the source code may be downloaded from git://github.com/Gozala/querystring.git. This software contains the following license and notice below: Copyright 2012 Irakli Gozalishvili. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: ramda. A copy of the source code may be downloaded from git://github.com/ramda/ramda.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2013-2018 Scott Sauyet and Michael Hurley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: react, react-dom, react-is, scheduler. A copy of the source code may be downloaded from git+https://github.com/facebook/react.git (react), git+https://github.com/facebook/react.git (react-dom), https://github.com/facebook/react.git (react-is), https://github.com/facebook/react.git (scheduler). This software contains the following license and notice below: MIT License Copyright (c) Facebook, Inc. and its affiliates. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: react-input-autosize. A copy of the source code may be downloaded from https://github.com/JedWatson/react-input-autosize.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2018 Jed Watson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: react-intl. A copy of the source code may be downloaded from git@github.com:formatjs/formatjs.git. This software contains the following license and notice below: Copyright 2019 Oath Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the Oath Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Oath INC. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: react-simplemde-editor. A copy of the source code may be downloaded from https://github.com/RIP21/react-simplemde-editor. This software contains the following license and notice below: MIT License Copyright (c) 2017 Ben Lodge and Andrii Los Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: react-transition-group. A copy of the source code may be downloaded from https://github.com/reactjs/react-transition-group.git. This software contains the following license and notice below: BSD 3-Clause License Copyright (c) 2018, React Community Forked from React (https://github.com/facebook/react) Copyright 2013-present, Facebook, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: readable-stream. A copy of the source code may be downloaded from git://github.com/nodejs/readable-stream. This software contains the following license and notice below: Node.js is licensed for use as follows: """ Copyright Node.js contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ This license applies to parts of Node.js originating from the https://github.com/joyent/node repository: """ Copyright Joyent, Inc. and other Node contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ----- The following software may be included in this product: regenerator-runtime. A copy of the source code may be downloaded from https://github.com/facebook/regenerator/tree/master/packages/regenerator-runtime. This software contains the following license and notice below: MIT License Copyright (c) 2014-present, Facebook, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: request-progress. A copy of the source code may be downloaded from git://github.com/IndigoUnited/node-request-progress. This software contains the following license and notice below: Copyright (c) 2012 IndigoUnited Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: resolve. A copy of the source code may be downloaded from git://github.com/browserify/resolve.git. This software contains the following license and notice below: MIT License Copyright (c) 2012 James Halliday Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: resolve-pathname, value-equal. A copy of the source code may be downloaded from https://github.com/mjackson/resolve-pathname.git (resolve-pathname), https://github.com/mjackson/value-equal.git (value-equal). This software contains the following license and notice below: MIT License Copyright (c) Michael Jackson 2016-2018 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: rxjs. A copy of the source code may be downloaded from https://github.com/reactivex/rxjs.git. This software contains the following license and notice below: Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ----- The following software may be included in this product: safe-buffer. A copy of the source code may be downloaded from git://github.com/feross/safe-buffer.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) Feross Aboukhadijeh Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: safer-buffer. A copy of the source code may be downloaded from git+https://github.com/ChALkeR/safer-buffer.git. This software contains the following license and notice below: MIT License Copyright (c) 2018 Nikita Skovoroda Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: shallow-equal. A copy of the source code may be downloaded from https://github.com/moroshko/shallow-equal.git. This software contains the following license and notice below: The MIT License (MIT) Copyright © 2016 Misha Moroshko Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: shebang-command. A copy of the source code may be downloaded from https://github.com/kevva/shebang-command.git. This software contains the following license and notice below: MIT License Copyright (c) Kevin Mårtensson (github.com/kevva) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: signal-exit. A copy of the source code may be downloaded from https://github.com/tapjs/signal-exit.git. This software contains the following license and notice below: The ISC License Copyright (c) 2015, Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: slice-ansi. A copy of the source code may be downloaded from https://github.com/chalk/slice-ansi.git. This software contains the following license and notice below: (The MIT License) Copyright (c) 2015 DC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: source-map. A copy of the source code may be downloaded from http://github.com/mozilla/source-map.git. This software contains the following license and notice below: Copyright (c) 2009-2011, Mozilla Foundation and contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the names of the Mozilla Foundation nor the names of project contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: string_decoder. A copy of the source code may be downloaded from git://github.com/nodejs/string_decoder.git. This software contains the following license and notice below: Node.js is licensed for use as follows: """ Copyright Node.js contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ This license applies to parts of Node.js originating from the https://github.com/joyent/node repository: """ Copyright Joyent, Inc. and other Node contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ----- The following software may be included in this product: symbol-observable. A copy of the source code may be downloaded from https://github.com/blesh/symbol-observable.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) Sindre Sorhus (sindresorhus.com) Copyright (c) Ben Lesh Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: tmp. A copy of the source code may be downloaded from https://github.com/raszi/node-tmp.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 KARASZI István Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: to-fast-properties. A copy of the source code may be downloaded from https://github.com/sindresorhus/to-fast-properties.git. This software contains the following license and notice below: MIT License Copyright (c) 2014 Petka Antonov 2015 Sindre Sorhus Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: tough-cookie. A copy of the source code may be downloaded from git://github.com/salesforce/tough-cookie.git. This software contains the following license and notice below: Copyright (c) 2015, Salesforce.com, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of Salesforce.com nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- The following software may be included in this product: tslib. A copy of the source code may be downloaded from https://github.com/Microsoft/tslib.git. This software contains the following license and notice below: Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: tweetnacl. A copy of the source code may be downloaded from https://github.com/dchest/tweetnacl-js.git. This software contains the following license and notice below: This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ----- The following software may be included in this product: typedarray. A copy of the source code may be downloaded from git://github.com/substack/typedarray.git. This software contains the following license and notice below: /* Copyright (c) 2010, Linden Research, Inc. Copyright (c) 2012, Joshua Bell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. $/LicenseInfo$ */ // Original can be found at: // https://bitbucket.org/lindenlab/llsd // Modifications by Joshua Bell inexorabletash@gmail.com // https://github.com/inexorabletash/polyfill // ES3/ES5 implementation of the Krhonos Typed Array Specification // Ref: http://www.khronos.org/registry/typedarray/specs/latest/ // Date: 2011-02-01 // // Variations: // * Allows typed_array.get/set() as alias for subscripts (typed_array[]) ----- The following software may be included in this product: universalify. A copy of the source code may be downloaded from git+https://github.com/RyanZim/universalify.git. This software contains the following license and notice below: (The MIT License) Copyright (c) 2017, Ryan Zimmerman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: uri-js. A copy of the source code may be downloaded from http://github.com/garycourt/uri-js. This software contains the following license and notice below: Copyright 2011 Gary Court. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY GARY COURT "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GARY COURT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Gary Court. ----- The following software may be included in this product: url. A copy of the source code may be downloaded from https://github.com/defunctzombie/node-url.git. This software contains the following license and notice below: The MIT License (MIT) Copyright Joyent, Inc. and other Node contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: util-deprecate. A copy of the source code may be downloaded from git://github.com/TooTallNate/util-deprecate.git. This software contains the following license and notice below: (The MIT License) Copyright (c) 2014 Nathan Rajlich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: uuid. A copy of the source code may be downloaded from https://github.com/uuidjs/uuid.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2010-2016 Robert Kieffer and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: verror. A copy of the source code may be downloaded from git://github.com/davepacheco/node-verror.git. This software contains the following license and notice below: Copyright (c) 2016, Joyent, Inc. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ----- The following software may be included in this product: yaml. A copy of the source code may be downloaded from https://github.com/eemeli/yaml.git. This software contains the following license and notice below: Copyright 2018 Eemeli Aro Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----- The following software may be included in this product: yauzl. A copy of the source code may be downloaded from https://github.com/thejoshwolfe/yauzl.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Josh Wolfe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The following software may be included in this product: react-day-picker. A copy of the source code may be downloaded from https://github.com/gpbl/react-day-picker.git. This software contains the following license and notice below: The MIT License (MIT) Copyright (c) 2014 Giampaolo Bellavite Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: webapp/__mocks__/fileMock.js ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. module.exports = 'test-file-stub'; ================================================ FILE: webapp/__mocks__/styleMock.js ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. module.exports = {}; ================================================ FILE: webapp/cypress/config.json ================================================ { "serverRoot": "http://localhost:8088", "port": 8088, "dbtype": "sqlite3", "dbconfig": "file::memory:?cache=shared&_busy_timeout=5000", "useSSL": false, "webpath": "../pack", "filespath": "../../files", "telemetry": false, "webhook_update": [], "session_expire_time": 2592000, "session_refresh_time": 18000 } ================================================ FILE: webapp/cypress/global.d.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. declare namespace Cypress { type LoginData = { username: string password: string } type UserData = LoginData & { email: string } interface Chainable { apiRegisterUser: (data: UserData, token?: string, failOnError?: boolean) => Chainable apiLoginUser: (data: LoginData) => Chainable apiGetMe: () => Chainable apiChangePassword: (userId: string, oldPassword: string, newPassword: string) => Chainable apiInitServer: () => Chainable apiDeleteBoard: (id: string) => Chainable apiResetBoards: () => Chainable apiSkipTour: (userID: string) => Chainable uiCreateNewBoard: (title?: string) => Chainable uiAddNewGroup: (name?: string) => Chainable uiAddNewCard: (title?: string, columnIndex?: number) => Chainable /** * Create a board on a given menu item. * * @param {string} item - one of the template menu options, ex. 'Empty board' */ uiCreateBoard: (item: string) => Chainable uiCreateEmptyBoard: () => Chainable } } ================================================ FILE: webapp/cypress/integration/cardBadges.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. describe('Card badges', () => { beforeEach(() => { cy.apiInitServer() cy.apiResetBoards() cy.apiGetMe().then((userID) => cy.apiSkipTour(userID)) localStorage.setItem('welcomePageViewed', 'true') localStorage.setItem('language', 'en') }) it('Shows and hides card badges', () => { cy.visit('/') // Create new board cy.uiCreateNewBoard('Testing') // Add a new card cy.uiAddNewCard('Card') // Add some comments cy.log('**Add some comments**') addComment('Some comment') addComment('Another comment') addComment('Additional comment') // Add card description cy.log('**Add card description**') cy.findByText('Add a description...').click() cy.findByRole('combobox').type('## Header\n- [ ] one\n- [x] two{esc}') // Add checkboxes cy.log('**Add checkboxes**') cy.findByRole('button', {name: 'Add content'}).click() cy.findByRole('button', {name: 'checkbox'}).click() cy.focused().type('three{enter}') cy.focused().type('four{enter}') cy.focused().type('{esc}') cy.findByDisplayValue('three').prev().click() // Close card dialog cy.log('**Close card dialog**') cy.findByRole('button', {name: 'Close dialog'}).click() cy.findByRole('dialog').should('not.exist') // Show card badges cy.log('**Show card badges**') cy.findByRole('button', {name: 'Properties menu'}).click() cy.findByRole('button', {name: 'Comments and description'}).click() cy.findByTitle('This card has a description').should('exist') cy.findByTitle('Comments').contains('3').should('exist') cy.findByTitle('Checkboxes').contains('2/4').should('exist') // Hide card badges cy.log('**Hide card badges**') cy.findByRole('button', {name: 'Comments and description'}).click() cy.findByRole('button', {name: 'Properties menu'}).click() cy.findByTitle('This card has a description').should('not.exist') cy.findByTitle('Comments').should('not.exist') cy.findByTitle('Checkboxes').should('not.exist') }) const addComment = (text: string) => { cy.findByText('Add a comment...').click() cy.findByRole('combobox').type(text).blur() cy.findByRole('button', {name: 'Send'}).click() } }) ================================================ FILE: webapp/cypress/integration/cardURLProperty.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. describe('Card URL Property', () => { beforeEach(() => { cy.apiInitServer() cy.apiResetBoards() cy.apiGetMe().then((userID) => cy.apiSkipTour(userID)) localStorage.setItem('welcomePageViewed', 'true') localStorage.setItem('language', 'en') }) const url = 'https://mattermost.com' const changedURL = 'https://mattermost.com/blog' it('Allows to create and edit URL property', () => { cy.visit('/') // Create new board cy.uiCreateNewBoard('Testing') // Add a new card cy.uiAddNewCard('Card') // Add URL property cy.log('**Add URL property**') cy.findByRole('button', {name: '+ Add a property'}).click() cy.findByRole('button', {name: 'URL'}).click() cy.findByRole('textbox', {name: 'URL'}).type('{enter}') // Enter URL cy.log('**Enter URL**') cy.findByPlaceholderText('Empty').type(`${url}{enter}`) // Check buttons cy.log('**Check buttons**') cy.findByRole('link', {name: url}).realHover() cy.findByRole('button', {name: 'Edit'}).should('exist') cy.findByRole('button', {name: 'Copy'}).should('exist') // Change URL cy.log('**Change URL**') cy.findByRole('link', {name: url}).realHover() cy.findByRole('button', {name: 'Edit'}).click() cy.findByRole('textbox', {name: url}).clear().type(`${changedURL}{enter}`) cy.findByRole('link', {name: changedURL}).should('exist') // Close card dialog cy.log('**Close card dialog**') cy.findByRole('button', {name: 'Close dialog'}).click() cy.findByRole('dialog').should('not.exist') // Show URL property showURLProperty() // Copy URL to clipboard cy.log('**Copy URL to clipboard**') cy.document().then((doc) => cy.spy(doc, 'execCommand')).as('exec') cy.findByRole('link', {name: changedURL}).realHover() cy.findByRole('button', {name: 'Edit'}).should('not.exist') cy.findByRole('button', {name: 'Copy'}).click() cy.findByText('Copied!').should('exist') cy.findByText('Copied!').should('not.exist') cy.get('@exec').should('have.been.calledOnceWith', 'copy') // Add table view addView('Table') // Check buttons cy.log('**Check buttons**') cy.findByRole('link', {name: changedURL}).realHover() cy.findByRole('button', {name: 'Edit'}).should('exist') cy.findByRole('button', {name: 'Copy'}).should('not.exist') // Add gallery view addView('Gallery') showURLProperty() // Check buttons cy.log('**Check buttons**') cy.findByRole('link', {name: changedURL}).realHover() cy.findByRole('button', {name: 'Edit'}).should('not.exist') cy.findByRole('button', {name: 'Copy'}).should('exist') // Add calendar view addView('Calendar') showURLProperty() // Check buttons cy.log('**Check buttons**') cy.findByRole('link', {name: changedURL}).realHover() cy.findByRole('button', {name: 'Edit'}).should('not.exist') cy.findByRole('button', {name: 'Copy'}).should('exist') }) type ViewType = 'Board' | 'Table' | 'Gallery' | 'Calendar' const addView = (type: ViewType) => { cy.log(`**Add ${type} view**`) cy.findByRole('button', {name: 'View menu'}).click() cy.findByText('Add view').realHover() cy.findByRole('button', {name: type}).click() cy.findByRole('textbox', {name: `${type} view`}).should('exist') } const showURLProperty = () => { cy.log('**Show URL property**') cy.findByRole('button', {name: 'Properties'}).click() cy.findByRole('button', {name: 'URL'}).click() cy.findByRole('link', {name: changedURL}).should('exist') } }) ================================================ FILE: webapp/cypress/integration/createBoard.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. describe('Create and delete board / card', () => { const timestamp = new Date().toLocaleString() const boardTitle = `Test Board (${timestamp})` const cardTitle = `Test Card (${timestamp})` beforeEach(() => { cy.apiInitServer() cy.apiResetBoards() cy.apiGetMe().then((userID) => cy.apiSkipTour(userID)) localStorage.setItem('welcomePageViewed', 'true') localStorage.setItem('language', 'en') }) it('MM-T4274 Create an Empty Board', () => { cy.visit('/') cy.contains('+ Add board').should('exist').click() // Tests for template selector cy.contains('Use this template').should('exist') // Some options are present cy.contains('Meeting Agenda').should('exist') cy.contains('Personal Goals').should('exist') cy.contains('Project Tasks').should('exist') // Create empty board cy.contains('Create an empty board').should('exist').click({force: true}) cy.get('.BoardComponent').should('exist') cy.get('.Editable.title').invoke('attr', 'placeholder').should('contain', 'Untitled board') // Change Title cy.get('.Editable.title'). type('Testing'). type('{enter}'). should('have.value', 'Testing') }) it('Can create and delete a board and a card', () => { // Visit a page and create new empty board cy.visit('/') cy.uiCreateEmptyBoard() // Change board title cy.log('**Change board title**') cy.get('.Editable.title'). type(boardTitle). type('{enter}'). should('have.value', boardTitle) // Hide and show the sidebar cy.log('**Hide and show the sidebar**') cy.get('.sidebarSwitcher').click() cy.get('.Sidebar .heading').should('not.exist') cy.get('.Sidebar .show-button').click() cy.get('.Sidebar .heading').should('exist') // Rename board view cy.log('**Rename board view**') const boardViewTitle = `Test board (${timestamp})` cy.get(".ViewHeader>.viewSelector>.Editable[title='Board view']").should('exist') cy.get('.ViewHeader>.viewSelector>.Editable'). clear(). type(boardViewTitle). type('{esc}') cy.get(`.ViewHeader .Editable[title='${boardViewTitle}']`).should('exist') // Create card cy.log('**Create card**') cy.get('.ViewHeader').contains('New').click() cy.get('.CardDetail').should('exist') //Check title has focus when card is created cy.log('**Check title has focus when card is created**') cy.get('.CardDetail .EditableArea.title'). should('have.focus') // Change card title cy.log('**Change card title**') // eslint-disable-next-line cypress/no-unnecessary-waiting cy.get('.CardDetail .EditableArea.title'). click(). should('have.focus'). wait(1000). type(cardTitle). should('have.value', cardTitle) // Close card dialog cy.log('**Close card dialog**') cy.get('.Dialog Button[title=\'Close dialog\']'). should('be.visible'). click(). wait(500) // Create a card by clicking on the + button cy.log('**Create a card by clicking on the + button**') cy.get('.KanbanColumnHeader button .AddIcon').click() cy.get('.CardDetail').should('exist') cy.get('.Dialog.dialog-back .wrapper').click({force: true}) // Create table view cy.log('**Create table view**') cy.get('.ViewHeader').get('.DropdownIcon').first().parent().click() cy.get('.ViewHeader').contains('Add view').realHover() cy.get('.ViewHeader'). contains('Add view'). parent(). contains('Table'). click() cy.get(".ViewHeader .Editable[title='Table view']").should('exist') cy.get(`.TableRow [value='${cardTitle}']`).should('exist') // Rename table view cy.log('**Rename table view**') const tableViewTitle = `Test table (${timestamp})` cy.get(".ViewHeader .Editable[title='Table view']"). clear(). type(tableViewTitle). type('{esc}') cy.get(`.ViewHeader .Editable[title='${tableViewTitle}']`).should('exist') // Sort the table cy.log('**Sort the table**') cy.get('.ViewHeader').contains('Sort').click() cy.get('.ViewHeader'). contains('Sort'). parent(). contains('Name'). click() // Delete board cy.log('**Delete board**') cy.get('.Sidebar .octo-sidebar-list').then((el) => { cy.log(el.text()) }) cy.get('.Sidebar .octo-sidebar-list'). contains(boardTitle). parent(). find('.MenuWrapper'). find('button.IconButton'). click({force: true}) cy.contains('Delete board').click({force: true}) cy.get('.DeleteBoardDialog button.danger').click({force: true}) cy.contains(boardTitle).should('not.exist') }) it('MM-T4433 Scrolls the kanban board when dragging card to edge', () => { // Visit a page and create new empty board cy.visit('/') cy.wait(500) cy.uiCreateEmptyBoard() // Create 10 empty groups cy.log('**Create new empty groups**') for (let i = 0; i < 10; i++) { cy.contains('+ Add a group').scrollIntoView().should('be.visible').click() cy.get('.KanbanColumnHeader .Editable[value=\'New group\']').should('have.length', i + 1) } // Create empty card in last group cy.log('**Create new empty card in first group**') cy.get('.octo-board-column').last().contains('+ New').scrollIntoView().click() cy.get('.Dialog').should('exist') cy.get('.Dialog Button[title=\'Close dialog\']').should('be.visible').click() cy.get('.KanbanCard').scrollIntoView().should('exist') // Drag card to right corner and expect scroll to occur // eslint-disable-next-line cypress/no-unnecessary-waiting cy.get('.Kanban').invoke('scrollLeft').should('not.equal', 0).wait(1000) // wait necessary to let state change propagate // eslint-disable-next-line cypress/no-unnecessary-waiting cy.get('.KanbanCard'). trigger('dragstart'). wait(500) // wait necessary to trigger scroll animation for some time // eslint-disable-next-line cypress/no-unnecessary-waiting cy.get('.Kanban'). trigger('dragover', {clientX: 400, clientY: Cypress.config().viewportHeight / 2}). wait(4500). trigger('dragend') cy.get('.Kanban').invoke('scrollLeft').should('equal', 0) }) it('GH-2520 make cut/undo/redo work in comments', () => { const isMAC = navigator.userAgent.indexOf('Mac') !== -1 const ctrlKey = isMAC ? 'meta' : 'ctrl' // Visit a page and create new empty board cy.visit('/') cy.uiCreateEmptyBoard() // Create card cy.log('**Create card**') cy.get('.ViewHeader').contains('New').click() cy.get('.CardDetail').should('exist') cy.wait(1000) cy.log('**Add comment**') cy.get('.CommentsList'). findAllByTestId('preview-element'). click(). get('.CommentsList .MarkdownEditorInput'). type('Test Text') cy.log('**Cut comment**') cy.get('.CommentsList .MarkdownEditorInput'). type('{selectAll}'). trigger('cut'). should('have.text', '') cy.log('**Undo comment**') cy.get('.CommentsList .MarkdownEditorInput'). type(`{${ctrlKey}+z}`). should('have.text', 'Test Text') cy.log('**Redo comment**') cy.get('.CommentsList .MarkdownEditorInput'). type(`{shift+${ctrlKey}+z}`). should('have.text', '') }) }) ================================================ FILE: webapp/cypress/integration/groupByProperty.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. describe('Group board by different properties', () => { beforeEach(() => { cy.apiInitServer() cy.apiResetBoards() cy.apiGetMe().then((userID) => cy.apiSkipTour(userID)) localStorage.setItem('welcomePageViewed', 'true') localStorage.setItem('language', 'en') }) it('MM-T4291 Group by different property', () => { cy.visit('/') // Create new board cy.uiCreateNewBoard('Testing') // Add a new group cy.uiAddNewGroup('Group 1') // Add a new card to the group cy.log('**Add a new card to the group**') cy.findAllByRole('button', {name: '+ New'}).eq(1).click() cy.findByRole('dialog').should('exist') cy.findByTestId('select-non-editable').findByText('Group 1').should('exist') cy.get('#mainBoardBody').findByText('Untitled').should('exist') // Add new select property cy.log('**Add new select property**') cy.findAllByRole('button', {name: '+ Add a property'}).click() cy.findAllByRole('button', {name: 'Select'}).click() cy.findByRole('textbox', {name: 'Select'}).type('{enter}') cy.findByRole('dialog').findByRole('button', {name: 'Select'}).should('exist') // Close card dialog cy.log('**Close card dialog**') cy.findByRole('button', {name: 'Close dialog'}).click() cy.findByRole('dialog').should('not.exist') // Group by new select property cy.log('**Group by new select property**') cy.findByRole('button', {name: /Group by:/}).click() cy.findByRole('button', {name: 'Status'}).get('.CheckIcon').should('exist') cy.findByRole('button', {name: 'Select'}).click() cy.findByTitle(/empty Select property/).contains('No Select') cy.get('#mainBoardBody').findByText('Untitled').should('exist') // Add another new group cy.log('**Add another new group**') cy.findByRole('button', {name: '+ Add a group'}).click() cy.findByRole('textbox', {name: 'New group'}).should('exist') // Add a new card to another group cy.log('**Add a new card to another group**') cy.findAllByRole('button', {name: '+ New'}).eq(1).click() cy.findByRole('dialog').should('exist') cy.findAllByTestId('select-non-editable').last().findByText('New group').should('exist') }) }) ================================================ FILE: webapp/cypress/integration/loginActions.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. describe('Login actions', () => { const username = Cypress.env('username') const email = Cypress.env('email') const password = Cypress.env('password') beforeEach(() => { localStorage.setItem('language', 'en') }) it('Can perform login/register actions', () => { // Redirects to login page cy.log('**Redirects to login page (except plugin mode) **') cy.visit('/') cy.location('pathname').should('eq', '/login') cy.get('.LoginPage').contains('Log in') cy.get('#login-username').should('exist') cy.get('#login-password').should('exist') cy.get('button').contains('Log in') cy.get('a').contains('create an account', {matchCase: false}) // Can register a user cy.log('**Can register a user**') cy.visit('/login') cy.get('a').contains('create an account', {matchCase: false}).click() cy.location('pathname').should('eq', '/register') cy.get('.RegisterPage').contains('Sign up') cy.get('#login-email').type(email) cy.get('#login-username').type(username) cy.get('#login-password').type(password) cy.get('button').contains('Register').click() workspaceIsAvailable() // Can log out user cy.log('**Can log out user**') cy.get('.SidebarUserMenu').click() cy.get('.menu-name').contains('Log out').click() cy.location('pathname').should('eq', '/login') // User should not be logged in automatically cy.log('**User should not be logged in automatically**') cy.visit('/') cy.location('pathname').should('eq', '/login') // Can log in registered user cy.log('**Can log in registered user**') loginUser(password) // Can change password cy.log('**Can change password**') const newPassword = 'new_password' cy.get('.SidebarUserMenu').click() cy.get('.menu-name').contains('Change password').click() cy.location('pathname').should('eq', '/change_password') cy.get('.ChangePasswordPage').contains('Change Password') cy.get('#login-oldpassword').type(password) cy.get('#login-newpassword').type(newPassword) cy.get('button').contains('Change password').click() cy.get('.succeeded').click() workspaceIsAvailable() logoutUser() // Can log in user with new password cy.log('**Can log in user with new password**') loginUser(newPassword).then(() => resetPassword(newPassword)) logoutUser() // Can't register second user without invite link cy.log('**Can\'t register second user without invite link**') cy.visit('/register') cy.get('#login-email').type(email) cy.get('#login-username').type(username) cy.get('#login-password').type(password) cy.get('button').contains('Register').click() cy.get('.error').contains('Invalid registration link').should('exist') // Can register second user using invite link cy.log('**Can register second user using invite link**') // Copy invite link cy.log('**Copy invite link**') loginUser(password) cy.get('.Sidebar .SidebarUserMenu').click() cy.get('.menu-name').contains('Invite users').click() cy.get('.Button').contains('Copy link').click() cy.get('.Button').contains('Copied').should('exist') cy.get('a.shareUrl').invoke('attr', 'href').then((inviteLink) => { logoutUser() // Register a new user cy.log('**Register new user**') cy.visit(inviteLink as string) cy.get('#login-email').type('new-user@mail.com') cy.get('#login-username').type('new-user') cy.get('#login-password').type('new-password') cy.get('button').contains('Register').click() workspaceIsAvailable() }) }) const workspaceIsAvailable = () => { cy.location('pathname').should('eq', '/') cy.get('.Workspace').should('exist') return cy.get('.Sidebar').should('exist') } const loginUser = (withPassword: string) => { cy.visit('/login') cy.get('#login-username').type(username) cy.get('#login-password').type(withPassword) cy.get('button').contains('Log in').click() return workspaceIsAvailable() } const logoutUser = () => { cy.log('**Log out existing user**') cy.get('.SidebarUserMenu').click() cy.get('.menu-name').contains('Log out').click() cy.location('pathname').should('eq', '/login') } const resetPassword = (oldPassword: string) => { cy.apiGetMe().then((userId) => cy.apiChangePassword(userId, oldPassword, password)) } }) ================================================ FILE: webapp/cypress/integration/manageGroups.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. describe('Manage groups', () => { beforeEach(() => { cy.apiInitServer() cy.apiResetBoards() cy.apiGetMe().then((userID) => cy.apiSkipTour(userID)) localStorage.setItem('welcomePageViewed', 'true') localStorage.setItem('language', 'en') }) it('MM-T4284 Adding a group', () => { // Visit a page and create new empty board cy.visit('/') cy.uiCreateEmptyBoard() cy.contains('+ Add a group').click({force: true}) cy.get('.KanbanColumnHeader .Editable[value=\'New group\']').should('exist') cy.get('.KanbanColumnHeader .Editable[value=\'New group\']'). clear(). type('Group 1'). blur() cy.get('.KanbanColumnHeader .Editable[value=\'Group 1\']').should('exist') }) it('MM-T4285 Adding group color', () => { // Visit a page and create new empty board cy.visit('/') cy.uiCreateEmptyBoard() cy.contains('+ Add a group').click({force: true}) cy.get('.KanbanColumnHeader .Editable[value=\'New group\']').should('exist') cy.get('.KanbanColumnHeader').last().within(() => { cy.get('.icon-dots-horizontal').click({force: true}) cy.get('.menu-options').should('exist').within(() => { cy.contains('Hide').should('exist') cy.contains('Delete').should('exist') // Some colours cy.contains('Brown').should('exist') cy.contains('Gray').should('exist') cy.contains('Orange').should('exist') // Click on green cy.contains('Green').should('be.visible').click().wait(1000) // eslint-disable-line cypress/no-unnecessary-waiting }) }) cy.get('.KanbanColumnHeader').last().within(() => { cy.get('.Label.propColorGreen').should('exist') }) }) it('MM-T4287 Hiding/unhiding a group', () => { // Step 1: Create an empty board and add a group cy.visit('/') cy.uiCreateEmptyBoard() cy.contains('+ Add a group').click({force: true}) cy.get('.KanbanColumnHeader .Editable[value=\'New group\']').should('exist') cy.get('.KanbanColumnHeader .Editable[value=\'New group\']'). clear(). type('Group 1'). blur() cy.get('.KanbanColumnHeader .Editable[value=\'Group 1\']').should('exist') // Step 2: Click on the three dots next to "Group 1" cy.get('.KanbanColumnHeader').last().within(() => { cy.get('.icon-dots-horizontal').click({force: true}) cy.get('.menu-options').should('exist').within(() => { cy.contains('Hide').should('exist') cy.contains('Delete').should('exist') // Some colours cy.contains('Brown').should('exist') cy.contains('Gray').should('exist') cy.contains('Orange').should('exist') }) }) // Step 3: Click on "Hide" cy.contains('Hide').click({force: true}) cy.get('.octo-board-hidden-item').contains('Group 1').should('exist') cy.get('.KanbanColumnHeader .Editable[value=\'Group 1\']').should('not.exist') // Step 4: Click "Group 1", then click "Show" in the dropdown cy.contains('Group 1').click({force: true}) cy.contains('Show').click({force: true}) cy.get('.octo-board-hidden-item').contains('Group 1').should('not.exist') cy.get('.KanbanColumnHeader .Editable[value=\'Group 1\']').should('exist') }) }) ================================================ FILE: webapp/cypress/plugins/index.js ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /// // *********************************************************** // This example plugins/index.js can be used to load plugins // // You can change the location of this file or turn off loading // the plugins file with the 'pluginsFile' configuration option. // // You can read more here: // https://on.cypress.io/plugins-guide // *********************************************************** // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) /** * @type {Cypress.PluginConfig} */ module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config on('task', { failed: require('cypress-failed-log/src/failed')(), }); }; ================================================ FILE: webapp/cypress/support/api_commands.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Board} from '../../src/blocks/board' import {UserConfigPatch} from '../../src/user' import {versionProperty} from '../../src/store/users' Cypress.Commands.add('apiRegisterUser', (data: Cypress.UserData, token?: string, failOnError?: boolean) => { return cy.request({ method: 'POST', url: '/api/v2/register', body: { ...data, token, }, headers: { 'X-Requested-With': 'XMLHttpRequest', }, failOnStatusCode: failOnError, }) }) Cypress.Commands.add('apiLoginUser', (data: Cypress.LoginData) => { return cy.request({ method: 'POST', url: '/api/v2/login', body: { ...data, type: 'normal', }, headers: { 'X-Requested-With': 'XMLHttpRequest', }, }).then((response) => { expect(response.body).to.have.property('token') localStorage.setItem('focalboardSessionId', response.body.token) }) }) const headers = () => ({ headers: { 'X-Requested-With': 'XMLHttpRequest', Authorization: `Bearer ${localStorage.getItem('focalboardSessionId')}`, }, }) Cypress.Commands.add('apiInitServer', () => { const data: Cypress.UserData = { username: Cypress.env('username'), password: Cypress.env('password'), email: Cypress.env('email'), } return cy.apiRegisterUser(data, '', false).apiLoginUser(data) }) Cypress.Commands.add('apiDeleteBoard', (id: string) => { return cy.request({ method: 'DELETE', url: `/api/v2/boards/${encodeURIComponent(id)}`, ...headers(), }) }) const deleteBoards = (ids: string[]) => { if (ids.length === 0) { return } const [id, ...other] = ids cy.apiDeleteBoard(id).then(() => deleteBoards(other)) } Cypress.Commands.add('apiResetBoards', () => { return cy.request({ method: 'GET', url: '/api/v2/teams/0/boards', ...headers(), }).then((response) => { if (Array.isArray(response.body)) { const boards = response.body as Board[] const toDelete = boards.filter((b) => !b.isTemplate).map((b) => b.id) deleteBoards(toDelete) } }) }) Cypress.Commands.add('apiSkipTour', (userID: string) => { const body: UserConfigPatch = { updatedFields: { welcomePageViewed: '1', [versionProperty]: 'true', }, } return cy.request({ method: 'PUT', url: `/api/v2/users/${encodeURIComponent(userID)}/config`, ...headers(), body, }) }) Cypress.Commands.add('apiGetMe', () => { return cy.request({ method: 'GET', url: '/api/v2/users/me', ...headers(), }).then((response) => response.body.id) }) Cypress.Commands.add('apiChangePassword', (userId: string, oldPassword: string, newPassword: string) => { const body = {oldPassword, newPassword} return cy.request({ method: 'POST', url: `/api/v2/users/${encodeURIComponent(userId)}/changepassword`, ...headers(), body, }) }) Cypress.Commands.add('uiCreateNewBoard', (title?: string) => { cy.log('**Create new empty board**') cy.uiCreateEmptyBoard() cy.findByPlaceholderText('Untitled board').should('exist') cy.wait(10) if (title) { cy.log('**Rename board**') cy.findByPlaceholderText('Untitled board').type(`${title}{enter}`) cy.findByRole('textbox', {name: title}).should('exist') } cy.wait(500) }) Cypress.Commands.add('uiAddNewGroup', (name?: string) => { cy.log('**Add a new group**') cy.findByRole('button', {name: '+ Add a group'}).click() cy.findByRole('textbox', {name: 'New group'}).should('exist') if (name) { cy.log('**Rename group**') cy.findByRole('textbox', {name: 'New group'}).type(`{selectall}${name}{enter}`) cy.findByRole('textbox', {name}).should('exist') } cy.wait(500) }) Cypress.Commands.add('uiAddNewCard', (title?: string, columnIndex?: number) => { cy.log('**Add a new card**') cy.findByRole('button', {name: '+ New'}).eq(columnIndex || 0).click() cy.findByRole('dialog').should('exist') if (title) { cy.log('**Change card title**') cy.findByPlaceholderText('Untitled').type(title) } }) ================================================ FILE: webapp/cypress/support/index.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import '@testing-library/cypress/add-commands' import 'cypress-real-events/support' import './api_commands' import './ui_commands' import 'cypress-failed-log' ================================================ FILE: webapp/cypress/support/ui_commands.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable cypress/no-unnecessary-waiting */ Cypress.Commands.add('uiCreateBoard', (item: string) => { cy.log(`Create new board: ${item}`) cy.contains('+ Add board').should('be.visible').click() cy.contains(item).click() cy.contains('Use this template').click({force: true}).wait(1000) }) Cypress.Commands.add('uiCreateEmptyBoard', () => { cy.log('Create new empty board') cy.contains('+ Add board').should('be.visible').click().wait(500) return cy.contains('Create an empty board').click({force: true}).wait(1000) }) ================================================ FILE: webapp/cypress/tsconfig.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, "types": [ "cypress", "cypress-real-events", "@testing-library/cypress" ] }, "include": [ "**/*.ts" ] } ================================================ FILE: webapp/cypress.json ================================================ { "chromeWebSecurity": false, "baseUrl": "http://localhost:8088", "testFiles": [ "**/login*.ts", "**/create*.ts", "**/manage*.ts", "**/group*.ts", "**/card*.ts" ], "env": { "username": "test-user", "password": "test-password", "email": "test@mail.com" }, "video": false, "viewportWidth": 1600, "viewportHeight": 1200 } ================================================ FILE: webapp/html-templates/deveditor.ejs ================================================ <%= htmlWebpackPlugin.options.title %>
================================================ FILE: webapp/html-templates/page.ejs ================================================ <%= htmlWebpackPlugin.options.title %>
================================================ FILE: webapp/i18n/ar.json ================================================ { "AppBar.Tooltip": "اخيار الالواح المرتبطة", "Attachment.Attachment-title": "المرفق", "AttachmentBlock.DeleteAction": "حذف", "AttachmentBlock.addElement": "اضافة {type}", "AttachmentBlock.delete": "تم حذف المرفق.", "AttachmentBlock.failed": "تعذر تحميل هذا الملف حيث تم الوصول إلى الحد الأقصى لحجم الملفات.", "AttachmentBlock.upload": "يتم الآن تحميل المرفق.", "AttachmentBlock.uploadSuccess": "تم تحميل المرفق.", "AttachmentElement.delete-confirmation-dialog-button-text": "حذف", "AttachmentElement.download": "تحميل", "AttachmentElement.upload-percentage": "جاري التحمي... ({uploadPercent}%)", "BoardComponent.add-a-group": "+ إضافة مجموعة", "BoardComponent.delete": "حذف", "BoardComponent.hidden-columns": "الأعمدة المخفية", "BoardComponent.hide": "إخفاء", "BoardComponent.new": "+ جديد", "BoardComponent.no-property": "لا يوجد {property}", "BoardComponent.no-property-title": "يستخدم هذا العمود للعناصر بلا خواص{property} مبينة . و لا يمكن ازالته.", "BoardComponent.show": "عرض", "BoardMember.schemeAdmin": "المسؤول", "BoardMember.schemeCommenter": "المعلق", "BoardMember.schemeEditor": "محرر", "BoardMember.schemeNone": "لا شيء", "BoardMember.schemeViewer": "مشاهد", "BoardMember.unlinkChannel": "إلغاء الربط", "BoardPage.newVersion": "يتوفر نسخة جديدة من الألواح. اضغط هنا للتحميل.", "BoardPage.syncFailed": "تعذر الوصول إلي اللوح , ربما يكون محذوفا .", "BoardTemplateSelector.add-template": "قالب جديد", "BoardTemplateSelector.create-empty-board": "أنشيء لوح فارغ", "BoardTemplateSelector.delete-template": "حذف", "BoardTemplateSelector.description": "اضافة لوح جديد للقائمة الجانبية يمكنك استخدام اي قالب من القوالب المتاحة او انشا ء لوح جديد.", "BoardTemplateSelector.edit-template": "تحرير", "BoardTemplateSelector.plugin.no-content-description": "اضافة لوح جديد للقائمة الجانبية يمكنك استخدام اي قالب من القوالب المتاحة او انشا ء لوح جديد.", "BoardTemplateSelector.plugin.no-content-title": "لوح جديد", "BoardTemplateSelector.title": "لوح جديد", "BoardTemplateSelector.use-this-template": "استخدم هذا القالب", "BoardsSwitcher.Title": "بحث في الالواح", "BoardsUnfurl.Limited": "تعزر اظهار بيانات اخرى بسبب ارشفة الكارت", "BoardsUnfurl.Remainder": "{remainder}+ المزيد", "BoardsUnfurl.Updated": "تم التحديث {time}", "Calculations.Options.average.displayName": "متوسط", "Calculations.Options.average.label": "متوسط", "Calculations.Options.count.displayName": "الاجمالي", "Calculations.Options.count.label": "الاجمالي", "Calculations.Options.countChecked.displayName": "تم التدقيق", "Calculations.Options.countChecked.label": "اجمالي ما تم تدقيقه", "Calculations.Options.countUnchecked.displayName": "لم يتم تدقيقه", "Calculations.Options.countUnchecked.label": "اجمالي ما تم تدقيقه", "Calculations.Options.countUniqueValue.displayName": "فريد", "Calculations.Options.countUniqueValue.label": "اجمالي القيم الفريدة", "Calculations.Options.countValue.displayName": "القيم", "Calculations.Options.countValue.label": "اجمالي القيم", "Calculations.Options.dateRange.displayName": "النطاق الزمني", "Calculations.Options.dateRange.label": "النطاق الزمني", "Calculations.Options.earliest.displayName": "الاقدم", "Calculations.Options.earliest.label": "الاقدم", "Calculations.Options.latest.displayName": "الأحدث", "Calculations.Options.latest.label": "الأحدث", "Calculations.Options.max.displayName": "الاقصى", "Calculations.Options.max.label": "الاقصى", "Calculations.Options.median.displayName": "المتوسط", "Calculations.Options.median.label": "المتوسط", "Calculations.Options.min.displayName": "الأدنى", "Calculations.Options.min.label": "الأدنى", "Calculations.Options.none.displayName": "احسب", "Calculations.Options.none.label": "لا شيء", "Calculations.Options.percentChecked.displayName": "تم التدقيق", "Calculations.Options.percentChecked.label": "نسبة ما تم تدقيقه", "Calculations.Options.percentUnchecked.displayName": "لم يتم تدقيقه", "Calculations.Options.percentUnchecked.label": "سبة ما لم يتم تدقيقه", "Calculations.Options.range.displayName": "النطاق الزمني", "Calculations.Options.range.label": "النطاق الزمني", "Calculations.Options.sum.displayName": "مجموع", "Calculations.Options.sum.label": "مجموع", "CalendarCard.untitled": "بدون اسم", "CardActionsMenu.copiedLink": "تم النسخ!", "CardActionsMenu.copyLink": "نسخ الرابط", "CardActionsMenu.delete": "حذف", "CardActionsMenu.duplicate": "تكرار", "CardBadges.title-checkboxes": "تم اخيتاره", "CardBadges.title-comments": "التعليقات", "CardBadges.title-description": "البطاقة لها تفاصيل", "CardDetail.Follow": "يتبع", "CardDetail.Following": "التالية", "CardDetail.add-content": "إضافة محتوى", "CardDetail.add-icon": "إضافة أيقونة", "CardDetail.add-property": "اضافة خاصية", "CardDetail.addCardText": "اضافة محوى البطاقة", "CardDetail.limited-body": "تحتاج رخصة Professional او Enterprise لعرض البطاقات المؤرشفة , حدث الآن و تمتع بعدد غير محدود من الالواح و البطاقات و المزيد.", "CardDetail.limited-button": "الترقية", "CardDetail.limited-title": "هذه البطاقة مخفية", "CardDetail.moveContent": "نقل بيانات البطاقة", "CardDetail.new-comment-placeholder": "إضافة تعليق…", "CardDetailProperty.confirm-delete-heading": "سيتم حذف هذه الخاصية", "CardDetailProperty.confirm-delete-subtext": "هل تريد حذف هذه الخاصية {propertyName}؟ حذف سيتم علي جميع البطاقات في هذا اللوح.", "CardDetailProperty.confirm-property-name-change-subtext": "هل تريد تعديل هذه الخاصية \"{propertyName}\" {customText}؟ حذف سيتم علي {numOfCards} من البطاقات في هذا اللوح, و قد يتسبب في خسارة بعض البيانات.", "CardDetailProperty.confirm-property-type-change": "سيتم تعديل نوع الخاصية", "CardDetailProperty.delete-action-button": "حذف", "CardDetailProperty.property-change-action-button": "تغيير نوع الخاصية", "CardDetailProperty.property-changed": "تم تعديل الخاصية بنجاح!", "CardDetailProperty.property-deleted": "تم حذف {propertyName} بنجاح!", "CardDetailProperty.property-name-change-subtext": "النوع من \"{oldPropType}\" إلى \"{newPropType}\"", "CardDetial.limited-link": "لمعرفة المزيد حول اسعارنا.", "CardDialog.delete-confirmation-dialog-button-text": "حذف", "CardDialog.delete-confirmation-dialog-heading": "سيتم حذف البطاقة!", "CardDialog.editing-template": "انت الآن تقوم بتعديل قالب.", "CardDialog.nocard": "تعذر الوصول للبطاقة او بطاقة غير موجودة.", "Categories.CreateCategoryDialog.CancelText": "إلغاء", "Categories.CreateCategoryDialog.CreateText": "إنشاء", "Categories.CreateCategoryDialog.Placeholder": "عرف فئتك", "Categories.CreateCategoryDialog.UpdateText": "تحديث", "CenterPanel.Login": "تسجيل الدخول", "CenterPanel.Share": "مشاركة", "ColorOption.selectColor": "اختر {color} اللون", "Comment.delete": "حذف", "CommentsList.send": "إرسال", "ConfirmationDialog.cancel-action": "إلغاء", "ConfirmationDialog.confirm-action": "تأكيد", "ContentBlock.Delete": "حذف", "ContentBlock.DeleteAction": "حذف", "ContentBlock.addElement": "إضافة {type}", "ContentBlock.checkbox": "اختيار", "ContentBlock.divider": "فاصل", "ContentBlock.editCardCheckbox": "مختار", "ContentBlock.editCardCheckboxText": "تحرير محوى البطاقة", "ContentBlock.editCardText": "تحرير محوى البطاقة", "ContentBlock.editText": "تحرير المحتوى...", "ContentBlock.image": "صورة", "ContentBlock.insertAbove": "إدراج بالأعلى", "ContentBlock.moveDown": "ازاحة للأسفل", "ContentBlock.moveUp": "إزاحة للأعلى", "ContentBlock.text": "نص", "DateRange.clear": "مسح", "DateRange.empty": "فارغ", "DateRange.endDate": "تاريخ الإنتهاء", "DateRange.today": "اليوم", "DeleteBoardDialog.confirm-cancel": "إلغاء", "DeleteBoardDialog.confirm-delete": "حذف", "DeleteBoardDialog.confirm-info": "هل تريد حذف اللوح \"{boardTitle}\"؟ سيتم حذف جميع البطاقات داخل اللوح.", "DeleteBoardDialog.confirm-info-template": "هل تريد حذف قالب اللوح \"{boardTitle}\"؟", "DeleteBoardDialog.confirm-tite": "نعم احذف اللوح", "DeleteBoardDialog.confirm-tite-template": "نعم احذف قالب اللوح", "Dialog.closeDialog": "اغلق", "EditableDayPicker.today": "اليوم", "Error.websocket-closed": "مشكلة في الاتصال بالخادم, برجاء التأكد من الشبكة . أو تأكد من إعدادات الخادم او الوكيل.", "Filter.contains": "يحتوي على", "Filter.ends-with": "ينتهي بـ", "Filter.includes": "يحتوي على", "Filter.is": "يطابق", "Filter.is-empty": "فارغ", "Filter.is-not-empty": "ليس فارغًا", "Filter.is-not-set": "بدون تحديد", "Filter.is-set": "معين", "Filter.not-contains": "لا يحتوى على", "Filter.not-ends-with": "لا ينتهى بـ", "Filter.not-includes": "لا تشمل", "Filter.not-starts-with": "لا تبدأ بـ", "Filter.starts-with": "تبدأ بـ", "FilterByText.placeholder": "فرز", "FilterComponent.add-filter": "+ إضافة فلتر", "FilterComponent.delete": "حذف", "FindBoardsDialog.IntroText": "البحث في الألواح", "FindBoardsDialog.NoResultsFor": "لا يوجد نتيجة للبحث \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "اختر بحث آخر أو تأكد من الأخطاء الإملائية.", "FindBoardsDialog.Title": "البحث عن ألواح", "GroupBy.ungroup": "إلغاء التجميع", "KanbanCard.untitled": "بدون عنوان", "Mutator.new-card-from-template": "بطاقة جديدة من نموذج", "Mutator.new-template-from-card": "نموذج جديد من بطاقة", "OnboardingTour.AddComments.Title": "إضافة تعليقات", "OnboardingTour.AddDescription.Title": "اضافة وصف", "OnboardingTour.AddProperties.Title": "إضافة خواص", "OnboardingTour.AddView.Body": "انتقل هنا لإنشاء عرض جديد لتنظيم لوحتك باستخدام تخطيطات مختلفة.", "OnboardingTour.AddView.Title": "إضافة عرض جديد", "OnboardingTour.CopyLink.Title": "نسخ الرابط", "PropertyMenu.Delete": "حذف", "PropertyMenu.changeType": "تغيير نوع الخاصية", "PropertyMenu.selectType": "اختيار نوع الخاصية", "PropertyMenu.typeTitle": "النوع", "PropertyType.CreatedBy": "تم الإنشاء بواسطة", "PropertyType.Date": "التاريخ", "PropertyType.Email": "البريد الإلكتروني", "PropertyType.Number": "رقم", "PropertyType.Person": "شخص", "PropertyType.Text": "نص", "RegistrationLink.copiedLink": "منسوخ!", "RegistrationLink.copyLink": "انسخ الرابط", "ShareBoard.copiedLink": "منسوخ!", "ShareBoard.copyLink": "انسخ الرابط", "Sidebar.about": "عن Focalboard", "Sidebar.changePassword": "تغيير الكلمة السرية", "Sidebar.invite-users": "دعوة المستخدمين", "Sidebar.logout": "الخروج", "Sidebar.set-language": "ضبط اللغة", "Sidebar.settings": "الإعدادت", "TableComponent.add-icon": "إضافة أيقونة", "TableComponent.name": "الإسم", "TableComponent.plus-new": "+ جديد", "TableHeaderMenu.delete": "حذف", "TableHeaderMenu.hide": "إخفاء", "TableRow.open": "افتح", "View.Table": "جدول", "ViewHeader.add-template": "نموذج جديد", "ViewHeader.delete-template": "حذف", "ViewHeader.new": "جديز", "ViewTitle.pick-icon": "اختر أيقونة", "default-properties.badges": "التعليقات و التفاصيل", "default-properties.title": "العنوان", "error.back-to-home": "الرجوع للواجهة الأساسية", "error.back-to-team": "الرجوع إلي الفريق", "error.board-not-found": "لوح غير موجود.", "error.go-login": "تسجيل الدخول", "login.log-in-button": "لِج", "login.log-in-title": "لِج" } ================================================ FILE: webapp/i18n/ars.json ================================================ {} ================================================ FILE: webapp/i18n/ca.json ================================================ { "Attachment.Attachment-title": "Adjunt", "AttachmentBlock.DeleteAction": "esborra", "AttachmentBlock.addElement": "afegir {type}", "AttachmentBlock.delete": "Adjunt esborrat.", "AttachmentBlock.failed": "Aquest fitxer no pot ser afegit ja que el límit de tamany de fitxer ha estat assolit.", "AttachmentBlock.upload": "Adjunt afegint-se.", "AttachmentBlock.uploadSuccess": "Adjunt afegit.", "AttachmentElement.delete-confirmation-dialog-button-text": "Esborra", "AttachmentElement.download": "Descarrega", "AttachmentElement.upload-percentage": "Afegint...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Afegir un grup", "BoardComponent.delete": "Eliminar", "BoardComponent.hidden-columns": "Columnes ocultes", "BoardComponent.hide": "Amagar", "BoardComponent.new": "+ Nou", "BoardComponent.no-property": "Sense {property}", "BoardComponent.no-property-title": "Els elements amb una propietat {property} buida anirán aquí. Aquesta col·lumna no pot eliminar-se.", "BoardComponent.show": "Mostrar", "BoardMember.schemeAdmin": "Admin", "BoardMember.schemeCommenter": "Comentarista", "BoardMember.schemeEditor": "Editor", "BoardMember.schemeNone": "Cap", "BoardMember.schemeViewer": "Visualitzador", "BoardPage.newVersion": "Una nova versió de Boards és disponible, clica aquí per recarregar.", "BoardPage.syncFailed": "El tauler podria ser eliminat o revocat l'accés.", "BoardTemplateSelector.add-template": "Crea una nova plantilla", "BoardTemplateSelector.create-empty-board": "Crea un taulell buit", "BoardTemplateSelector.delete-template": "Esborra", "BoardTemplateSelector.description": "Afegeix el taulell a la barra lateral usant alguna de les plantilles definides a sota o comença des de zero.", "BoardTemplateSelector.edit-template": "Edita", "BoardTemplateSelector.plugin.no-content-description": "Afegeix el taulell a la barra lateral usant alguna de les plantilles definides a sota o comença des de zero.", "BoardTemplateSelector.plugin.no-content-title": "Crea un taulell", "BoardTemplateSelector.title": "Crea un taulell", "BoardTemplateSelector.use-this-template": "Utilitza aquesta plantilla", "BoardsSwitcher.Title": "Busca taulells", "BoardsUnfurl.Updated": "Actualitzat {time}", "Calculations.Options.average.displayName": "Promig", "Calculations.Options.average.label": "Promig", "Calculations.Options.countChecked.displayName": "Comprovat", "Calculations.Options.countUniqueValue.displayName": "Únic", "Calculations.Options.countUniqueValue.label": "Compta valors únics", "Calculations.Options.countValue.displayName": "Valors", "Calculations.Options.dateRange.displayName": "Rang", "Calculations.Options.dateRange.label": "Rang", "Calculations.Options.earliest.displayName": "Proper", "Calculations.Options.earliest.label": "Proper", "Calculations.Options.latest.displayName": "Últim", "Calculations.Options.latest.label": "Últim", "Calculations.Options.max.displayName": "Màxim", "Calculations.Options.max.label": "Màxim", "Calculations.Options.min.displayName": "Mínim", "Calculations.Options.min.label": "Mínim", "Calculations.Options.none.displayName": "Calcula", "Calculations.Options.none.label": "Cap", "Calculations.Options.percentChecked.displayName": "Completat", "Calculations.Options.percentChecked.label": "Percentatge completat", "Calculations.Options.percentUnchecked.displayName": "No finalitzat", "Calculations.Options.percentUnchecked.label": "Percentatge no finalitzat", "Calculations.Options.range.displayName": "Rang", "Calculations.Options.range.label": "Rang", "Calculations.Options.sum.displayName": "Suma", "Calculations.Options.sum.label": "Suma", "CalendarCard.untitled": "Sense títol", "CardActionsMenu.copiedLink": "Copiat!", "CardActionsMenu.copyLink": "Còpia l'enllaç", "CardActionsMenu.delete": "Esborra", "CardActionsMenu.duplicate": "Duplica", "CardBadges.title-comments": "Comentaris", "CardBadges.title-description": "Aquesta tarjeta té una descripció", "CardDetail.Attach": "Adjunta", "CardDetail.Follow": "Segueix", "CardDetail.Following": "Segueix", "CardDetail.add-content": "Afegeix contingut", "CardDetail.add-icon": "Afegeix icona", "CardDetail.add-property": "+ Afegeix propietat", "CardDetail.addCardText": "afegeix text a la targeta", "CardDetail.moveContent": "Mou el contingut de la targeta", "CardDetail.new-comment-placeholder": "Afegeix un comentari...", "CardDialog.editing-template": "Estas editant una plantilla.", "CardDialog.nocard": "Aquesta targeta no existeix o és innaccesible.", "Comment.delete": "Eliminar", "CommentsList.send": "Enviar", "ContentBlock.Delete": "Eliminar", "ContentBlock.DeleteAction": "eliminar", "ContentBlock.addElement": "afegeix {type}", "ContentBlock.checkbox": "casella de verificació", "ContentBlock.divider": "divisor", "ContentBlock.editCardCheckbox": "casella de verificació conmutada", "ContentBlock.editCardCheckboxText": "edita el contigut de la targeta", "ContentBlock.editCardText": "edita el text de la targeta", "ContentBlock.editText": "Edita el text...", "ContentBlock.image": "imatge", "ContentBlock.insertAbove": "Insereix damunt", "ContentBlock.moveDown": "Mou abaix", "ContentBlock.moveUp": "Mou adalt", "ContentBlock.text": "text", "Dialog.closeDialog": "Tanca la finestra", "EditableDayPicker.today": "Avui", "Filter.includes": "inclou", "Filter.is-empty": "esta buit", "Filter.is-not-empty": "no està buit", "Filter.not-includes": "no inclou", "FilterComponent.add-filter": "+ Afegeix filtre", "FilterComponent.delete": "Eliminar", "GroupBy.ungroup": "Desagrupar", "KanbanCard.untitled": "Sense títol", "Mutator.new-card-from-template": "nova targeta des de plantilla", "Mutator.new-template-from-card": "nova plantilla des de targeta", "PropertyMenu.Delete": "Eliminar", "PropertyMenu.changeType": "Canviar el tipus de propietat", "PropertyMenu.typeTitle": "Tipus", "PropertyType.Checkbox": "casella de verificació", "PropertyType.CreatedBy": "Creada per", "PropertyType.CreatedTime": "Moment de creació", "PropertyType.Date": "Data", "PropertyType.Email": "Correu electrònic", "PropertyType.MultiSelect": "Selecció múltiple", "PropertyType.Number": "Número", "PropertyType.Person": "Persona", "PropertyType.Phone": "Telèfon", "PropertyType.Select": "Selecciona", "PropertyType.Text": "Text", "PropertyType.UpdatedBy": "Última actualització feta per", "PropertyType.UpdatedTime": "Moment d'actualització", "RegistrationLink.confirmRegenerateToken": "Això invalidarà enllaços compartits anteriorment. Continuar?", "RegistrationLink.copiedLink": "Copiat!", "RegistrationLink.copyLink": "Copiar enllaç", "RegistrationLink.description": "Comparteix aquest enllaç per crear comptes per altres:", "RegistrationLink.regenerateToken": "Regenerar token", "RegistrationLink.tokenRegenerated": "Enllaç de registre regenerat", "ShareBoard.confirmRegenerateToken": "Això invalidarà enllaços compartits anteriorment. Continuar?", "ShareBoard.copiedLink": "Copiat!", "ShareBoard.copyLink": "Copiar enllaç", "ShareBoard.tokenRegenrated": "Token regenerat", "Sidebar.about": "Sobre Focalboard", "Sidebar.add-board": "+ Afegir tauler", "Sidebar.changePassword": "Canvi de contrasenya", "Sidebar.delete-board": "Eliminar el tauler", "Sidebar.export-archive": "Arxiu d'exportació", "Sidebar.import-archive": "Arxiu d'importació", "Sidebar.invite-users": "Convida usuaris", "Sidebar.logout": "Tanca sessió", "Sidebar.random-icons": "Icones aleatòries", "Sidebar.set-language": "Seleccionar idioma", "Sidebar.set-theme": "Definir un tema", "Sidebar.settings": "Paràmetres", "Sidebar.untitled-board": "(Tauler sense títol)", "TableComponent.add-icon": "Afegeix icona", "TableComponent.name": "Nom", "TableComponent.plus-new": "+ Nou", "TableHeaderMenu.delete": "Eliminar", "TableHeaderMenu.duplicate": "Duplicar", "TableHeaderMenu.hide": "Amagar", "TableHeaderMenu.insert-left": "Insereix a l'esquerra", "TableHeaderMenu.insert-right": "Insereix a la dreta", "TableHeaderMenu.sort-ascending": "Ordena ascendent", "TableHeaderMenu.sort-descending": "Ordena descendent", "TableRow.open": "Obrir", "View.AddView": "Afegeix vista", "View.Board": "Tauler", "View.DeleteView": "Eliminar vista", "View.DuplicateView": "Duplicar vista", "View.NewBoardTitle": "Vista de tauler", "View.NewCalendarTitle": "Vista de calendari", "View.NewGalleryTitle": "Vista de galeria", "View.NewTableTitle": "Vista de tauler", "View.Table": "Taula", "ViewHeader.add-template": "Nova plantilla", "ViewHeader.delete-template": "Eliminar", "ViewHeader.edit-template": "Editar", "ViewHeader.empty-card": "Targeta buida", "ViewHeader.export-complete": "Exportació completada!", "ViewHeader.export-csv": "Exportació a CSV", "ViewHeader.export-failed": "L'exportació ha fallat!", "ViewHeader.filter": "Filtrar", "ViewHeader.group-by": "Agrupar per: {property}", "ViewHeader.new": "Nou", "ViewHeader.properties": "Propietats", "ViewHeader.search-text": "Cerca tarjetes", "ViewHeader.select-a-template": "Selecciona una plantilla", "ViewHeader.sort": "Ordenar", "ViewHeader.untitled": "Sense títol", "ViewTitle.hide-description": "amagar descripció", "ViewTitle.pick-icon": "Seleccionar icona", "ViewTitle.random-icon": "Aleatori", "ViewTitle.remove-icon": "Eliminar icona", "ViewTitle.show-description": "mostra la descripció", "ViewTitle.untitled-board": "Tauler sense títol", "Workspace.editing-board-template": "Estàs editant una plantilla de tauler.", "default-properties.title": "Títol", "login.log-in-button": "Inicia sessió", "login.log-in-title": "Inicia sessió", "login.register-button": "o crea un compte si no en tens", "register.login-button": "o inicia sessió si ja tens un compte", "register.signup-title": "Registrat un compte" } ================================================ FILE: webapp/i18n/de.json ================================================ { "AdminBadge.SystemAdmin": "Administrator", "AdminBadge.TeamAdmin": "Teamadministrator", "AppBar.Tooltip": "Verknüpfte Boards umschalten", "Attachment.Attachment-title": "Anhang", "AttachmentBlock.DeleteAction": "Löschen", "AttachmentBlock.addElement": "{type} hinzufügen", "AttachmentBlock.delete": "Anhang gelöscht.", "AttachmentBlock.failed": "Kann Datei nicht hochladen, da das Limit für Dateigröße erreicht ist.", "AttachmentBlock.upload": "Anhang wird hochgeladen.", "AttachmentBlock.uploadSuccess": "Anhang hochgeladen.", "AttachmentElement.delete-confirmation-dialog-button-text": "Löschen", "AttachmentElement.download": "Herunterladen", "AttachmentElement.upload-percentage": "Hochladen...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Hinzufügen einer Gruppe", "BoardComponent.delete": "Löschen", "BoardComponent.hidden-columns": "Versteckte Spalten", "BoardComponent.hide": "Ausblenden", "BoardComponent.new": "+ Neu", "BoardComponent.no-property": "Keine {property}", "BoardComponent.no-property-title": "Elemente mit einer leeren {property} Eigenschaft erscheinen hier. Diese Spalte kann nicht entfernt werden.", "BoardComponent.show": "Anzeigen", "BoardMember.schemeAdmin": "Administrator", "BoardMember.schemeCommenter": "Kommentator", "BoardMember.schemeEditor": "Bearbeiter", "BoardMember.schemeNone": "Keine", "BoardMember.schemeViewer": "Leser", "BoardMember.unlinkChannel": "Verknüpfung aufheben", "BoardPage.newVersion": "Eine neue Version von Boards ist verfügbar, klicke hier, um neu zu laden.", "BoardPage.syncFailed": "Das Board kann gelöscht oder der Zugang entzogen werden.", "BoardTemplateSelector.add-template": "Neue Vorlage erstellen", "BoardTemplateSelector.create-empty-board": "Leeres Board erstellen", "BoardTemplateSelector.delete-template": "Löschen", "BoardTemplateSelector.description": "Füge ein Board hinzu, indem du eine der unten definierten Vorlagen verwendest oder ganz neu beginnst.", "BoardTemplateSelector.edit-template": "Bearbeiten", "BoardTemplateSelector.plugin.no-content-description": "Füge ein Board zur Seitenleiste hinzu, indem du eine der Vorlagen unten verwendest oder starte mit einem leeren Board.", "BoardTemplateSelector.plugin.no-content-title": "Erstelle ein Board", "BoardTemplateSelector.title": "Erstelle ein Board", "BoardTemplateSelector.use-this-template": "Verwende diese Vorlage", "BoardsSwitcher.Title": "Finde Boards", "BoardsUnfurl.Limited": "Weitere Details sind versteckt, da die Karte archiviert wurde", "BoardsUnfurl.Remainder": "+{remainder} mehr", "BoardsUnfurl.Updated": "Aktualisiert {time}", "Calculations.Options.average.displayName": "Durchschnitt", "Calculations.Options.average.label": "Durchschnitt", "Calculations.Options.count.displayName": "Zählen", "Calculations.Options.count.label": "Zählen", "Calculations.Options.countChecked.displayName": "Geprüft", "Calculations.Options.countChecked.label": "Zähle Markierte", "Calculations.Options.countUnchecked.displayName": "Ungeprüft", "Calculations.Options.countUnchecked.label": "Zähle Unmarkierte", "Calculations.Options.countUniqueValue.displayName": "Eindeutig", "Calculations.Options.countUniqueValue.label": "Zähle eindeutige Werte", "Calculations.Options.countValue.displayName": "Werte", "Calculations.Options.countValue.label": "Zähle Wert", "Calculations.Options.dateRange.displayName": "Bereich", "Calculations.Options.dateRange.label": "Bereich", "Calculations.Options.earliest.displayName": "Früheste", "Calculations.Options.earliest.label": "Früheste", "Calculations.Options.latest.displayName": "Neueste", "Calculations.Options.latest.label": "Neueste", "Calculations.Options.max.displayName": "Max", "Calculations.Options.max.label": "Max", "Calculations.Options.median.displayName": "Median", "Calculations.Options.median.label": "Median", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Berechnen", "Calculations.Options.none.label": "Keine", "Calculations.Options.percentChecked.displayName": "Geprüft", "Calculations.Options.percentChecked.label": "Prozentsatz Markiert", "Calculations.Options.percentUnchecked.displayName": "Ungeprüft", "Calculations.Options.percentUnchecked.label": "Prozentsatz Unmarkiert", "Calculations.Options.range.displayName": "Bereich", "Calculations.Options.range.label": "Bereich", "Calculations.Options.sum.displayName": "Summe", "Calculations.Options.sum.label": "Summe", "CalendarCard.untitled": "Ohne Titel", "CardActionsMenu.copiedLink": "Kopiert!", "CardActionsMenu.copyLink": "Verknüpfung kopieren", "CardActionsMenu.delete": "Löschen", "CardActionsMenu.duplicate": "Duplizieren", "CardBadges.title-checkboxes": "Checkboxen", "CardBadges.title-comments": "Kommentare", "CardBadges.title-description": "Diese Karte hat eine Beschreibung", "CardDetail.Attach": "Anhängen", "CardDetail.Follow": "Folgen", "CardDetail.Following": "Folgend", "CardDetail.add-content": "Inhalt hinzufügen", "CardDetail.add-icon": "Symbol hinzufügen", "CardDetail.add-property": "+ Eigenschaft hinzufügen", "CardDetail.addCardText": "Kartentext hinzufügen", "CardDetail.limited-body": "Aktualisiere auf unseren Professional oder Enterprise Plan.", "CardDetail.limited-button": "Aktualisiere", "CardDetail.limited-title": "Diese Karte ist versteckt", "CardDetail.moveContent": "Karteninhalt verschieben", "CardDetail.new-comment-placeholder": "Kommentar hinzufügen...", "CardDetailProperty.confirm-delete-heading": "Eigenschaft löschen bestätigen", "CardDetailProperty.confirm-delete-subtext": "Bist du sicher, dass du die Eigenschaft \"{propertyName}\" löschen möchtest? Wenn du diese löscht, wird die Eigenschaft von allen Karten in diesem Board gelöscht.", "CardDetailProperty.confirm-property-name-change-subtext": "Bist du sicher, dass du die Eigenschaft \"{propertyName}\" {customText} ändern möchtest? Dies wird Werte auf {numOfCards} Karten in diesem Board ändern und kann dazu führen, dass diese verloren gehen.", "CardDetailProperty.confirm-property-type-change": "Bestätige Eigenschaftsänderung", "CardDetailProperty.delete-action-button": "Löschen", "CardDetailProperty.property-change-action-button": "Ändere Eigenschaft", "CardDetailProperty.property-changed": "Eigenschaft erfolgreich geändert!", "CardDetailProperty.property-deleted": "{propertyName} erfolgreich gelöscht!", "CardDetailProperty.property-name-change-subtext": "Typ von \"{oldPropType}\" zu \"{newPropType}\"", "CardDetial.limited-link": "Erfahre mehr über unsere Pläne.", "CardDialog.delete-confirmation-dialog-attachment": "Löschen des Anhangs bestätigen", "CardDialog.delete-confirmation-dialog-button-text": "Löschen", "CardDialog.delete-confirmation-dialog-heading": "Karte löschen bestätigen", "CardDialog.editing-template": "Du bearbeitest eine Vorlage.", "CardDialog.nocard": "Diese Karte existiert nicht oder ist nicht verfügbar.", "Categories.CreateCategoryDialog.CancelText": "Abbrechen", "Categories.CreateCategoryDialog.CreateText": "Erstellen", "Categories.CreateCategoryDialog.Placeholder": "Benenne deine Kategorie", "Categories.CreateCategoryDialog.UpdateText": "Aktualisieren", "CenterPanel.Login": "Anmeldung", "CenterPanel.Share": "Teilen", "ChannelIntro.CreateBoard": "Erstelle ein Board", "ColorOption.selectColor": "Wähle Farbe {color}", "Comment.delete": "Löschen", "CommentsList.send": "Abschicken", "ConfirmPerson.empty": "Leer", "ConfirmPerson.search": "Suche...", "ConfirmationDialog.cancel-action": "Abbrechen", "ConfirmationDialog.confirm-action": "Bestätigen", "ContentBlock.Delete": "Löschen", "ContentBlock.DeleteAction": "löschen", "ContentBlock.addElement": "{type} hinzufügen", "ContentBlock.checkbox": "Checkbox", "ContentBlock.divider": "Teiler", "ContentBlock.editCardCheckbox": "Umschaltbare Checkbox", "ContentBlock.editCardCheckboxText": "Kartentext bearbeiten", "ContentBlock.editCardText": "Kartentext bearbeiten", "ContentBlock.editText": "Text bearbeiten ...", "ContentBlock.image": "Bild", "ContentBlock.insertAbove": "Darüber einfügen", "ContentBlock.moveBlock": "Karteninhalt verschieben", "ContentBlock.moveDown": "Nach unten bewegen", "ContentBlock.moveUp": "Nach oben bewegen", "ContentBlock.text": "Text", "DateFilter.empty": "Leer", "DateRange.clear": "Leeren", "DateRange.empty": "Leer", "DateRange.endDate": "Enddatum", "DateRange.today": "Heute", "DeleteBoardDialog.confirm-cancel": "Abbrechen", "DeleteBoardDialog.confirm-delete": "Löschen", "DeleteBoardDialog.confirm-info": "Bist du sicher, dass du das Board \"{boardTitle}\" löschen möchtest? Wenn du es löschen, werden allen Karten auf diesem Board gelöscht.", "DeleteBoardDialog.confirm-info-template": "Bist du sicher, dass du die Board-Vorlage \"{boardTitle}\" löschen willst?", "DeleteBoardDialog.confirm-tite": "Board löschen bestätigen", "DeleteBoardDialog.confirm-tite-template": "Board Vorlage wirklich löschen", "Dialog.closeDialog": "Dialog schließen", "EditableDayPicker.today": "Heute", "Error.mobileweb": "Die Unterstützung für das mobile Web befindet sich derzeit in einer frühen Betaphase. Möglicherweise sind nicht alle Funktionen vorhanden.", "Error.websocket-closed": "Websocket-Verbindung geschlossen, Verbindung unterbrochen. Wenn dieses Problem weiterhin besteht, überprüfe bitte die Konfiguration deines Servers oder Web-Proxys.", "Filter.contains": "enthält", "Filter.ends-with": "endet mit", "Filter.includes": "beinhaltet", "Filter.is": "ist", "Filter.is-after": "ist nach", "Filter.is-before": "ist vor", "Filter.is-empty": "ist leer", "Filter.is-not-empty": "ist nicht leer", "Filter.is-not-set": "ist nicht gesetzt", "Filter.is-set": "ist gesetzt", "Filter.isafter": "ist nach", "Filter.isbefore": "ist vor", "Filter.not-contains": "enthält nicht", "Filter.not-ends-with": "endet nicht mit", "Filter.not-includes": "beinhaltet nicht", "Filter.not-starts-with": "beginnt nicht mit", "Filter.starts-with": "beginnt mit", "FilterByText.placeholder": "Filtertext", "FilterComponent.add-filter": "+ Filter hinzufügen", "FilterComponent.delete": "Löschen", "FilterValue.empty": "(leer)", "FindBoardsDialog.IntroText": "Suche nach Boards", "FindBoardsDialog.NoResultsFor": "Keine Ergebnisse für \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Prüfe die Schreibweise oder versuche eine weitere Suche.", "FindBoardsDialog.SubTitle": "Tippe um ein Board zu finden. Benutze HOCH/RUNTER zum Browsen. ENTER zur Auswahl, ESC zum Schließen", "FindBoardsDialog.Title": "Finde Boards", "GroupBy.hideEmptyGroups": "Verstecke {count} leere Gruppen", "GroupBy.showHiddenGroups": "Zeige {count} versteckte Gruppen", "GroupBy.ungroup": "Gruppierung aufheben", "HideBoard.MenuOption": "Board verstecken", "KanbanCard.untitled": "Unbenannt", "MentionSuggestion.is-not-board-member": "(kein Board Mitglied)", "Mutator.new-board-from-template": "Neues Board aus Vorlage", "Mutator.new-card-from-template": "neue Karte aus Vorlage", "Mutator.new-template-from-card": "neue Vorlage aus Karte", "OnboardingTour.AddComments.Body": "Du kannst Themen kommentieren und sogar deine Mattermost-Kollegen @erwähnen, um deren Aufmerksamkeit zu erhalten.", "OnboardingTour.AddComments.Title": "Kommentare hinzufügen", "OnboardingTour.AddDescription.Body": "Füge deiner Karte eine Beschreibung hinzu, damit deine Teamkollegen wissen, worum es in der Karte geht.", "OnboardingTour.AddDescription.Title": "Beschreibung hinzufügen", "OnboardingTour.AddProperties.Body": "Füge den Karten verschiedene Eigenschaften hinzu, um sie noch leistungsfähiger zu machen.", "OnboardingTour.AddProperties.Title": "Eigenschaften hinzufügen", "OnboardingTour.AddView.Body": "Hier kannst Du eine neue Ansicht erstellen, um dein Board mit verschiedenen Layouts zu organisieren.", "OnboardingTour.AddView.Title": "Eine neue Ansicht hinzufügen", "OnboardingTour.CopyLink.Body": "Du kannst deine Karten mit Teamkollegen teilen, indem Du den Link kopierst und in einen Kanal, eine Direktnachricht oder eine Gruppennachricht einfügst.", "OnboardingTour.CopyLink.Title": "Link kopieren", "OnboardingTour.OpenACard.Body": "Öffne eine Karte und entdecke die Möglichkeiten, die Boards bei der Organisation deiner Arbeit bietet.", "OnboardingTour.OpenACard.Title": "Eine Karte öffnen", "OnboardingTour.ShareBoard.Body": "Du kannst dein Board intern, innerhalb deines Teams, freigeben oder es öffentlich veröffentlichen, damit es auch außerhalb deines Unternehmens sichtbar ist.", "OnboardingTour.ShareBoard.Title": "Board teilen", "PersonProperty.board-members": "Board Mitglieder", "PersonProperty.me": "Ich", "PersonProperty.non-board-members": "Keine Board Mitglieder", "PropertyMenu.Delete": "Löschen", "PropertyMenu.changeType": "Eigenschaftstyp ändern", "PropertyMenu.selectType": "Eigenschaftstyp auswählen", "PropertyMenu.typeTitle": "Art", "PropertyType.Checkbox": "Checkbox", "PropertyType.CreatedBy": "Erstellt von", "PropertyType.CreatedTime": "Erstellzeit", "PropertyType.Date": "Datum", "PropertyType.Email": "E-Mail", "PropertyType.MultiPerson": "Mehrere Personen", "PropertyType.MultiSelect": "Mehrfachauswahl", "PropertyType.Number": "Zahl", "PropertyType.Person": "Person", "PropertyType.Phone": "Telefon", "PropertyType.Select": "Auswählen", "PropertyType.Text": "Text", "PropertyType.Unknown": "Unbekannt", "PropertyType.UpdatedBy": "Aktualisiert durch", "PropertyType.UpdatedTime": "Letzte Aktualisierung", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Leer", "RegistrationLink.confirmRegenerateToken": "Diese Aktion widerruft zuvor geteilte Links. Trotzdem fortfahren?", "RegistrationLink.copiedLink": "Kopiert!", "RegistrationLink.copyLink": "Link kopieren", "RegistrationLink.description": "Teile diesen Link mit anderen zur Accounterstellung:", "RegistrationLink.regenerateToken": "Token neu generieren", "RegistrationLink.tokenRegenerated": "Registrierungslink neu generiert", "ShareBoard.PublishDescription": "Veröffentliche und teile einen \"Nur Lesen\"-Link mit jedem im Web.", "ShareBoard.PublishTitle": "Im Web veröffentlichen", "ShareBoard.ShareInternal": "Intern teilen", "ShareBoard.ShareInternalDescription": "Benutzer mit Berechtigungen können diesen Link benutzen.", "ShareBoard.Title": "Board teilen", "ShareBoard.confirmRegenerateToken": "Diese Aktion invalidiert zuvor geteilte Links. Trotzdem fortfahren?", "ShareBoard.copiedLink": "Kopiert!", "ShareBoard.copyLink": "Link kopieren", "ShareBoard.regenerate": "Token neu erstellen", "ShareBoard.searchPlaceholder": "Suche nach Personen und Kanälen", "ShareBoard.teamPermissionsText": "Jeder im {teamName} Team", "ShareBoard.tokenRegenrated": "Token neu generiert", "ShareBoard.userPermissionsRemoveMemberText": "Mitglied entfernen", "ShareBoard.userPermissionsYouText": "(Du)", "ShareTemplate.Title": "Vorlage teilen", "ShareTemplate.searchPlaceholder": "Benutzer suchen", "Sidebar.about": "Über Focalboard", "Sidebar.add-board": "+ Board hinzufügen", "Sidebar.changePassword": "Passwort ändern", "Sidebar.delete-board": "Board löschen", "Sidebar.duplicate-board": "Board kopieren", "Sidebar.export-archive": "Archiv exportieren", "Sidebar.import": "Importieren", "Sidebar.import-archive": "Archiv importieren", "Sidebar.invite-users": "Nutzer einladen", "Sidebar.logout": "Ausloggen", "Sidebar.new-category.badge": "Neu", "Sidebar.new-category.drag-boards-cta": "Board hierher ziehen...", "Sidebar.no-boards-in-category": "Keine Boards vorhanden", "Sidebar.product-tour": "Produkttour", "Sidebar.random-icons": "Zufällige Icons", "Sidebar.set-language": "Sprache übernehmen", "Sidebar.set-theme": "Theme übernehmen", "Sidebar.settings": "Einstellungen", "Sidebar.template-from-board": "Neue Vorlage aus Board", "Sidebar.untitled-board": "(Unbenanntes Board)", "Sidebar.untitled-view": "(Ansicht ohne Titel)", "SidebarCategories.BlocksMenu.Move": "Bewege nach...", "SidebarCategories.CategoryMenu.CreateNew": "Erstelle neue Kategorie", "SidebarCategories.CategoryMenu.Delete": "Lösche Kategorie", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Boards in {categoryName} werden zurück zu den Board-Kategorien bewegt. Du wirst von keinen Boards entfernt.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Diese Kategorie löschen?", "SidebarCategories.CategoryMenu.Update": "Kategorie umbenennen", "SidebarTour.ManageCategories.Body": "Erstelle und verwalte eigene Kategorien. Diese sind benutzer-spezifisch, daher beeinflusst das Verschieben eines Boards in deine Kategorie andere Mitglieder, die das gleiche Board nutzen, nicht.", "SidebarTour.ManageCategories.Title": "Verwalte Kategorien", "SidebarTour.SearchForBoards.Body": "Öffne den Board Wechsler /Cmd/Strg + K) um schnell Boards zu finden und zu deiner Seitenleiste hinzuzufügen.", "SidebarTour.SearchForBoards.Title": "Suche nach Boards", "SidebarTour.SidebarCategories.Body": "Alle deine Boards sind jetzt unter deiner neuen Seitenleiste organisiert. Kein Wechseln mehr zwischen Arbeitsbereichen. Eigene Kategorien werden einmalig auf Basis deiner bisherigen Arbeitsbereiche automatisch im Rahmen des Upgrades auf 7.2 erstellt. Diese können entfernt oder nach deinem Bedarf bearbeitet werden.", "SidebarTour.SidebarCategories.Link": "Erfahre mehr", "SidebarTour.SidebarCategories.Title": "Seitenleisten Kategorien", "SiteStats.total_boards": "Boards gesamt", "SiteStats.total_cards": "Karten gesamt", "TableComponent.add-icon": "Symbol hinzufügen", "TableComponent.name": "Name", "TableComponent.plus-new": "+ Neu", "TableHeaderMenu.delete": "Löschen", "TableHeaderMenu.duplicate": "Duplizieren", "TableHeaderMenu.hide": "Verstecken", "TableHeaderMenu.insert-left": "Links einfügen", "TableHeaderMenu.insert-right": "Rechts einfügen", "TableHeaderMenu.sort-ascending": "Aufsteigend sortieren", "TableHeaderMenu.sort-descending": "Absteigend sortieren", "TableRow.DuplicateCard": "Kopiere Karte", "TableRow.MoreOption": "Weitere Aktionen", "TableRow.open": "Öffnen", "TopBar.give-feedback": "Feedback geben", "URLProperty.copiedLink": "Kopiert!", "URLProperty.copy": "Kopieren", "URLProperty.edit": "Bearbeiten", "UndoRedoHotKeys.canRedo": "Wiederherstellen", "UndoRedoHotKeys.canRedo-with-description": "{description} wiederherstellen", "UndoRedoHotKeys.canUndo": "Rückgängig", "UndoRedoHotKeys.canUndo-with-description": "{description} rückgängig machen", "UndoRedoHotKeys.cannotRedo": "Nichts wiederherstellbar", "UndoRedoHotKeys.cannotUndo": "Nichts zum rückgängig machen", "ValueSelector.noOptions": "Keine Optionen. Fange an zu tippen, um die erste Option hinzuzufügen!", "ValueSelector.valueSelector": "Werteselektor", "ValueSelectorLabel.openMenu": "Menü öffnen", "VersionMessage.help": "Finde raus, was es Neues in dieser Version gibt.", "VersionMessage.learn-more": "Erfahre mehr", "View.AddView": "Ansicht hinzufügen", "View.Board": "Board", "View.DeleteView": "Ansicht löschen", "View.DuplicateView": "Ansicht duplizieren", "View.Gallery": "Galerie", "View.NewBoardTitle": "Boardansicht", "View.NewCalendarTitle": "Kalenderansicht", "View.NewGalleryTitle": "Galerie Ansicht", "View.NewTableTitle": "Tabellenansicht", "View.NewTemplateDefaultTitle": "Unbenannte Vorlage", "View.NewTemplateTitle": "Unbenannt", "View.Table": "Tabelle", "ViewHeader.add-template": "+ Neue Vorlage", "ViewHeader.delete-template": "Löschen", "ViewHeader.display-by": "Anzeige durch: {property}", "ViewHeader.edit-template": "Bearbeiten", "ViewHeader.empty-card": "Leere Karte", "ViewHeader.export-board-archive": "Board Archiv exportieren", "ViewHeader.export-complete": "Export abgeschlossen!", "ViewHeader.export-csv": "Als CSV exportieren", "ViewHeader.export-failed": "Export fehlgeschlagen!", "ViewHeader.filter": "Filter", "ViewHeader.group-by": "Gruppiere nach: {property}", "ViewHeader.new": "Neu", "ViewHeader.properties": "Eigenschaften", "ViewHeader.properties-menu": "Eigenschaften Menü", "ViewHeader.search-text": "Suche Karten", "ViewHeader.select-a-template": "Vorlage auswählen", "ViewHeader.set-default-template": "Als Standard eingestellt", "ViewHeader.sort": "Sortieren", "ViewHeader.untitled": "Unbenannt", "ViewHeader.view-header-menu": "Kopfmenü ansehen", "ViewHeader.view-menu": "Ansichten Menü", "ViewLimitDialog.Heading": "Ansichten pro Board Limit erreicht", "ViewLimitDialog.PrimaryButton.Title.Admin": "Aktualisieren", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Admin benachrichtigen", "ViewLimitDialog.Subtext.Admin": "Aktualisiere auf unseren Professional oder Enterprise Plan.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Erfahre mehr über unsere Pläne.", "ViewLimitDialog.Subtext.RegularUser": "Benachrichtige deinen Admin um auf unseren Professional oder Enterprise Plan zu aktualisieren.", "ViewLimitDialog.UpgradeImg.AltText": "Bild aktualisieren", "ViewLimitDialog.notifyAdmin.Success": "Dein Admin wurde benachrichtigt", "ViewTitle.hide-description": "Beschreibung ausblenden", "ViewTitle.pick-icon": "Symbol auswählen", "ViewTitle.random-icon": "Zufällig", "ViewTitle.remove-icon": "Symbol entfernen", "ViewTitle.show-description": "Beschreibung anzeigen", "ViewTitle.untitled-board": "Unbenanntes Board", "WelcomePage.Description": "Boards ist ein Projektmanagement-Tool, das die Definition, Organisation, Verfolgung und Verwaltung von Arbeit in verschiedenen Teams mit Hilfe einer vertrauten Kanban-Board-Ansicht unterstützt.", "WelcomePage.Explore.Button": "Rundgang", "WelcomePage.Heading": "Willkommen bei Boards", "WelcomePage.NoThanks.Text": "Nein danke, ich werde es selbst herausfinden", "WelcomePage.StartUsingIt.Text": "Verwende es", "Workspace.editing-board-template": "Du bearbeitest eine Board Vorlage.", "badge.guest": "Gast", "boardPage.confirm-join-button": "Teilnehmen", "boardPage.confirm-join-text": "Du bist dabei einem privaten Board zu betreten, ohne dass du explizit durch den Board-Administrator hinzugefügt wurdest. Bist du sicher, dass du diesem privaten Board beitreten willst?", "boardPage.confirm-join-title": "Privatem Board beitreten", "boardSelector.confirm-link-board": "Verknüpfe Board mit Kanal", "boardSelector.confirm-link-board-button": "Ja, verknüpfe Board", "boardSelector.confirm-link-board-subtext": "Wenn du \"{boardName}\" mit diesem Kanal verknüpfst, werden alle Mitglieder des Kanals (aktuelle und neue) das Board bearbeiten können. Dies schließt Mitglieder aus, die Gast sind. Du kannst die Verknüpfung eine Boards mit einem Kanal jederzeit entfernen.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Wenn du \"{boardName}\" mit dem Kanal verknüpfst, werden alle Mitglieder des Kanals (aktuelle und neue) das Board bearbeiten können. Dies schließt Mitglieder aus, die Gast sind.{lineBreak} Dieses Board ist mit einem anderen Kanal verknüpft. Die Verknüpfung wird getrennt, wenn du es hier verknüpfst.", "boardSelector.create-a-board": "Erstelle ein Board", "boardSelector.link": "Verknüpfung", "boardSelector.search-for-boards": "Suche nach Boards", "boardSelector.title": "Verknüpfe Boards", "boardSelector.unlink": "Verknüpfung aufheben", "calendar.month": "Monat", "calendar.today": "HEUTE", "calendar.week": "Woche", "centerPanel.undefined": "Kein(e) {propertyName}", "centerPanel.unknown-user": "Unbekannter Benutzer", "cloudMessage.learn-more": "Erfahre mehr", "createImageBlock.failed": "Kann Datei nicht hochladen, da das Limit für Dateigröße erreicht ist.", "default-properties.badges": "Kommentare und Beschreibung", "default-properties.title": "Titel", "error.back-to-home": "Zurück zur Startseite", "error.back-to-team": "Zurück zum Team", "error.board-not-found": "Board nicht gefunden.", "error.go-login": "Anmeldung", "error.invalid-read-only-board": "Du hast keine Zugriff auf dieses Board. Melde dich an um auf das Board zu zugreifen.", "error.not-logged-in": "Deine Sitzung könnte abgelaufen sein oder du bist nicht angemeldet. Melde dich nochmal an um auf das Board zuzugreifen.", "error.page.title": "Entschuldigung, etwas ist schief gelaufen", "error.team-undefined": "Kein gültiges Team.", "error.unknown": "Ein Fehler ist aufgetreten.", "generic.previous": "Zurück", "guest-no-board.subtitle": "Du hast noch keinen Zugang zu einem Board in diesem Team. Bitte warte, bis dich jemand zu einem Board hinzufügt.", "guest-no-board.title": "Noch keine Boards", "imagePaste.upload-failed": "Einige Dateien nicht hochgeladen, da das Limit für Dateigröße erreicht ist.", "limitedCard.title": "Versteckte Karten", "login.log-in-button": "Anmelden", "login.log-in-title": "Anmelden", "login.register-button": "oder erstelle einen Account wenn du noch keines hast", "new_channel_modal.create_board.empty_board_description": "Neues leeres Board erstellen", "new_channel_modal.create_board.empty_board_title": "Leeres Board", "new_channel_modal.create_board.select_template_placeholder": "Vorlage auswählen", "new_channel_modal.create_board.title": "Erstelle ein Board für diesen Kanal", "notification-box-card-limit-reached.close-tooltip": "Für 10 Tage schlummern", "notification-box-card-limit-reached.contact-link": "Benachrichtige deinen Admin", "notification-box-card-limit-reached.link": "Wechsel auf einen kostenpflichtigen Plan", "notification-box-card-limit-reached.title": "{cards} vom Board versteckte Karten", "notification-box-cards-hidden.title": "Diese Aktion verdeckt eine andere Karte", "notification-box.card-limit-reached.not-admin.text": "Um auf archivierte Karten zuzugreifen, kannst du {contactLink} um auf einen bezahlten Plan zu wechseln.", "notification-box.card-limit-reached.text": "Kartenlimit erreicht. Um ältere Karten zu betrachten, {link}", "person.add-user-to-board": "Füge {username} zum Board hinzu", "person.add-user-to-board-confirm-button": "Zum Board hinzufügen", "person.add-user-to-board-permissions": "Berechtigungen", "person.add-user-to-board-question": "Möchtest du {username} zum Board hinzufügen?", "person.add-user-to-board-warning": "{username} ist kein Mitglied des Boards und wird keine Benachrichtigungen darüber erhalten.", "register.login-button": "oder melde dich an, wenn du bereits ein Konto hast", "register.signup-title": "Registriere dich für deinen Account", "rhs-board-non-admin-msg": "Du bist kein Administrator des Boards", "rhs-boards.add": "Hinzufügen", "rhs-boards.dm": "DN", "rhs-boards.gm": "GN", "rhs-boards.header.dm": "diese Direktnachricht", "rhs-boards.header.gm": "diese Gruppennachricht", "rhs-boards.last-update-at": "Letzte Aktualisierung um: {datetime}", "rhs-boards.link-boards-to-channel": "Verknüpfe Board mit {channelName}", "rhs-boards.linked-boards": "Verknüpfte Boards", "rhs-boards.no-boards-linked-to-channel": "Bisher sind keine Boards mit {channelName} verknüpft", "rhs-boards.no-boards-linked-to-channel-description": "Boards ist ein Projektmanagement Werkzeug, das hilft Aufgaben über Teams hinweg zu definieren, organisieren, verfolgen und verwalten, ähnlich den bekannten Kanban Boards.", "rhs-boards.unlink-board": "Board Verknüpfung aufheben", "rhs-boards.unlink-board1": "Board Verknüpfung aufheben", "rhs-channel-boards-header.title": "Boards", "share-board.publish": "Veröffentlichen", "share-board.share": "Teilen", "shareBoard.channels-select-group": "Kanäle", "shareBoard.confirm-change-team-role.body": "Jeder in diesem Board, der eine niedrigere Berechtigung als die Rolle \"{role}\" hat, wird nun zu {role} befördert. Bist du sicher, dass du die Mindestrolle für das Board ändern willst?", "shareBoard.confirm-change-team-role.confirmBtnText": "Minimale Rolle des Boards ändern", "shareBoard.confirm-change-team-role.title": "Minimale Rolle des Boards ändern", "shareBoard.confirm-link-channel": "Verknüpfe Board mit Kanal", "shareBoard.confirm-link-channel-button": "Verknüpfe Kanal", "shareBoard.confirm-link-channel-button-with-other-channel": "Verknüpfung lösen und hier verknüpfen", "shareBoard.confirm-link-channel-subtext": "Wenn du einen Kanal mit einem Board verknüpfst, werden alle Mitglieder des Kanals (aktuelle und neue) das Board bearbeiten können. Dies schließt Mitglieder aus, die Gast sind.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Wenn du einen Kanal mit einem Board verknüpfst, werden alle Mitglieder des Kanals (aktuelle und neue) dies bearbeiten können. Dies schließt Mitglieder aus, die Gast sind.{lineBreak}Dieses Board ist aktuell mit einem anderen Kanal verknüpft. Die Verknüpfung wird aufgehoben, wenn du es hier verknüpfst.", "shareBoard.confirm-unlink.body": "Wenn du einen Kanal von einem Board trennst, werden alle Mitglieder des Kanals (aktuelle und neue) den Zugriff auf das Board verlieren außer die Berechtigungen wurden individuell vergeben.", "shareBoard.confirm-unlink.confirmBtnText": "Kanal trennen", "shareBoard.confirm-unlink.title": "Kanal vom Board trennen", "shareBoard.lastAdmin": "Boards müssen mindestens eine Administrator haben", "shareBoard.members-select-group": "Mitglieder", "shareBoard.unknown-channel-display-name": "Unbekannter Kanal", "tutorial_tip.finish_tour": "Erledigt", "tutorial_tip.got_it": "Alles klar", "tutorial_tip.ok": "Weiter", "tutorial_tip.out": "Diese Tipps nicht mehr anzeigen.", "tutorial_tip.seen": "Schon mal gesehen?" } ================================================ FILE: webapp/i18n/el.json ================================================ { "BoardComponent.add-a-group": "+ Προσθήκη ομάδας", "BoardComponent.delete": "Διαγραφή", "BoardComponent.hidden-columns": "Κρυφές στήλες", "BoardComponent.hide": "Απόκρυψη", "BoardComponent.new": "+ Νέο", "BoardComponent.show": "Εμφάνιση", "CardDetail.add-content": "Προσθήκη περιεχομένου", "CardDetail.add-icon": "Προσθήκη εικονιδίου", "CardDetail.new-comment-placeholder": "Προσθήκη σχολίου ...", "CardDialog.nocard": "Αυτή η κάρτα δεν υπάρχει ή δεν είναι προσβάσιμη", "Comment.delete": "Διαγραφή", "CommentsList.send": "Αποστολή", "ContentBlock.Delete": "Διαγραφή", "ContentBlock.DeleteAction": "διαγραφή", "ContentBlock.editText": "Επεξεργασία κειμένου ...", "ContentBlock.image": "εικόνα", "ContentBlock.insertAbove": "Εισαγωγή από πάνω", "ContentBlock.moveDown": "Μετακίνηση κάτω", "ContentBlock.moveUp": "Μετακίνηση επάνω", "ContentBlock.text": "κείμενο", "EditableDayPicker.today": "Σήμερα", "Filter.includes": "περιέχει", "Filter.is-empty": "είναι άδειο", "Filter.is-not-empty": "δεν είναι άδειο", "Filter.not-includes": "δεν περιέχει", "FilterComponent.add-filter": "+ Προσθήκη φίλτρου", "FilterComponent.delete": "Διαγραφή", "KanbanCard.untitled": "Χωρίς τίτλο", "Mutator.new-card-from-template": "νέα κάρτα από πρότυπο", "Mutator.new-template-from-card": "νέο πρότυπο από την κάρτα", "PropertyMenu.Delete": "Διαγραφή", "PropertyMenu.typeTitle": "Τύπος", "PropertyType.CreatedBy": "Δημιουργήθηκε από", "PropertyType.CreatedTime": "Χρόνος δημιουργίας", "PropertyType.Date": "Ημερομηνία", "PropertyType.Email": "Email", "PropertyType.Number": "Αριθμός", "PropertyType.Phone": "Τηλέφωνο", "PropertyType.Text": "Κείμενο", "PropertyType.UpdatedBy": "Ενημερώθηκε από", "PropertyType.UpdatedTime": "Ώρα Ενημέρωσης", "RegistrationLink.copyLink": "Αντιγραφή συνδέσμου", "RegistrationLink.description": "Μοιραστείτε αυτόν τον σύνδεσμο με άλλους για να δημιουργήσουν λογαριασμούς:", "ViewTitle.pick-icon": "Επιλογή εικονιδίου", "ViewTitle.random-icon": "Τυχαίο", "ViewTitle.remove-icon": "Αφαίρεση εικονιδίου", "default-properties.title": "Τίτλος" } ================================================ FILE: webapp/i18n/en.json ================================================ { "AdminBadge.SystemAdmin": "Admin", "AdminBadge.TeamAdmin": "Team Admin", "AppBar.Tooltip": "Toggle Linked Boards", "Attachment.Attachment-title": "Attachment", "AttachmentBlock.DeleteAction": "delete", "AttachmentBlock.addElement": "add {type}", "AttachmentBlock.delete": "Attachment deleted.", "AttachmentBlock.failed": "This file couldn't be uploaded as the file size limit has been reached.", "AttachmentBlock.upload": "Attachment uploading.", "AttachmentBlock.uploadSuccess": "Attachment uploaded.", "AttachmentElement.delete-confirmation-dialog-button-text": "Delete", "AttachmentElement.download": "Download", "AttachmentElement.upload-percentage": "Uploading...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Add a group", "BoardComponent.delete": "Delete", "BoardComponent.hidden-columns": "Hidden columns", "BoardComponent.hide": "Hide", "BoardComponent.new": "+ New", "BoardComponent.no-property": "No {property}", "BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column can't be removed.", "BoardComponent.show": "Show", "BoardMember.schemeAdmin": "Admin", "BoardMember.schemeCommenter": "Commenter", "BoardMember.schemeEditor": "Editor", "BoardMember.schemeNone": "None", "BoardMember.schemeViewer": "Viewer", "BoardMember.unlinkChannel": "Unlink", "BoardPage.newVersion": "A new version of Boards is available, click here to reload.", "BoardPage.syncFailed": "Board may be deleted or access revoked.", "BoardTemplateSelector.add-template": "Create new template", "BoardTemplateSelector.create-empty-board": "Create an empty board", "BoardTemplateSelector.delete-template": "Delete", "BoardTemplateSelector.description": "Add a board to the sidebar using any of the templates defined below or start from scratch.", "BoardTemplateSelector.edit-template": "Edit", "BoardTemplateSelector.plugin.no-content-description": "Add a board to the sidebar using any of the templates defined below or start from scratch.", "BoardTemplateSelector.plugin.no-content-title": "Create a board", "BoardTemplateSelector.title": "Create a board", "BoardTemplateSelector.use-this-template": "Use this template", "BoardsSwitcher.Title": "Find boards", "BoardsUnfurl.Limited": "Additional details are hidden due to the card being archived", "BoardsUnfurl.Remainder": "+{remainder} more", "BoardsUnfurl.Updated": "Updated {time}", "Calculations.Options.average.displayName": "Average", "Calculations.Options.average.label": "Average", "Calculations.Options.count.displayName": "Count", "Calculations.Options.count.label": "Count", "Calculations.Options.countChecked.displayName": "Checked", "Calculations.Options.countChecked.label": "Count checked", "Calculations.Options.countUnchecked.displayName": "Unchecked", "Calculations.Options.countUnchecked.label": "Count unchecked", "Calculations.Options.countUniqueValue.displayName": "Unique", "Calculations.Options.countUniqueValue.label": "Count unique values", "Calculations.Options.countValue.displayName": "Values", "Calculations.Options.countValue.label": "Count value", "Calculations.Options.dateRange.displayName": "Range", "Calculations.Options.dateRange.label": "Range", "Calculations.Options.earliest.displayName": "Earliest", "Calculations.Options.earliest.label": "Earliest", "Calculations.Options.latest.displayName": "Latest", "Calculations.Options.latest.label": "Latest", "Calculations.Options.max.displayName": "Max", "Calculations.Options.max.label": "Max", "Calculations.Options.median.displayName": "Median", "Calculations.Options.median.label": "Median", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Calculate", "Calculations.Options.none.label": "None", "Calculations.Options.percentChecked.displayName": "Checked", "Calculations.Options.percentChecked.label": "Percent checked", "Calculations.Options.percentUnchecked.displayName": "Unchecked", "Calculations.Options.percentUnchecked.label": "Percent unchecked", "Calculations.Options.range.displayName": "Range", "Calculations.Options.range.label": "Range", "Calculations.Options.sum.displayName": "Sum", "Calculations.Options.sum.label": "Sum", "CalendarCard.untitled": "Untitled", "CardActionsMenu.copiedLink": "Copied!", "CardActionsMenu.copyLink": "Copy link", "CardActionsMenu.delete": "Delete", "CardActionsMenu.duplicate": "Duplicate", "CardBadges.title-checkboxes": "Checkboxes", "CardBadges.title-comments": "Comments", "CardBadges.title-description": "This card has a description", "CardDetail.Attach": "Attach", "CardDetail.Follow": "Follow", "CardDetail.Following": "Following", "CardDetail.add-content": "Add content", "CardDetail.add-icon": "Add icon", "CardDetail.add-property": "+ Add a property", "CardDetail.addCardText": "add card text", "CardDetail.limited-body": "Upgrade to our Professional or Enterprise plan.", "CardDetail.limited-button": "Upgrade", "CardDetail.limited-title": "This card is hidden", "CardDetail.moveContent": "Move card content", "CardDetail.new-comment-placeholder": "Add a comment...", "CardDetailProperty.confirm-delete-heading": "Confirm delete property", "CardDetailProperty.confirm-delete-subtext": "Are you sure you want to delete the property \"{propertyName}\"? Deleting it will delete the property from all cards in this board.", "CardDetailProperty.confirm-property-name-change-subtext": "Are you sure you want to change property \"{propertyName}\" {customText}? This will affect value(s) across {numOfCards} card(s) in this board, and can result in data loss.", "CardDetailProperty.confirm-property-type-change": "Confirm property type change", "CardDetailProperty.delete-action-button": "Delete", "CardDetailProperty.property-change-action-button": "Change property", "CardDetailProperty.property-changed": "Changed property successfully!", "CardDetailProperty.property-deleted": "Deleted {propertyName} successfully!", "CardDetailProperty.property-name-change-subtext": "type from \"{oldPropType}\" to \"{newPropType}\"", "CardDetial.limited-link": "Learn more about our plans.", "CardDialog.delete-confirmation-dialog-attachment": "Confirm attachment delete", "CardDialog.delete-confirmation-dialog-button-text": "Delete", "CardDialog.delete-confirmation-dialog-heading": "Confirm card delete", "CardDialog.editing-template": "You're editing a template.", "CardDialog.nocard": "This card doesn't exist or is inaccessible.", "Categories.CreateCategoryDialog.CancelText": "Cancel", "Categories.CreateCategoryDialog.CreateText": "Create", "Categories.CreateCategoryDialog.Placeholder": "Name your category", "Categories.CreateCategoryDialog.UpdateText": "Update", "CenterPanel.Login": "Login", "CenterPanel.Share": "Share", "ChannelIntro.CreateBoard": "Create a board", "ColorOption.selectColor": "Select {color} Color", "Comment.delete": "Delete", "CommentsList.send": "Send", "ConfirmPerson.empty": "Empty", "ConfirmPerson.search": "Search...", "ConfirmationDialog.cancel-action": "Cancel", "ConfirmationDialog.confirm-action": "Confirm", "ContentBlock.Delete": "Delete", "ContentBlock.DeleteAction": "delete", "ContentBlock.addElement": "add {type}", "ContentBlock.checkbox": "checkbox", "ContentBlock.divider": "divider", "ContentBlock.editCardCheckbox": "toggled-checkbox", "ContentBlock.editCardCheckboxText": "edit card text", "ContentBlock.editCardText": "edit card text", "ContentBlock.editText": "Edit text...", "ContentBlock.errorText": "You've exceeded the size limit for this content. Please shorten it to avoid losing data.", "ContentBlock.image": "image", "ContentBlock.insertAbove": "Insert above", "ContentBlock.moveBlock": "move card content", "ContentBlock.moveDown": "Move down", "ContentBlock.moveUp": "Move up", "ContentBlock.text": "text", "DateFilter.empty": "Empty", "DateRange.clear": "Clear", "DateRange.empty": "Empty", "DateRange.endDate": "End date", "DateRange.today": "Today", "DeleteBoardDialog.confirm-cancel": "Cancel", "DeleteBoardDialog.confirm-delete": "Delete", "DeleteBoardDialog.confirm-info": "Are you sure you want to delete the board “{boardTitle}”? Deleting it will delete all cards in the board.", "DeleteBoardDialog.confirm-info-template": "Are you sure you want to delete the board template “{boardTitle}”?", "DeleteBoardDialog.confirm-tite": "Confirm delete board", "DeleteBoardDialog.confirm-tite-template": "Confirm delete board template", "Dialog.closeDialog": "Close dialog", "EditableDayPicker.today": "Today", "Error.mobileweb": "Mobile web support is currently in early beta. Not all functionality may be present.", "Error.websocket-closed": "Websocket connection closed, connection interrupted. If this persists, check your server or web proxy configuration.", "Filter.contains": "contains", "Filter.ends-with": "ends with", "Filter.includes": "includes", "Filter.is": "is", "Filter.is-after": "is after", "Filter.is-before": "is before", "Filter.is-empty": "is empty", "Filter.is-not-empty": "is not empty", "Filter.is-not-set": "is not set", "Filter.is-set": "is set", "Filter.isafter": "is after", "Filter.isbefore": "is before", "Filter.not-contains": "doesn't contain", "Filter.not-ends-with": "doesn't end with", "Filter.not-includes": "doesn't include", "Filter.not-starts-with": "doesn't start with", "Filter.starts-with": "starts with", "FilterByText.placeholder": "filter text", "FilterComponent.add-filter": "+ Add filter", "FilterComponent.delete": "Delete", "FilterValue.empty": "(empty)", "FindBoardsDialog.IntroText": "Search for boards", "FindBoardsDialog.NoResultsFor": "No results for \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Check the spelling or try another search.", "FindBoardsDialog.SubTitle": "Type to find a board. Use UP/DOWN to browse. ENTER to select, ESC to dismiss", "FindBoardsDialog.Title": "Find Boards", "GroupBy.hideEmptyGroups": "Hide {count} empty groups", "GroupBy.showHiddenGroups": "Show {count} hidden groups", "GroupBy.ungroup": "Ungroup", "HideBoard.MenuOption": "Hide board", "KanbanCard.untitled": "Untitled", "MentionSuggestion.is-not-board-member": "(not board member)", "Mutator.new-board-from-template": "new board from template", "Mutator.new-card-from-template": "new card from template", "Mutator.new-template-from-card": "new template from card", "OnboardingTour.AddComments.Body": "You can comment on issues, and even @mention your fellow Mattermost users to get their attention.", "OnboardingTour.AddComments.Title": "Add comments", "OnboardingTour.AddDescription.Body": "Add a description to your card so your teammates know what the card is about.", "OnboardingTour.AddDescription.Title": "Add description", "OnboardingTour.AddProperties.Body": "Add various properties to cards to make them more powerful.", "OnboardingTour.AddProperties.Title": "Add properties", "OnboardingTour.AddView.Body": "Go here to create a new view to organise your board using different layouts.", "OnboardingTour.AddView.Title": "Add a new view", "OnboardingTour.CopyLink.Body": "You can share your cards with teammates by copying the link and pasting it in a channel, direct message, or group message.", "OnboardingTour.CopyLink.Title": "Copy link", "OnboardingTour.OpenACard.Body": "Open a card to explore the powerful ways that Boards can help you organize your work.", "OnboardingTour.OpenACard.Title": "Open a card", "OnboardingTour.ShareBoard.Body": "You can share your board internally, within your team, or publish it publicly for visibility outside of your organization.", "OnboardingTour.ShareBoard.Title": "Share board", "PersonProperty.board-members": "Board members", "PersonProperty.me": "Me", "PersonProperty.non-board-members": "Not board members", "PropertyMenu.Delete": "Delete", "PropertyMenu.changeType": "Change property type", "PropertyMenu.selectType": "Select property type", "PropertyMenu.typeTitle": "Type", "PropertyType.Checkbox": "Checkbox", "PropertyType.CreatedBy": "Created by", "PropertyType.CreatedTime": "Created time", "PropertyType.Date": "Date", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "Multi person", "PropertyType.MultiSelect": "Multi select", "PropertyType.Number": "Number", "PropertyType.Person": "Person", "PropertyType.Phone": "Phone", "PropertyType.Select": "Select", "PropertyType.Text": "Text", "PropertyType.Unknown": "Unknown", "PropertyType.UpdatedBy": "Last updated by", "PropertyType.UpdatedTime": "Last updated time", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Empty", "RegistrationLink.confirmRegenerateToken": "This will invalidate previously shared links. Continue?", "RegistrationLink.copiedLink": "Copied!", "RegistrationLink.copyLink": "Copy link", "RegistrationLink.description": "Share this link for others to create accounts:", "RegistrationLink.regenerateToken": "Regenerate token", "RegistrationLink.tokenRegenerated": "Registration link regenerated", "ShareBoard.PublishDescription": "Publish and share a read-only link with everyone on the web.", "ShareBoard.PublishTitle": "Publish to the web", "ShareBoard.ShareInternal": "Share internally", "ShareBoard.ShareInternalDescription": "Users who have permissions will be able to use this link.", "ShareBoard.Title": "Share Board", "ShareBoard.confirmRegenerateToken": "This will invalidate previously shared links. Continue?", "ShareBoard.copiedLink": "Copied!", "ShareBoard.copyLink": "Copy link", "ShareBoard.regenerate": "Regenerate token", "ShareBoard.searchPlaceholder": "Search for people and channels", "ShareBoard.teamPermissionsText": "Everyone at {teamName} team", "ShareBoard.tokenRegenrated": "Token regenerated", "ShareBoard.userPermissionsRemoveMemberText": "Remove member", "ShareBoard.userPermissionsYouText": "(You)", "ShareTemplate.Title": "Share template", "ShareTemplate.searchPlaceholder": "Search for people", "Sidebar.about": "About Focalboard", "Sidebar.add-board": "+ Add board", "Sidebar.changePassword": "Change password", "Sidebar.delete-board": "Delete board", "Sidebar.duplicate-board": "Duplicate board", "Sidebar.export-archive": "Export archive", "Sidebar.import": "Import", "Sidebar.import-archive": "Import archive", "Sidebar.invite-users": "Invite users", "Sidebar.logout": "Log out", "Sidebar.new-category.badge": "New", "Sidebar.new-category.drag-boards-cta": "Drag boards here...", "Sidebar.no-boards-in-category": "No boards inside", "Sidebar.product-tour": "Product tour", "Sidebar.random-icons": "Random icons", "Sidebar.set-language": "Set language", "Sidebar.set-theme": "Set theme", "Sidebar.settings": "Settings", "Sidebar.template-from-board": "New template from board", "Sidebar.untitled-board": "(Untitled Board)", "Sidebar.untitled-view": "(Untitled View)", "SidebarCategories.BlocksMenu.Move": "Move To...", "SidebarCategories.CategoryMenu.CreateNew": "Create New Category", "SidebarCategories.CategoryMenu.Delete": "Delete Category", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Boards in {categoryName} will move back to the Boards categories. You're not removed from any boards.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Delete this category?", "SidebarCategories.CategoryMenu.Update": "Rename Category", "SidebarTour.ManageCategories.Body": "Create and manage custom categories. Categories are user-specific, so moving a board to your category won’t impact other members using the same board.", "SidebarTour.ManageCategories.Title": "Manage categories", "SidebarTour.SearchForBoards.Body": "Open the board switcher (Cmd/Ctrl + K) to quickly search and add boards to your sidebar.", "SidebarTour.SearchForBoards.Title": "Search for boards", "SidebarTour.SidebarCategories.Body": "All your boards are now organized under your new sidebar. No more switching between workspaces. One-time custom categories based on your prior workspaces may have automatically been created for you as part of your v7.2 upgrade. These can be removed or edited to your preference.", "SidebarTour.SidebarCategories.Link": "Learn more", "SidebarTour.SidebarCategories.Title": "Sidebar categories", "SiteStats.total_boards": "Total boards", "SiteStats.total_cards": "Total cards", "TableComponent.add-icon": "Add icon", "TableComponent.name": "Name", "TableComponent.plus-new": "+ New", "TableHeaderMenu.delete": "Delete", "TableHeaderMenu.duplicate": "Duplicate", "TableHeaderMenu.hide": "Hide", "TableHeaderMenu.insert-left": "Insert left", "TableHeaderMenu.insert-right": "Insert right", "TableHeaderMenu.sort-ascending": "Sort ascending", "TableHeaderMenu.sort-descending": "Sort descending", "TableRow.DuplicateCard": "duplicate card", "TableRow.MoreOption": "More actions", "TableRow.open": "Open", "TopBar.give-feedback": "Give feedback", "URLProperty.copiedLink": "Copied!", "URLProperty.copy": "Copy", "URLProperty.edit": "Edit", "UndoRedoHotKeys.canRedo": "Redo", "UndoRedoHotKeys.canRedo-with-description": "Redo {description}", "UndoRedoHotKeys.canUndo": "Undo", "UndoRedoHotKeys.canUndo-with-description": "Undo {description}", "UndoRedoHotKeys.cannotRedo": "Nothing to Redo", "UndoRedoHotKeys.cannotUndo": "Nothing to Undo", "ValueSelector.noOptions": "No options. Start typing to add the first one!", "ValueSelector.valueSelector": "Value selector", "ValueSelectorLabel.openMenu": "Open menu", "VersionMessage.help": "Check out what's new in this version.", "VersionMessage.learn-more": "Learn more", "View.AddView": "Add view", "View.Board": "Board", "View.DeleteView": "Delete view", "View.DuplicateView": "Duplicate view", "View.Gallery": "Gallery", "View.NewBoardTitle": "Board view", "View.NewCalendarTitle": "Calendar view", "View.NewGalleryTitle": "Gallery view", "View.NewTableTitle": "Table view", "View.NewTemplateDefaultTitle": "Untitled Template", "View.NewTemplateTitle": "Untitled", "View.Table": "Table", "ViewHeader.add-template": "New template", "ViewHeader.delete-template": "Delete", "ViewHeader.display-by": "Display by: {property}", "ViewHeader.edit-template": "Edit", "ViewHeader.empty-card": "Empty card", "ViewHeader.export-board-archive": "Export board archive", "ViewHeader.export-complete": "Export complete!", "ViewHeader.export-csv": "Export to CSV", "ViewHeader.export-failed": "Export failed!", "ViewHeader.filter": "Filter", "ViewHeader.group-by": "Group by: {property}", "ViewHeader.new": "New", "ViewHeader.properties": "Properties", "ViewHeader.properties-menu": "Properties menu", "ViewHeader.search-text": "Search cards", "ViewHeader.select-a-template": "Select a template", "ViewHeader.set-default-template": "Set as default", "ViewHeader.sort": "Sort", "ViewHeader.untitled": "Untitled", "ViewHeader.view-header-menu": "View header menu", "ViewHeader.view-menu": "View menu", "ViewLimitDialog.Heading": "Views per board limit reached", "ViewLimitDialog.PrimaryButton.Title.Admin": "Upgrade", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Notify Admin", "ViewLimitDialog.Subtext.Admin": "Upgrade to our Professional or Enterprise plan.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Learn more about our plans.", "ViewLimitDialog.Subtext.RegularUser": "Notify your Admin to upgrade to our Professional or Enterprise plan.", "ViewLimitDialog.UpgradeImg.AltText": "upgrade image", "ViewLimitDialog.notifyAdmin.Success": "Your admin has been notified", "ViewTitle.hide-description": "hide description", "ViewTitle.pick-icon": "Pick icon", "ViewTitle.random-icon": "Random", "ViewTitle.remove-icon": "Remove icon", "ViewTitle.show-description": "show description", "ViewTitle.untitled-board": "Untitled board", "WelcomePage.Description": "Boards is a project management tool that helps define, organize, track, and manage work across teams using a familiar Kanban board view.", "WelcomePage.Explore.Button": "Take a tour", "WelcomePage.Heading": "Welcome To Boards", "WelcomePage.NoThanks.Text": "No thanks, I'll figure it out myself", "WelcomePage.StartUsingIt.Text": "Start using it", "Workspace.editing-board-template": "You're editing a board template.", "badge.guest": "Guest", "boardPage.confirm-join-button": "Join", "boardPage.confirm-join-text": "You are about to join a private board without explicitly being added by the board admin. Are you sure you wish to join this private board?", "boardPage.confirm-join-title": "Join private board", "boardSelector.confirm-link-board": "Link board to channel", "boardSelector.confirm-link-board-button": "Yes, link board", "boardSelector.confirm-link-board-subtext": "When you link \"{boardName}\" to the channel, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests. You can unlink a board from a channel at any time.", "boardSelector.confirm-link-board-subtext-with-other-channel": "When you link \"{boardName}\" to the channel, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests.{lineBreak} This board is currently linked to another channel. It will be unlinked if you choose to link it here.", "boardSelector.create-a-board": "Create a board", "boardSelector.link": "Link", "boardSelector.search-for-boards": "Search for boards", "boardSelector.title": "Link boards", "boardSelector.unlink": "Unlink", "calendar.month": "Month", "calendar.today": "TODAY", "calendar.week": "Week", "centerPanel.undefined": "No {propertyName}", "centerPanel.unknown-user": "Unknown user", "cloudMessage.learn-more": "Learn more", "createImageBlock.failed": "This file couldn't be uploaded as the file size limit has been reached.", "default-properties.badges": "Comments and description", "default-properties.title": "Title", "error.back-to-home": "Back to home", "error.back-to-team": "Back to team", "error.board-not-found": "Board not found.", "error.go-login": "Log in", "error.invalid-read-only-board": "You don't have access to this board. Log in to access Boards.", "error.not-logged-in": "Your session may have expired or you're not logged in. Log in again to access Boards.", "error.page.title": "Sorry, something went wrong", "error.team-undefined": "Not a valid team.", "error.unknown": "An error occurred.", "generic.previous": "Previous", "guest-no-board.subtitle": "You don't have access to any board in this team yet, please wait until somebody adds you to any board.", "guest-no-board.title": "No boards yet", "imagePaste.upload-failed": "Some files weren't uploaded because the file size limit has been reached.", "limitedCard.title": "Cards hidden", "login.log-in-button": "Log in", "login.log-in-title": "Log in", "login.register-button": "or create an account if you don't have one", "new_channel_modal.create_board.empty_board_description": "Create a new empty board", "new_channel_modal.create_board.empty_board_title": "Empty board", "new_channel_modal.create_board.select_template_placeholder": "Select a template", "new_channel_modal.create_board.title": "Create a board for this channel", "notification-box-card-limit-reached.close-tooltip": "Snooze for 10 days", "notification-box-card-limit-reached.contact-link": "notify your admin", "notification-box-card-limit-reached.link": "Upgrade to a paid plan", "notification-box-card-limit-reached.title": "{cards} cards hidden from board", "notification-box-cards-hidden.title": "This action has hidden another card", "notification-box.card-limit-reached.not-admin.text": "To access archived cards, you can {contactLink} to upgrade to a paid plan.", "notification-box.card-limit-reached.text": "Card limit reached, to view older cards, {link}", "person.add-user-to-board": "Add {username} to board", "person.add-user-to-board-confirm-button": "Add to board", "person.add-user-to-board-permissions": "Permissions", "person.add-user-to-board-question": "Do you want to add {username} to the board?", "person.add-user-to-board-warning": "{username} isn't a member of the board, and won't receive any notifications about it.", "register.login-button": "or log in if you already have an account", "register.signup-title": "Sign up for your account", "rhs-board-non-admin-msg": "You're not an admin of the board", "rhs-boards.add": "Add", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "this direct message", "rhs-boards.header.gm": "this group message", "rhs-boards.last-update-at": "Last update at: {datetime}", "rhs-boards.link-boards-to-channel": "Link boards to {channelName}", "rhs-boards.linked-boards": "Linked boards", "rhs-boards.no-boards-linked-to-channel": "No boards are linked to {channelName} yet", "rhs-boards.no-boards-linked-to-channel-description": "Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.", "rhs-boards.unlink-board": "Unlink board", "rhs-boards.unlink-board1": "Unlink board", "rhs-channel-boards-header.title": "Boards", "share-board.publish": "Publish", "share-board.share": "Share", "shareBoard.channels-select-group": "Channels", "shareBoard.confirm-change-team-role.body": "Everyone on this board with a lower permission than the \"{role}\" role will now be promoted to {role}. Are you sure you want to change the minimum role for the board?", "shareBoard.confirm-change-team-role.confirmBtnText": "Change minimum board role", "shareBoard.confirm-change-team-role.title": "Change minimum board role", "shareBoard.confirm-link-channel": "Link board to channel", "shareBoard.confirm-link-channel-button": "Link channel", "shareBoard.confirm-link-channel-button-with-other-channel": "Unlink and link here", "shareBoard.confirm-link-channel-subtext": "When you link a channel to a board, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "When you link a channel to a board, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests.{lineBreak}This board is currently linked to another channel. It will be unlinked if you choose to link it here.", "shareBoard.confirm-unlink.body": "When you unlink a channel from a board, all members of the channel (existing and new) will lose access to it unless they're given permission separately.", "shareBoard.confirm-unlink.confirmBtnText": "Unlink channel", "shareBoard.confirm-unlink.title": "Unlink channel from board", "shareBoard.lastAdmin": "Boards must have at least one Administrator", "shareBoard.members-select-group": "Members", "shareBoard.unknown-channel-display-name": "Unknown channel", "tutorial_tip.finish_tour": "Done", "tutorial_tip.got_it": "Got it", "tutorial_tip.ok": "Next", "tutorial_tip.out": "Opt out of these tips.", "tutorial_tip.seen": "Seen this before?" } ================================================ FILE: webapp/i18n/en_AU.json ================================================ { "AdminBadge.SystemAdmin": "Admin", "AdminBadge.TeamAdmin": "Team Admin", "AppBar.Tooltip": "Toggle Linked Boards", "Attachment.Attachment-title": "Attachment", "AttachmentBlock.DeleteAction": "delete", "AttachmentBlock.addElement": "add {type}", "AttachmentBlock.delete": "Attachment deleted.", "AttachmentBlock.failed": "This file couldn't be uploaded as the file size limit has been reached.", "AttachmentBlock.upload": "Attachment uploading.", "AttachmentBlock.uploadSuccess": "Attachment uploaded.", "AttachmentElement.delete-confirmation-dialog-button-text": "Delete", "AttachmentElement.download": "Download", "AttachmentElement.upload-percentage": "Uploading...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Add a group", "BoardComponent.delete": "Delete", "BoardComponent.hidden-columns": "Hidden columns", "BoardComponent.hide": "Hide", "BoardComponent.new": "+ New", "BoardComponent.no-property": "No {property}", "BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column can't be removed.", "BoardComponent.show": "Show", "BoardMember.schemeAdmin": "Admin", "BoardMember.schemeCommenter": "Commenter", "BoardMember.schemeEditor": "Editor", "BoardMember.schemeNone": "None", "BoardMember.schemeViewer": "Viewer", "BoardMember.unlinkChannel": "Unlink", "BoardPage.newVersion": "A new version of Boards is available, click here to reload.", "BoardPage.syncFailed": "Board may be deleted or access revoked.", "BoardTemplateSelector.add-template": "Create new template", "BoardTemplateSelector.create-empty-board": "Create an empty board", "BoardTemplateSelector.delete-template": "Delete", "BoardTemplateSelector.description": "Add a board to the sidebar using any of the templates defined below or start from scratch.", "BoardTemplateSelector.edit-template": "Edit", "BoardTemplateSelector.plugin.no-content-description": "Add a board to the sidebar using any of the templates defined below or start from scratch.", "BoardTemplateSelector.plugin.no-content-title": "Create a board", "BoardTemplateSelector.title": "Create a board", "BoardTemplateSelector.use-this-template": "Use this template", "BoardsSwitcher.Title": "Find boards", "BoardsUnfurl.Limited": "Additional details are hidden due to the card being archived", "BoardsUnfurl.Remainder": "+{remainder} more", "BoardsUnfurl.Updated": "Updated {time}", "Calculations.Options.average.displayName": "Average", "Calculations.Options.average.label": "Average", "Calculations.Options.count.displayName": "Count", "Calculations.Options.count.label": "Count", "Calculations.Options.countChecked.displayName": "Checked", "Calculations.Options.countChecked.label": "Count checked", "Calculations.Options.countUnchecked.displayName": "Unchecked", "Calculations.Options.countUnchecked.label": "Count unchecked", "Calculations.Options.countUniqueValue.displayName": "Unique", "Calculations.Options.countUniqueValue.label": "Count unique values", "Calculations.Options.countValue.displayName": "Values", "Calculations.Options.countValue.label": "Count value", "Calculations.Options.dateRange.displayName": "Range", "Calculations.Options.dateRange.label": "Range", "Calculations.Options.earliest.displayName": "Earliest", "Calculations.Options.earliest.label": "Earliest", "Calculations.Options.latest.displayName": "Latest", "Calculations.Options.latest.label": "Latest", "Calculations.Options.max.displayName": "Max", "Calculations.Options.max.label": "Max", "Calculations.Options.median.displayName": "Median", "Calculations.Options.median.label": "Median", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Calculate", "Calculations.Options.none.label": "None", "Calculations.Options.percentChecked.displayName": "Checked", "Calculations.Options.percentChecked.label": "Percent checked", "Calculations.Options.percentUnchecked.displayName": "Unchecked", "Calculations.Options.percentUnchecked.label": "Percent unchecked", "Calculations.Options.range.displayName": "Range", "Calculations.Options.range.label": "Range", "Calculations.Options.sum.displayName": "Sum", "Calculations.Options.sum.label": "Sum", "CalendarCard.untitled": "Untitled", "CardActionsMenu.copiedLink": "Copied", "CardActionsMenu.copyLink": "Copy link", "CardActionsMenu.delete": "Delete", "CardActionsMenu.duplicate": "Duplicate", "CardBadges.title-checkboxes": "Checkboxes", "CardBadges.title-comments": "Comments", "CardBadges.title-description": "This card has a description", "CardDetail.Attach": "Attach", "CardDetail.Follow": "Follow", "CardDetail.Following": "Following", "CardDetail.add-content": "Add content", "CardDetail.add-icon": "Add icon", "CardDetail.add-property": "+ Add a property", "CardDetail.addCardText": "add card text", "CardDetail.limited-body": "Upgrade to the Professional or Enterprise plan.", "CardDetail.limited-button": "Upgrade", "CardDetail.limited-title": "This card is hidden", "CardDetail.moveContent": "Move card content", "CardDetail.new-comment-placeholder": "Add a comment", "CardDetailProperty.confirm-delete-heading": "Confirm property deletion", "CardDetailProperty.confirm-delete-subtext": "Are you sure you want to delete the property '{propertyName}'? This will remove the property from all cards in this board.", "CardDetailProperty.confirm-property-name-change-subtext": "Are you sure you want to change property '{propertyName}' {customText}? This will affect value(s) across {numOfCards} card(s) in this board, and may result in loss of data.", "CardDetailProperty.confirm-property-type-change": "Confirm change of property type", "CardDetailProperty.delete-action-button": "Delete", "CardDetailProperty.property-change-action-button": "Change property", "CardDetailProperty.property-changed": "Property changed successfully!", "CardDetailProperty.property-deleted": "{propertyName} deleted successfully!", "CardDetailProperty.property-name-change-subtext": "type from '{oldPropType}' to '{newPropType}'", "CardDetial.limited-link": "Learn more about our plans.", "CardDialog.delete-confirmation-dialog-attachment": "Confirm attachment deletion", "CardDialog.delete-confirmation-dialog-button-text": "Delete", "CardDialog.delete-confirmation-dialog-heading": "Confirm card deletion", "CardDialog.editing-template": "You're editing a template.", "CardDialog.nocard": "This card doesn't exist or is inaccessible.", "Categories.CreateCategoryDialog.CancelText": "Cancel", "Categories.CreateCategoryDialog.CreateText": "Create", "Categories.CreateCategoryDialog.Placeholder": "Name your category", "Categories.CreateCategoryDialog.UpdateText": "Update", "CenterPanel.Login": "Login", "CenterPanel.Share": "Share", "ChannelIntro.CreateBoard": "Create a board", "ColorOption.selectColor": "Select {color} Colour", "Comment.delete": "Delete", "CommentsList.send": "Send", "ConfirmPerson.empty": "Empty", "ConfirmPerson.search": "Search...", "ConfirmationDialog.cancel-action": "Cancel", "ConfirmationDialog.confirm-action": "Confirm", "ContentBlock.Delete": "Delete", "ContentBlock.DeleteAction": "delete", "ContentBlock.addElement": "add {type}", "ContentBlock.checkbox": "checkbox", "ContentBlock.divider": "divider", "ContentBlock.editCardCheckbox": "toggled-checkbox", "ContentBlock.editCardCheckboxText": "edit card text", "ContentBlock.editCardText": "edit card text", "ContentBlock.editText": "Edit text", "ContentBlock.image": "image", "ContentBlock.insertAbove": "Insert above", "ContentBlock.moveBlock": "move card content", "ContentBlock.moveDown": "Move down", "ContentBlock.moveUp": "Move up", "ContentBlock.text": "text", "DateFilter.empty": "Empty", "DateRange.clear": "Clear", "DateRange.empty": "Empty", "DateRange.endDate": "End date", "DateRange.today": "Today", "DeleteBoardDialog.confirm-cancel": "Cancel", "DeleteBoardDialog.confirm-delete": "Delete", "DeleteBoardDialog.confirm-info": "Are you sure you want to delete the board '{boardTitle}'? This will remove all cards in the board.", "DeleteBoardDialog.confirm-info-template": "Are you sure you want to delete the board template ‘{boardTitle}’?", "DeleteBoardDialog.confirm-tite": "Confirm board deletion", "DeleteBoardDialog.confirm-tite-template": "Confirm deletion of board template", "Dialog.closeDialog": "Close dialog", "EditableDayPicker.today": "Today", "Error.mobileweb": "Mobile web support is currently in early beta. Not all functionality may be present.", "Error.websocket-closed": "Websocket connection closed, connection interrupted. If this persists, check your server or web proxy configuration.", "Filter.contains": "contains", "Filter.ends-with": "ends with", "Filter.includes": "includes", "Filter.is": "is", "Filter.is-after": "is after", "Filter.is-before": "is before", "Filter.is-empty": "is empty", "Filter.is-not-empty": "is not empty", "Filter.is-not-set": "is not set", "Filter.is-set": "is set", "Filter.isafter": "is after", "Filter.isbefore": "is before", "Filter.not-contains": "does not contain", "Filter.not-ends-with": "does not end with", "Filter.not-includes": "doesn't include", "Filter.not-starts-with": "does not start with", "Filter.starts-with": "starts with", "FilterByText.placeholder": "filter text", "FilterComponent.add-filter": "+ Add filter", "FilterComponent.delete": "Delete", "FilterValue.empty": "(empty)", "FindBoardsDialog.IntroText": "Search for boards", "FindBoardsDialog.NoResultsFor": "No results for '\\{searchQuery}'\\", "FindBoardsDialog.NoResultsSubtext": "Check the spelling or try another search.", "FindBoardsDialog.SubTitle": "Type to find a board. Use UP/DOWN to browse. ENTER to select, ESC to dismiss", "FindBoardsDialog.Title": "Find Boards", "GroupBy.hideEmptyGroups": "Hide {count} empty groups", "GroupBy.showHiddenGroups": "Show {count} hidden groups", "GroupBy.ungroup": "Ungroup", "HideBoard.MenuOption": "Hide board", "KanbanCard.untitled": "Untitled", "MentionSuggestion.is-not-board-member": "(not board member)", "Mutator.new-board-from-template": "new board from template", "Mutator.new-card-from-template": "new card from template", "Mutator.new-template-from-card": "new template from card", "OnboardingTour.AddComments.Body": "You can comment on issues, and even @mention your fellow Mattermost users to get their attention.", "OnboardingTour.AddComments.Title": "Add comments", "OnboardingTour.AddDescription.Body": "Add a description to your card so your teammates know what the card is about.", "OnboardingTour.AddDescription.Title": "Add description", "OnboardingTour.AddProperties.Body": "Add various properties to cards to make them more powerful.", "OnboardingTour.AddProperties.Title": "Add properties", "OnboardingTour.AddView.Body": "Go here to create a new view to organise your board using different layouts.", "OnboardingTour.AddView.Title": "Add a new view", "OnboardingTour.CopyLink.Body": "You can share your cards with teammates by copying the link and pasting it in a channel, direct message or group message.", "OnboardingTour.CopyLink.Title": "Copy link", "OnboardingTour.OpenACard.Body": "Open a card to explore the powerful ways that Boards can help you organise your work.", "OnboardingTour.OpenACard.Title": "Open a card", "OnboardingTour.ShareBoard.Body": "You can share your board internally, within your team or publish it publicly for visibility outside of your organisation.", "OnboardingTour.ShareBoard.Title": "Share board", "PersonProperty.board-members": "Board members", "PersonProperty.me": "Me", "PersonProperty.non-board-members": "Not board members", "PropertyMenu.Delete": "Delete", "PropertyMenu.changeType": "Change property type", "PropertyMenu.selectType": "Select property type", "PropertyMenu.typeTitle": "Type", "PropertyType.Checkbox": "Checkbox", "PropertyType.CreatedBy": "Created by", "PropertyType.CreatedTime": "Time created", "PropertyType.Date": "Date", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "Multi person", "PropertyType.MultiSelect": "Multi select", "PropertyType.Number": "Number", "PropertyType.Person": "Person", "PropertyType.Phone": "Phone", "PropertyType.Select": "Select", "PropertyType.Text": "Text", "PropertyType.Unknown": "Unknown", "PropertyType.UpdatedBy": "Last updated by", "PropertyType.UpdatedTime": "Time last updated", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Empty", "RegistrationLink.confirmRegenerateToken": "This will invalidate previously shared links. Continue?", "RegistrationLink.copiedLink": "Copied!", "RegistrationLink.copyLink": "Copy link", "RegistrationLink.description": "Share this link for others to create accounts:", "RegistrationLink.regenerateToken": "Regenerate token", "RegistrationLink.tokenRegenerated": "Registration link regenerated", "ShareBoard.PublishDescription": "Publish and share a read-only link with everyone on the web.", "ShareBoard.PublishTitle": "Publish to the web", "ShareBoard.ShareInternal": "Share internally", "ShareBoard.ShareInternalDescription": "Users who have permissions will be able to use this link.", "ShareBoard.Title": "Share Board", "ShareBoard.confirmRegenerateToken": "This will invalidate previously shared links. Continue?", "ShareBoard.copiedLink": "Copied!", "ShareBoard.copyLink": "Copy link", "ShareBoard.regenerate": "Regenerate token", "ShareBoard.searchPlaceholder": "Search for people and channels", "ShareBoard.teamPermissionsText": "Everyone at {teamName} team", "ShareBoard.tokenRegenrated": "Token regenerated", "ShareBoard.userPermissionsRemoveMemberText": "Remove member", "ShareBoard.userPermissionsYouText": "(You)", "ShareTemplate.Title": "Share template", "ShareTemplate.searchPlaceholder": "Search for people", "Sidebar.about": "About Focalboard", "Sidebar.add-board": "+ Add board", "Sidebar.changePassword": "Change password", "Sidebar.delete-board": "Delete board", "Sidebar.duplicate-board": "Duplicate board", "Sidebar.export-archive": "Export archive", "Sidebar.import": "Import", "Sidebar.import-archive": "Import archive", "Sidebar.invite-users": "Invite users", "Sidebar.logout": "Log out", "Sidebar.new-category.badge": "New", "Sidebar.new-category.drag-boards-cta": "Drag boards here...", "Sidebar.no-boards-in-category": "No boards inside", "Sidebar.product-tour": "Product tour", "Sidebar.random-icons": "Random icons", "Sidebar.set-language": "Set language", "Sidebar.set-theme": "Set theme", "Sidebar.settings": "Settings", "Sidebar.template-from-board": "New template from board", "Sidebar.untitled-board": "(Untitled Board)", "Sidebar.untitled-view": "(Untitled View)", "SidebarCategories.BlocksMenu.Move": "Move To...", "SidebarCategories.CategoryMenu.CreateNew": "Create New Category", "SidebarCategories.CategoryMenu.Delete": "Delete Category", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Boards in {categoryName} will move back to the Boards categories. You're not removed from any boards.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Delete this category?", "SidebarCategories.CategoryMenu.Update": "Rename Category", "SidebarTour.ManageCategories.Body": "Create and manage custom categories. Categories are user-specific, so moving a board to your category won’t impact other members using the same board.", "SidebarTour.ManageCategories.Title": "Manage categories", "SidebarTour.SearchForBoards.Body": "Open the board switcher (Cmd/Ctrl + K) to quickly search and add boards to your sidebar.", "SidebarTour.SearchForBoards.Title": "Search for boards", "SidebarTour.SidebarCategories.Body": "All your boards are now organised under your new sidebar. No more switching between workspaces. One-time custom categories based on your prior workspaces may have automatically been created for you as part of your v7.2 upgrade. These can be removed or edited to your preference.", "SidebarTour.SidebarCategories.Link": "Learn more", "SidebarTour.SidebarCategories.Title": "Sidebar categories", "SiteStats.total_boards": "Total boards", "SiteStats.total_cards": "Total cards", "TableComponent.add-icon": "Add icon", "TableComponent.name": "Name", "TableComponent.plus-new": "+ New", "TableHeaderMenu.delete": "Delete", "TableHeaderMenu.duplicate": "Duplicate", "TableHeaderMenu.hide": "Hide", "TableHeaderMenu.insert-left": "Insert left", "TableHeaderMenu.insert-right": "Insert right", "TableHeaderMenu.sort-ascending": "Sort ascending", "TableHeaderMenu.sort-descending": "Sort descending", "TableRow.DuplicateCard": "duplicate card", "TableRow.MoreOption": "More actions", "TableRow.open": "Open", "TopBar.give-feedback": "Give feedback", "URLProperty.copiedLink": "Copied", "URLProperty.copy": "Copy", "URLProperty.edit": "Edit", "UndoRedoHotKeys.canRedo": "Redo", "UndoRedoHotKeys.canRedo-with-description": "Redo {description}", "UndoRedoHotKeys.canUndo": "Undo", "UndoRedoHotKeys.canUndo-with-description": "Undo {description}", "UndoRedoHotKeys.cannotRedo": "Nothing to Redo", "UndoRedoHotKeys.cannotUndo": "Nothing to Undo", "ValueSelector.noOptions": "No options. Start typing to add the first one!", "ValueSelector.valueSelector": "Value selector", "ValueSelectorLabel.openMenu": "Open menu", "VersionMessage.help": "Check out what's new in this version.", "VersionMessage.learn-more": "Learn more", "View.AddView": "Add view", "View.Board": "Board", "View.DeleteView": "Delete view", "View.DuplicateView": "Duplicate view", "View.Gallery": "Gallery", "View.NewBoardTitle": "Board view", "View.NewCalendarTitle": "Calendar view", "View.NewGalleryTitle": "Gallery view", "View.NewTableTitle": "Table view", "View.NewTemplateDefaultTitle": "Untitled Template", "View.NewTemplateTitle": "Untitled", "View.Table": "Table", "ViewHeader.add-template": "New template", "ViewHeader.delete-template": "Delete", "ViewHeader.display-by": "Display by: {property}", "ViewHeader.edit-template": "Edit", "ViewHeader.empty-card": "Empty card", "ViewHeader.export-board-archive": "Export board archive", "ViewHeader.export-complete": "Export complete!", "ViewHeader.export-csv": "Export to CSV", "ViewHeader.export-failed": "Export failed", "ViewHeader.filter": "Filter", "ViewHeader.group-by": "Group by: {property}", "ViewHeader.new": "New", "ViewHeader.properties": "Properties", "ViewHeader.properties-menu": "Properties menu", "ViewHeader.search-text": "Search cards", "ViewHeader.select-a-template": "Select a template", "ViewHeader.set-default-template": "Set as default", "ViewHeader.sort": "Sort", "ViewHeader.untitled": "Untitled", "ViewHeader.view-header-menu": "View header menu", "ViewHeader.view-menu": "View menu", "ViewLimitDialog.Heading": "Views per board limit reached", "ViewLimitDialog.PrimaryButton.Title.Admin": "Upgrade", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Notify Admin", "ViewLimitDialog.Subtext.Admin": "Upgrade to the Professional or Enterprise plan.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Learn more about our plans.", "ViewLimitDialog.Subtext.RegularUser": "Ask your Admin to upgrade to the Professional or Enterprise plan.", "ViewLimitDialog.UpgradeImg.AltText": "upgrade image", "ViewLimitDialog.notifyAdmin.Success": "Your admin has been contacted", "ViewTitle.hide-description": "hide description", "ViewTitle.pick-icon": "Pick icon", "ViewTitle.random-icon": "Random", "ViewTitle.remove-icon": "Remove icon", "ViewTitle.show-description": "show description", "ViewTitle.untitled-board": "Untitled board", "WelcomePage.Description": "Boards is a project management tool that helps define, organise, track and manage work across teams using a familiar Kanban board view.", "WelcomePage.Explore.Button": "Take a tour", "WelcomePage.Heading": "Welcome To Boards", "WelcomePage.NoThanks.Text": "No thanks, I'll figure it out myself", "WelcomePage.StartUsingIt.Text": "Start using it", "Workspace.editing-board-template": "You're editing a board template.", "badge.guest": "Guest", "boardPage.confirm-join-button": "Join", "boardPage.confirm-join-text": "You are about to join a private board without explicitly being added by the board admin. Are you sure you wish to join this private board?", "boardPage.confirm-join-title": "Join private board", "boardSelector.confirm-link-board": "Link board to channel", "boardSelector.confirm-link-board-button": "Link board", "boardSelector.confirm-link-board-subtext": "When you link '\\{boardName}'\\ to the channel, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests. You can unlink a board from a channel at any time.", "boardSelector.confirm-link-board-subtext-with-other-channel": "When you link '\\{boardName}'\\ to the channel, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests.{lineBreak} This board is currently linked to another channel. It will be unlinked if you choose to link it here.", "boardSelector.create-a-board": "Create a board", "boardSelector.link": "Link", "boardSelector.search-for-boards": "Search for boards", "boardSelector.title": "Link boards", "boardSelector.unlink": "Unlink", "calendar.month": "Month", "calendar.today": "TODAY", "calendar.week": "Week", "centerPanel.undefined": "No {propertyName}", "centerPanel.unknown-user": "Unknown user", "cloudMessage.learn-more": "Learn more", "createImageBlock.failed": "This file couldn't be uploaded as the file size limit has been reached.", "default-properties.badges": "Comments and description", "default-properties.title": "Title", "error.back-to-home": "Back to home", "error.back-to-team": "Back to team", "error.board-not-found": "Board not found.", "error.go-login": "Log in", "error.invalid-read-only-board": "You don't have access to this board. Log in to access Boards.", "error.not-logged-in": "Your session may have expired or you're not logged in. Log in again to access Boards.", "error.page.title": "An error occurred", "error.team-undefined": "Invalid team.", "error.unknown": "An error occurred.", "generic.previous": "Previous", "guest-no-board.subtitle": "You don't have access to any board in this team yet, please wait until somebody adds you to any board.", "guest-no-board.title": "No boards yet", "imagePaste.upload-failed": "Some files weren't uploaded because the file size limit has been reached.", "limitedCard.title": "Cards hidden", "login.log-in-button": "Log in", "login.log-in-title": "Log in", "login.register-button": "or create an account if you don't have one", "new_channel_modal.create_board.empty_board_description": "Create a new empty board", "new_channel_modal.create_board.empty_board_title": "Empty board", "new_channel_modal.create_board.select_template_placeholder": "Select a template", "new_channel_modal.create_board.title": "Create a board for this channel", "notification-box-card-limit-reached.close-tooltip": "Snooze for 10 days", "notification-box-card-limit-reached.contact-link": "Contact your adminstrator", "notification-box-card-limit-reached.link": "Upgrade to a paid plan", "notification-box-card-limit-reached.title": "{cards} cards hidden from board", "notification-box-cards-hidden.title": "This action has hidden another card", "notification-box.card-limit-reached.not-admin.text": "To access archived cards, you can {contactLink} to upgrade to a paid plan.", "notification-box.card-limit-reached.text": "Card limit reached, to view older cards, {link}", "person.add-user-to-board": "Add {username} to board", "person.add-user-to-board-confirm-button": "Add to board", "person.add-user-to-board-permissions": "Permissions", "person.add-user-to-board-question": "Do you want to add {username} to the board?", "person.add-user-to-board-warning": "{username} isn't a member of the board and won't receive any notifications for it.", "register.login-button": "or log in if you already have an account", "register.signup-title": "Sign up for your account", "rhs-board-non-admin-msg": "You're not an admin of the board", "rhs-boards.add": "Add", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "this direct message", "rhs-boards.header.gm": "this group message", "rhs-boards.last-update-at": "Last update at: {datetime}", "rhs-boards.link-boards-to-channel": "Link boards to {channelName}", "rhs-boards.linked-boards": "Linked boards", "rhs-boards.no-boards-linked-to-channel": "No boards are linked to {channelName} yet", "rhs-boards.no-boards-linked-to-channel-description": "Boards is a project management tool that helps define, organise, track and manage work across teams using a familiar kanban board view.", "rhs-boards.unlink-board": "Unlink board", "rhs-boards.unlink-board1": "Unlink board", "rhs-channel-boards-header.title": "Boards", "share-board.publish": "Publish", "share-board.share": "Share", "shareBoard.channels-select-group": "Channels", "shareBoard.confirm-change-team-role.body": "Everyone on this board with a lower permission than the '\\{role}'\\ role will now be promoted to {role}. Are you sure you want to change the minimum role for the board?", "shareBoard.confirm-change-team-role.confirmBtnText": "Change minimum board role", "shareBoard.confirm-change-team-role.title": "Change minimum board role", "shareBoard.confirm-link-channel": "Link board to channel", "shareBoard.confirm-link-channel-button": "Link channel", "shareBoard.confirm-link-channel-button-with-other-channel": "Unlink and link here", "shareBoard.confirm-link-channel-subtext": "When you link a channel to a board, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "When you link a channel to a board, all members of the channel (existing and new) will be able to edit it. This excludes members who are guests.{lineBreak}This board is currently linked to another channel. It will be unlinked if you choose to link it here.", "shareBoard.confirm-unlink.body": "When you unlink a channel from a board, all members of the channel (existing and new) will lose access to it unless they're given permission separately.", "shareBoard.confirm-unlink.confirmBtnText": "Unlink channel", "shareBoard.confirm-unlink.title": "Unlink channel from board", "shareBoard.lastAdmin": "Boards must have at least one Administrator", "shareBoard.members-select-group": "Members", "shareBoard.unknown-channel-display-name": "Unknown channel", "tutorial_tip.finish_tour": "Done", "tutorial_tip.got_it": "Got it", "tutorial_tip.ok": "Next", "tutorial_tip.out": "Opt out of these tips.", "tutorial_tip.seen": "Have you seen this before?" } ================================================ FILE: webapp/i18n/es.json ================================================ { "AppBar.Tooltip": "Alternar tableros vinculados", "Attachment.Attachment-title": "Archivos adjuntos", "AttachmentBlock.DeleteAction": "borrar", "AttachmentBlock.addElement": "agregar {type}", "AttachmentBlock.delete": "Archivo adjunto eliminado.", "AttachmentBlock.failed": "Este archivo no puede subirse debido a que excede el límite de tamaño de archivo.", "AttachmentBlock.upload": "Subiendo archivo adjunto.", "AttachmentBlock.uploadSuccess": "Archivo adjunto subido.", "AttachmentElement.delete-confirmation-dialog-button-text": "Borrar", "AttachmentElement.download": "Descargar", "AttachmentElement.upload-percentage": "Subiendo...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Añadir un grupo", "BoardComponent.delete": "Borrar", "BoardComponent.hidden-columns": "Columnas Ocultas", "BoardComponent.hide": "Ocultar", "BoardComponent.new": "+ Nuevo", "BoardComponent.no-property": "Sin {property}", "BoardComponent.no-property-title": "Elementos sin la propiedad {property} irán aquí. Esta columna no se puede eliminar.", "BoardComponent.show": "Mostrar", "BoardMember.schemeAdmin": "Administrador", "BoardMember.schemeEditor": "Editor", "BoardMember.schemeNone": "Ninguno", "BoardMember.schemeViewer": "Visualizador", "BoardMember.unlinkChannel": "Desvincular", "BoardPage.newVersion": "Una nueva versión de Boards está disponible, haz clic aquí para recargar.", "BoardPage.syncFailed": "El tablero puede haber sido eliminado o el acceso revocado.", "BoardTemplateSelector.add-template": "Crear nueva plantilla", "BoardTemplateSelector.create-empty-board": "Crear un tablero vacío", "BoardTemplateSelector.delete-template": "Eliminar", "BoardTemplateSelector.description": "Agregar un tablero a la barra lateral usando alguna de las plantillas definidas a continuación o empezar desde cero.", "BoardTemplateSelector.edit-template": "Editar", "BoardTemplateSelector.plugin.no-content-description": "Agregar un tablero a la barra lateral usando alguna de las plantillas definidas a continuación o empezar desde cero.", "BoardTemplateSelector.plugin.no-content-title": "Crear un tablero", "BoardTemplateSelector.title": "Crear un tablero", "BoardTemplateSelector.use-this-template": "Utiliza esta plantilla", "BoardsSwitcher.Title": "Encontrar tableros", "BoardsUnfurl.Limited": "Los detalles adicionales están ocultos debido a que la tarjeta ha sido archivada", "BoardsUnfurl.Updated": "Actualizado {time}", "Calculations.Options.average.displayName": "Promedio", "Calculations.Options.average.label": "Promedio", "Calculations.Options.count.displayName": "Contar", "Calculations.Options.count.label": "Contar", "Calculations.Options.countChecked.displayName": "Marcado", "Calculations.Options.countChecked.label": "Contar marcados", "Calculations.Options.countUnchecked.displayName": "Deseleccionado", "Calculations.Options.countUnchecked.label": "Contar no marcados", "Calculations.Options.countUniqueValue.displayName": "Único", "Calculations.Options.countUniqueValue.label": "Contar valores únicos", "Calculations.Options.countValue.displayName": "Valores", "Calculations.Options.dateRange.displayName": "Rango", "Calculations.Options.dateRange.label": "Rango", "Calculations.Options.earliest.displayName": "Más antiguo", "Calculations.Options.earliest.label": "Más antiguo", "Calculations.Options.latest.displayName": "Último", "Calculations.Options.latest.label": "Último", "Calculations.Options.max.displayName": "Máx", "Calculations.Options.max.label": "Máx", "Calculations.Options.median.displayName": "Mediana", "Calculations.Options.median.label": "Mediana", "Calculations.Options.min.displayName": "Mín", "Calculations.Options.min.label": "Mín", "Calculations.Options.none.displayName": "Calcular", "Calculations.Options.none.label": "Ninguna", "Calculations.Options.percentChecked.displayName": "Marcado", "Calculations.Options.percentChecked.label": "Porcentaje marcado", "Calculations.Options.percentUnchecked.displayName": "Desmarcado", "Calculations.Options.percentUnchecked.label": "Porcentaje desmarcado", "Calculations.Options.range.displayName": "Rango", "Calculations.Options.range.label": "Rango", "Calculations.Options.sum.displayName": "Suma", "Calculations.Options.sum.label": "Suma", "CalendarCard.untitled": "Sin título", "CardActionsMenu.copiedLink": "¡Copiado!", "CardActionsMenu.copyLink": "Copiar hipervínculo", "CardActionsMenu.delete": "Eliminar", "CardActionsMenu.duplicate": "Duplicar", "CardBadges.title-checkboxes": "Casillas de verificación", "CardBadges.title-comments": "Comentarios", "CardBadges.title-description": "Esta tarjeta tiene una descripción", "CardDetail.Attach": "Adjuntar", "CardDetail.Follow": "Seguir", "CardDetail.Following": "Siguiendo", "CardDetail.add-content": "Añadir contenido", "CardDetail.add-icon": "Añadir icono", "CardDetail.add-property": "+ Añadir propiedad", "CardDetail.addCardText": "agregar texto a la tarjeta", "CardDetail.limited-body": "Mejorar a nuestro plan Professional o Enterprise.", "CardDetail.limited-button": "Mejorar", "CardDetail.limited-title": "Esta tarjeta está oculta", "CardDetail.moveContent": "Mover contenido de la tarjeta", "CardDetail.new-comment-placeholder": "Añadir un comentario...", "CardDetailProperty.confirm-delete-heading": "Confirmar eliminación de la propiedad", "CardDetailProperty.confirm-delete-subtext": "¿Estás seguro de que quieres eliminar la propiedad \"{propertyName}\"? Al eliminarla también se removerá la propiedad en todas las tarjetas de este tablero.", "CardDetailProperty.confirm-property-name-change-subtext": "¿Estás seguro de que quieres cambiar la propiedad \"{propertyName}\" {customText}? Esto puede afectar a los valores en {numOfCards} tarjeta(s) en este tablero, lo que puede resultar en una pérdida de datos.", "CardDetailProperty.confirm-property-type-change": "Confirmar cambio de tipo de la propiedad", "CardDetailProperty.delete-action-button": "Eliminar", "CardDetailProperty.property-change-action-button": "Modificar propiedad", "CardDetailProperty.property-changed": "¡Propiedad modificada exitosamente!", "CardDetailProperty.property-deleted": "¡La propiedad {propertyName} ha sido eliminada exitosamente!", "CardDetial.limited-link": "Aprende más sobre nuestros planes.", "CardDialog.delete-confirmation-dialog-attachment": "Confirmar eliminación del archivo adjunto", "CardDialog.delete-confirmation-dialog-button-text": "Eliminar", "CardDialog.delete-confirmation-dialog-heading": "Confirmar eliminación de la tarjeta", "CardDialog.editing-template": "Estás editando una plantilla.", "CardDialog.nocard": "Esta tarjeta no existe o es inaccesible.", "Categories.CreateCategoryDialog.CancelText": "Cancelar", "Categories.CreateCategoryDialog.CreateText": "Crear", "Categories.CreateCategoryDialog.Placeholder": "Pon nombre a la categoría", "Categories.CreateCategoryDialog.UpdateText": "Actualizar", "CenterPanel.Login": "Ingresar", "CenterPanel.Share": "Compartir", "ChannelIntro.CreateBoard": "Crear un tablero", "ColorOption.selectColor": "Seleccionar {color} Color", "Comment.delete": "Borrar", "CommentsList.send": "Enviar", "ContentBlock.Delete": "Borrar", "ContentBlock.DeleteAction": "borrar", "ContentBlock.addElement": "añadir {type}", "ContentBlock.checkbox": "casilla de selección", "ContentBlock.divider": "divisor", "ContentBlock.editCardCheckbox": "casilla de verificación conmutada", "ContentBlock.editCardCheckboxText": "editar texto de la tarjeta", "ContentBlock.editCardText": "editar texto de la tarjeta", "ContentBlock.editText": "Editar texto...", "ContentBlock.image": "imagen", "ContentBlock.insertAbove": "Insertar encima", "ContentBlock.moveDown": "Mover hacia abajo", "ContentBlock.moveUp": "Mover hacia arriba", "ContentBlock.text": "texto", "Dialog.closeDialog": "Cerrar diálogo", "EditableDayPicker.today": "Hoy", "Error.websocket-closed": "Conexión de Websocket cerrada, conexión interrumpida. Si esto persiste, verifique la configuración de su servidor o proxy web.", "Filter.includes": "incluye", "Filter.is-empty": "está vacío", "Filter.is-not-empty": "no está vacío", "Filter.not-includes": "no incluye", "FilterComponent.add-filter": "+ Añadir filtro", "FilterComponent.delete": "Borrar", "GroupBy.ungroup": "Desagrupar", "KanbanCard.untitled": "Sin título", "Mutator.new-card-from-template": "nueva tarjeta desde una plantilla", "Mutator.new-template-from-card": "nueva plantilla desde una tarjeta", "PropertyMenu.Delete": "Borrar", "PropertyMenu.changeType": "Cambiar el tipo de propiedad", "PropertyMenu.typeTitle": "Tipo", "PropertyType.Checkbox": "Casilla de selección", "PropertyType.CreatedBy": "Creado por", "PropertyType.CreatedTime": "Hora de creación", "PropertyType.Date": "Fecha", "PropertyType.Email": "Email", "PropertyType.MultiSelect": "Selección Múltiple", "PropertyType.Number": "Número", "PropertyType.Person": "Persona", "PropertyType.Phone": "Teléfono", "PropertyType.Select": "Selector", "PropertyType.Text": "Texto", "PropertyType.UpdatedBy": "Última actualización por", "PropertyType.UpdatedTime": "Hora de última actualización", "RegistrationLink.confirmRegenerateToken": "Esto invalidará los enlaces compartidos previos. ¿Continuar?", "RegistrationLink.copiedLink": "¡Copiado!", "RegistrationLink.copyLink": "Copiar enlace", "RegistrationLink.description": "Comparte este enlace para que otros se creen sus cuentas:", "RegistrationLink.regenerateToken": "Regenerar token", "RegistrationLink.tokenRegenerated": "Enlace de registro regenerado", "ShareBoard.confirmRegenerateToken": "Esto invalidará los enlaces compartidos previos. ¿Continuar?", "ShareBoard.copiedLink": "¡Copiado!", "ShareBoard.copyLink": "Copiar enlace", "ShareBoard.tokenRegenrated": "Token regenerado", "Sidebar.about": "Sobre Focalboard", "Sidebar.add-board": "+ Añadir panel", "Sidebar.changePassword": "Cambiar contraseña", "Sidebar.delete-board": "Borrar Panel", "Sidebar.export-archive": "Exportar Archivo", "Sidebar.import-archive": "Importar Archivo", "Sidebar.invite-users": "Invitar usuarios", "Sidebar.logout": "Cerrar sesión", "Sidebar.random-icons": "Íconos random", "Sidebar.set-language": "Establecer idioma", "Sidebar.set-theme": "Establecer apariencia", "Sidebar.settings": "Configuración", "Sidebar.untitled-board": "(Panel sin titulo)", "TableComponent.add-icon": "Añadir Icono", "TableComponent.name": "Nombre", "TableComponent.plus-new": "+ Nueva", "TableHeaderMenu.delete": "Borrar", "TableHeaderMenu.duplicate": "Duplicar", "TableHeaderMenu.hide": "Ocultar", "TableHeaderMenu.insert-left": "Insertar a la izquierda", "TableHeaderMenu.insert-right": "Insertar a la derecha", "TableHeaderMenu.sort-ascending": "Orden ascendente", "TableHeaderMenu.sort-descending": "Orden descendente", "TableRow.open": "Abrir", "TopBar.give-feedback": "Dar feedback", "ValueSelector.valueSelector": "Valorar el selector", "ValueSelectorLabel.openMenu": "Abrir menú", "View.AddView": "Añadir vista", "View.Board": "Panel", "View.DeleteView": "Eliminar vista", "View.DuplicateView": "Duplicar vista", "View.NewBoardTitle": "Vista de panel", "View.NewGalleryTitle": "Vista de galería", "View.NewTableTitle": "Vista de tabla", "View.Table": "Tabla", "ViewHeader.add-template": "+ Nueva plantilla", "ViewHeader.delete-template": "Borrar", "ViewHeader.edit-template": "Editar", "ViewHeader.empty-card": "Tarjeta vacía", "ViewHeader.export-board-archive": "Exportar archivo de tablero", "ViewHeader.export-complete": "¡Se ha completado la exportación!", "ViewHeader.export-csv": "Exportar a CSV", "ViewHeader.export-failed": "¡Ha fallado la exportación!", "ViewHeader.filter": "Filtrar", "ViewHeader.group-by": "Agrupar por: {property}", "ViewHeader.new": "Nueva", "ViewHeader.properties": "Propiedades", "ViewHeader.search-text": "Texto de búsqueda", "ViewHeader.select-a-template": "Seleccionar una plantilla", "ViewHeader.sort": "Ordenar", "ViewHeader.untitled": "Sin título", "ViewTitle.hide-description": "ocultar descripción", "ViewTitle.pick-icon": "Escoger Icono", "ViewTitle.random-icon": "Aleatorio", "ViewTitle.remove-icon": "Quitar Icono", "ViewTitle.show-description": "mostrar descripción", "ViewTitle.untitled-board": "Panel sin título", "default-properties.title": "Título" } ================================================ FILE: webapp/i18n/et.json ================================================ { "BoardComponent.add-a-group": "+ Lisa grupp", "BoardComponent.delete": "Kustuta", "BoardComponent.hidden-columns": "Peidetud veerud", "BoardComponent.hide": "Peida", "BoardComponent.new": "+ Uus", "BoardComponent.no-property": "{property} pole", "BoardComponent.show": "Näita", "BoardsUnfurl.Updated": "Uuendatud {time}", "Calculations.Options.average.displayName": "Keskmine", "Calculations.Options.average.label": "Keskmine", "Calculations.Options.count.displayName": "Arv", "Calculations.Options.count.label": "Arv", "Calculations.Options.countUniqueValue.displayName": "Unikaalne", "Calculations.Options.countValue.displayName": "Väärtused", "Calculations.Options.dateRange.displayName": "Vahemik", "Calculations.Options.dateRange.label": "Vahemik", "Calculations.Options.earliest.displayName": "Varaseim", "Calculations.Options.earliest.label": "Varaseim", "Calculations.Options.latest.displayName": "Viimased", "Calculations.Options.latest.label": "Viimased", "Calculations.Options.max.displayName": "Maks", "Calculations.Options.max.label": "Maks", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Arvuta", "Calculations.Options.none.label": "Pole", "Calculations.Options.range.displayName": "Vahemik", "Calculations.Options.range.label": "Vahemik", "Calculations.Options.sum.displayName": "Sum", "Calculations.Options.sum.label": "Sum", "CardDetail.Follow": "Jälgi", "CardDetail.Following": "Jälgimisel", "CardDetail.add-content": "Lisa sisu", "CardDetail.add-icon": "Lisa ikoon", "CardDetail.add-property": "+ Lisa omadus", "CardDetail.addCardText": "lisa kaardi tekst", "CardDetail.moveContent": "liiguta kaardi sisu", "CardDetail.new-comment-placeholder": "Lisa kommentaar...", "CardDetailProperty.confirm-delete-heading": "Kinnita omaduse kustutamine", "CardDetailProperty.delete-action-button": "Kustuta", "CardDetailProperty.property-change-action-button": "Muuda omadust", "CardDialog.editing-template": "Sa muudad malli.", "CardDialog.nocard": "Seda kaarti pole olemas või see pole ligipääsetav.", "Comment.delete": "Kustuta", "CommentsList.send": "Saada", "ConfirmationDialog.cancel-action": "Loobu", "ConfirmationDialog.confirm-action": "Kinnita", "ContentBlock.Delete": "Kustuta", "ContentBlock.DeleteAction": "Kustuta", "ContentBlock.addElement": "lisa {type}", "ContentBlock.divider": "eraldaja", "ContentBlock.editCardCheckboxText": "lisa kaardi tekst", "ContentBlock.editCardText": "muuda kaardi teksti", "ContentBlock.editText": "Muuda teksti...", "ContentBlock.image": "pilt", "ContentBlock.insertAbove": "Sisesta üles", "ContentBlock.moveDown": "Liiguta alla", "ContentBlock.moveUp": "Liiguta üles", "ContentBlock.text": "tekst", "DeleteBoardDialog.confirm-cancel": "Loobu", "DeleteBoardDialog.confirm-delete": "Kustuta", "EditableDayPicker.today": "Täna", "Filter.includes": "sisaldab", "Filter.is-empty": "on tühi", "Filter.is-not-empty": "pole tühi", "Filter.not-includes": "ei sisalda", "FilterComponent.add-filter": "+ Lisa filter", "FilterComponent.delete": "Kustuta", "KanbanCard.untitled": "Nimetu", "Mutator.new-card-from-template": "malli põhjal uus kaart", "PropertyMenu.Delete": "Kustuta", "PropertyMenu.typeTitle": "Liik", "PropertyType.CreatedBy": "Lisas", "PropertyType.CreatedTime": "Lisamise aeg", "PropertyType.Date": "Kuupäev", "PropertyType.Email": "E-post", "PropertyType.MultiSelect": "Mitme valimine", "PropertyType.Number": "Number", "PropertyType.Person": "Isik", "PropertyType.Phone": "Telefon", "PropertyType.Select": "Vali", "PropertyType.Text": "Tekst", "PropertyType.UpdatedBy": "Viimati uuendati", "PropertyType.UpdatedTime": "Viimane uuendamise aeg", "PropertyValueElement.empty": "Tühi", "RegistrationLink.copiedLink": "Kopeeritud!", "RegistrationLink.copyLink": "Kopeeri link", "ShareBoard.copiedLink": "Kopeeritud!", "ShareBoard.copyLink": "Kopeeri link", "Sidebar.about": "Focalboardi info", "Sidebar.changePassword": "Muuda parooli", "Sidebar.invite-users": "Kutsu kasutajaid", "Sidebar.logout": "Logi välja", "Sidebar.random-icons": "Juhuslikud ikoonid", "Sidebar.set-language": "Määra keel", "Sidebar.set-theme": "Määra kujundus", "Sidebar.settings": "Seaded", "TableComponent.add-icon": "Lisa ikoon", "TableComponent.name": "Nimi", "TableComponent.plus-new": "+ Uus", "TableHeaderMenu.delete": "Kustuta", "TableHeaderMenu.duplicate": "Tee koopia", "TableHeaderMenu.hide": "Peida", "TableHeaderMenu.insert-left": "Sisesta vasakule", "TableHeaderMenu.insert-right": "Sisesta paremale", "TableHeaderMenu.sort-ascending": "Sorteeri kasvavalt", "TableHeaderMenu.sort-descending": "Sorteeri kahanevalt", "TableRow.open": "Ava", "TopBar.give-feedback": "Anna tagasisidet", "ValueSelector.valueSelector": "Väärtuse valija", "ValueSelectorLabel.openMenu": "Ava menüü", "View.AddView": "Lisa vaade", "View.DeleteView": "Kustuta vaade", "View.DuplicateView": "Tee vaatest koopia", "View.Gallery": "Galerii", "View.NewCalendarTitle": "Kalendri vaade", "View.NewGalleryTitle": "Galerii vaade", "View.NewTableTitle": "Tabeli vaade", "View.Table": "Tabel", "ViewHeader.add-template": "Uus mall", "ViewHeader.delete-template": "Kustuta", "ViewHeader.edit-template": "Muuda", "ViewHeader.empty-card": "Tühi kaart", "ViewHeader.export-complete": "Eksportimine on valmis!", "ViewHeader.export-csv": "Ekspordi CSV-na", "ViewHeader.export-failed": "Eksportimine ebaõnnestus!", "ViewHeader.filter": "Filter", "ViewHeader.group-by": "Grupeeri: {property}", "ViewHeader.new": "Uus", "ViewHeader.properties": "Omadused", "ViewHeader.search-text": "Otsi teksti", "ViewHeader.select-a-template": "Vali mall", "ViewHeader.set-default-template": "Määra vaikeväärtuseks", "ViewHeader.sort": "Sorteeri", "ViewHeader.untitled": "Nimetu", "ViewTitle.hide-description": "peida kirjeldus", "ViewTitle.pick-icon": "Vali ikoon", "ViewTitle.random-icon": "Juhuslik", "ViewTitle.remove-icon": "Eemalda ikoon", "ViewTitle.show-description": "näita kirjeldust", "calendar.month": "Kuu", "calendar.today": "TÄNA", "calendar.week": "Nädal", "default-properties.title": "Pealkiri", "login.log-in-button": "Logi sisse", "login.log-in-title": "Logi sisse", "login.register-button": "või loo konto, kui sul seda veel pole", "register.login-button": "või logi sisse, kui sul juba on konto", "register.signup-title": "Loo omale konto" } ================================================ FILE: webapp/i18n/fa.json ================================================ { "AppBar.Tooltip": "تغییر وضعیت تخته‌های مرتبط", "Attachment.Attachment-title": "ضمیمه", "AttachmentBlock.DeleteAction": "حذف", "AttachmentBlock.addElement": "افزودن {type}", "AttachmentBlock.delete": "ضمیمه حذف شد.", "AttachmentBlock.failed": "به دلیل محدودیت حجم، این پرونده نمی‌تواند بارگذاری شود.", "AttachmentBlock.upload": "بارگذاری ضمیمه.", "AttachmentBlock.uploadSuccess": "ضمیمه بارگذاری شد.", "AttachmentElement.delete-confirmation-dialog-button-text": "حذف", "AttachmentElement.download": "بارگیری", "AttachmentElement.upload-percentage": "بارگذاری...({uploadPercent}%)", "BoardComponent.add-a-group": "+ افزودن گروه", "BoardComponent.delete": "حذف", "BoardComponent.hidden-columns": "ستون های مخفی", "BoardComponent.hide": "مخفی", "BoardComponent.new": "+ جدید", "BoardComponent.no-property": "بدون {property}", "BoardComponent.no-property-title": "موارد با {property} خالی اینجا نمایش داده میشوند. این ستون قابل حذف نیست.", "BoardComponent.show": "نمایش", "BoardMember.schemeAdmin": "مدیر", "BoardMember.schemeEditor": "ویرایشگر", "BoardMember.schemeNone": "هیچکدام", "BoardMember.schemeViewer": "بیننده", "BoardPage.newVersion": "نسخه جدیدی از برنامه Boards موجود است، برای بارگیری مجدد اینجا را کلیک کنید.", "BoardPage.syncFailed": "تابلو ممکن است حذف شود یا دسترسی آن لغو شود.", "BoardTemplateSelector.add-template": "ایجاد قالب جدید", "BoardTemplateSelector.create-empty-board": "ایجاد تابلو خالی", "BoardTemplateSelector.delete-template": "حذف", "BoardTemplateSelector.description": "با استفاده از الگوهای زیر، یک تابلو به نوار کناری اضافه کنید یا از اول شروع کنید.", "BoardTemplateSelector.edit-template": "ویرایش", "BoardTemplateSelector.plugin.no-content-description": "با استفاده از الگوهای زیر، یک تابلو به نوار کناری اضافه کنید یا از اول شروع کنید.", "BoardTemplateSelector.plugin.no-content-title": "ایجاد یک تابلو", "BoardTemplateSelector.title": "ایجاد یک تابلو", "BoardTemplateSelector.use-this-template": "از این قالب استفاده کنید", "BoardsSwitcher.Title": "جستجوی تابلوها", "BoardsUnfurl.Remainder": "+{remainder} بیشتر", "BoardsUnfurl.Updated": "به روز شد {time}", "Calculations.Options.average.displayName": "میانگین", "Calculations.Options.average.label": "میانگین", "Calculations.Options.count.displayName": "تعداد", "Calculations.Options.count.label": "تعداد", "Calculations.Options.countChecked.displayName": "نشان‌دار", "Calculations.Options.countChecked.label": "تعداد نشان‌دارها", "Calculations.Options.countUnchecked.displayName": "بی‌نشان", "Calculations.Options.countUnchecked.label": "تعداد نشان‌دارها", "Calculations.Options.countUniqueValue.displayName": "یکتا", "Calculations.Options.countUniqueValue.label": "تعداد مقادیر یکتا", "Calculations.Options.countValue.displayName": "مقادیر", "Calculations.Options.countValue.label": "تعداد مقادیر", "Calculations.Options.dateRange.displayName": "دامنه", "Calculations.Options.dateRange.label": "دامنه", "Calculations.Options.earliest.displayName": "اولین", "Calculations.Options.earliest.label": "اولین", "Calculations.Options.latest.displayName": "آخرین", "Calculations.Options.latest.label": "آخرین", "Calculations.Options.max.displayName": "حداکثر", "Calculations.Options.max.label": "حداکثر", "Calculations.Options.median.displayName": "میانه", "Calculations.Options.median.label": "میانه", "Calculations.Options.min.displayName": "حداقل", "Calculations.Options.min.label": "حداقل", "Calculations.Options.none.displayName": "محاسبه", "Calculations.Options.none.label": "هیچ یک", "Calculations.Options.percentChecked.displayName": "بررسی شد", "Calculations.Options.percentChecked.label": "درصد انتخاب‌شده‌ها", "Calculations.Options.percentUnchecked.displayName": "بدون علامت", "Calculations.Options.percentUnchecked.label": "درصد بی‌نشان", "Calculations.Options.range.displayName": "بازه", "Calculations.Options.range.label": "بازه", "Calculations.Options.sum.displayName": "جمع", "Calculations.Options.sum.label": "جمع", "CardBadges.title-checkboxes": "چک باکس ها", "CardBadges.title-comments": "نظرات", "CardBadges.title-description": "این کارت دارای توضیحات است", "CardDetail.Follow": "دنبال کردن", "CardDetail.Following": "ذیل", "CardDetail.add-content": "محتوا اضافه کنید", "CardDetail.add-icon": "اضافه کردن نماد", "CardDetail.add-property": "+ اضافه کردن یک ویژگی", "CardDetail.addCardText": "متن کارت را اضافه کنید", "CardDetail.moveContent": "انتقال محتوای کارت", "CardDetail.new-comment-placeholder": "افزودن نظر...", "CardDetailProperty.confirm-delete-heading": "تایید حذف ویژگی", "CardDetailProperty.confirm-delete-subtext": "آیا مطمئن هستید که می خواهید ویژگی \"{propertyName}\" را حذف کنید؟ با حذف آن، اموال از تمام کارت های موجود در این تابلو حذف می شود.", "CardDetailProperty.confirm-property-name-change-subtext": "آیا مطمئن هستید که می خواهید ویژگی \"{propertyName}\" {customText} را تغییر دهید؟ این روی مقدار(های) کارت(های) {numOfCards} در این برد تأثیر می گذارد و می تواند منجر به از دست رفتن داده شود.", "CardDetailProperty.confirm-property-type-change": "تایید تغییر نوع ویژگی", "CardDetailProperty.delete-action-button": "حذف", "CardDetailProperty.property-change-action-button": "تغییر ویژگی", "CardDetailProperty.property-changed": "تغییر ویژگی با موفقیت!", "CardDetailProperty.property-deleted": "{propertyName} با موفقیت حذف شد!", "CardDetailProperty.property-name-change-subtext": "از \"{oldPropType}\" به \"{newPropType}\" تایپ کنید", "CardDialog.delete-confirmation-dialog-button-text": "حذف", "CardDialog.delete-confirmation-dialog-heading": "تایید حذف کارت", "CardDialog.editing-template": "شما در حال ویرایش یک الگو هستید.", "CardDialog.nocard": "این کارت وجود ندارد یا غیرقابل دسترسی است.", "Categories.CreateCategoryDialog.CancelText": "لغو کنید", "Categories.CreateCategoryDialog.CreateText": "ايجاد كردن", "Categories.CreateCategoryDialog.Placeholder": "دسته خود را نام ببرید", "Categories.CreateCategoryDialog.UpdateText": "به روز رسانی", "CenterPanel.Login": "وارد شدن", "CenterPanel.Share": "اشتراک گذاری", "ColorOption.selectColor": "رنگ {color} را انتخاب کنید", "Comment.delete": "حذف", "CommentsList.send": "ارسال", "ConfirmationDialog.cancel-action": "لغو کنید", "ConfirmationDialog.confirm-action": "تایید", "ContentBlock.Delete": "حذف", "ContentBlock.DeleteAction": "حذف", "GroupBy.hideEmptyGroups": "پنهان‌کردن {count} گروه خالی", "GroupBy.showHiddenGroups": "نمایش {count} گروه پنهان‌شده", "ViewHeader.view-menu": "نمایش فهرست", "ViewLimitDialog.Heading": "محدودیت تعداد نما برای تخته رسید", "ViewLimitDialog.PrimaryButton.Title.Admin": "ارتقا", "ViewLimitDialog.UpgradeImg.AltText": "ارتقا عکس", "ViewLimitDialog.notifyAdmin.Success": "مدیر شما مطلع شد", "ViewTitle.hide-description": "پنهان کردن توضیحات", "ViewTitle.pick-icon": "انتخاب تصویرک", "ViewTitle.random-icon": "تصادفی", "ViewTitle.remove-icon": "پاک‌کردن تصویرک", "ViewTitle.show-description": "نمایش توضیحات", "ViewTitle.untitled-board": "تخته بدون عنوان", "badge.guest": "مهمان" } ================================================ FILE: webapp/i18n/fr.json ================================================ { "AppBar.Tooltip": "Activer les panneaux liés", "BoardComponent.add-a-group": "+ Ajouter un groupe", "BoardComponent.delete": "Supprimer", "BoardComponent.hidden-columns": "Colonnes cachées", "BoardComponent.hide": "Cacher", "BoardComponent.new": "+ Nouveau", "BoardComponent.no-property": "Pas de {property}", "BoardComponent.no-property-title": "Les éléments sans propriété {property} seront placés ici. Cette colonne ne peut pas être supprimée.", "BoardComponent.show": "Montrer", "BoardMember.schemeAdmin": "Admin", "BoardMember.schemeCommenter": "Commentateur", "BoardMember.schemeEditor": "Éditeur", "BoardMember.schemeNone": "Aucun", "BoardMember.schemeViewer": "Lecteur", "BoardMember.unlinkChannel": "Détacher", "BoardPage.newVersion": "Une nouvelle version de Boards est disponible, cliquez ici pour recharger.", "BoardPage.syncFailed": "Le tableau a peut-être été supprimé ou vos droits d'accès révoqués.", "BoardTemplateSelector.add-template": "Nouveau modèle", "BoardTemplateSelector.create-empty-board": "Créer un tableau vide", "BoardTemplateSelector.delete-template": "Supprimer", "BoardTemplateSelector.description": "Ajoutez un tableau à la barre latérale en utilisant l'un des modèles définis ci-dessous ou recommencez de zéro.", "BoardTemplateSelector.edit-template": "Éditer", "BoardTemplateSelector.plugin.no-content-description": "Ajouter un tableau à la barre latérale en utilisant l'un des modèles ci-dessous ou commencer à partir de zéro.", "BoardTemplateSelector.plugin.no-content-title": "Créer un tableau", "BoardTemplateSelector.title": "Créer un tableau", "BoardTemplateSelector.use-this-template": "Utiliser ce modèle", "BoardsSwitcher.Title": "Rechercher des tableaux", "BoardsUnfurl.Limited": "Les détails supplémentaires sont masqués car la carte est archivée", "BoardsUnfurl.Remainder": "+{remainder} plus", "BoardsUnfurl.Updated": "Mis à jour {time}", "Calculations.Options.average.displayName": "Moyenne", "Calculations.Options.average.label": "Moyenne", "Calculations.Options.count.displayName": "Compter", "Calculations.Options.count.label": "Compter", "Calculations.Options.countChecked.displayName": "Coché", "Calculations.Options.countChecked.label": "Total coché", "Calculations.Options.countUnchecked.displayName": "Décoché", "Calculations.Options.countUnchecked.label": "Total décoché", "Calculations.Options.countUniqueValue.displayName": "Unique", "Calculations.Options.countUniqueValue.label": "Compter les valeurs uniques", "Calculations.Options.countValue.displayName": "Valeurs", "Calculations.Options.countValue.label": "Calculer la valeur", "Calculations.Options.dateRange.displayName": "Il y a", "Calculations.Options.dateRange.label": "Intervalle", "Calculations.Options.earliest.displayName": "Plus ancien", "Calculations.Options.earliest.label": "Plus ancien", "Calculations.Options.latest.displayName": "Plus récent", "Calculations.Options.latest.label": "Plus récent", "Calculations.Options.max.displayName": "Maximum", "Calculations.Options.max.label": "Maximum", "Calculations.Options.median.displayName": "Médiane", "Calculations.Options.median.label": "Médiane", "Calculations.Options.min.displayName": "Minimum", "Calculations.Options.min.label": "Minimum", "Calculations.Options.none.displayName": "Calculer", "Calculations.Options.none.label": "Aucun", "Calculations.Options.percentChecked.displayName": "Coché", "Calculations.Options.percentChecked.label": "Pourcentage coché", "Calculations.Options.percentUnchecked.displayName": "Décoché", "Calculations.Options.percentUnchecked.label": "Pourcentage décoché", "Calculations.Options.range.displayName": "Curseur", "Calculations.Options.range.label": "Curseur", "Calculations.Options.sum.displayName": "Somme", "Calculations.Options.sum.label": "Somme", "CalendarCard.untitled": "Sans titre", "CardActionsMenu.copiedLink": "Copié !", "CardActionsMenu.copyLink": "Copier le lien", "CardActionsMenu.delete": "Supprimer", "CardActionsMenu.duplicate": "Dupliquer", "CardBadges.title-checkboxes": "Cases à cocher", "CardBadges.title-comments": "Commentaires", "CardBadges.title-description": "Cette carte a une description", "CardDetail.Follow": "Suivre", "CardDetail.Following": "Suivi", "CardDetail.add-content": "Ajouter du contenu", "CardDetail.add-icon": "Ajouter une icône", "CardDetail.add-property": "+ Ajouter une propriété", "CardDetail.addCardText": "ajouter une carte texte", "CardDetail.limited-body": "Passez à notre offre Professionnel ou Entreprise pour afficher les cartes archivées, avoir des vues illimitées par tableau, des cartes illimitées et plus encore.", "CardDetail.limited-button": "Mise à niveau", "CardDetail.limited-title": "Cette carte est masquée", "CardDetail.moveContent": "Déplacer le contenu de la carte", "CardDetail.new-comment-placeholder": "Ajouter un commentaire...", "CardDetailProperty.confirm-delete-heading": "Confirmer la suppression de la propriété", "CardDetailProperty.confirm-delete-subtext": "Êtes-vous sûr de vouloir supprimer la propriété « {propertyName} » ? La suppression retirera la propriété de toutes les cartes dans ce tableau.", "CardDetailProperty.confirm-property-name-change-subtext": "Voulez-vous vraiment modifier le type de la propriété \"{propertyName}\" {customText} ? Cela affectera la ou les valeur(s) sur {numOfCards} carte(s) dans ce tableau et peut entraîner une perte de données.", "CardDetailProperty.confirm-property-type-change": "Confirmer le changement de type de propriété", "CardDetailProperty.delete-action-button": "Supprimer", "CardDetailProperty.property-change-action-button": "Modifier la propriété", "CardDetailProperty.property-changed": "Propriété modifiée avec succès !", "CardDetailProperty.property-deleted": "{propertyName} supprimé avec succès !", "CardDetailProperty.property-name-change-subtext": "de \"{oldPropType}\" à \"{newPropType}\"", "CardDetial.limited-link": "En savoir plus sur nos offres.", "CardDialog.delete-confirmation-dialog-button-text": "Supprimer", "CardDialog.delete-confirmation-dialog-heading": "Confirmer la suppression de la carte !", "CardDialog.editing-template": "Vous éditez un modèle.", "CardDialog.nocard": "Cette carte n'existe pas ou n'est pas accessible.", "Categories.CreateCategoryDialog.CancelText": "Annuler", "Categories.CreateCategoryDialog.CreateText": "Créer", "Categories.CreateCategoryDialog.Placeholder": "Nommez votre catégorie", "Categories.CreateCategoryDialog.UpdateText": "Mettre à jour", "CenterPanel.Login": "Connexion", "CenterPanel.Share": "Partager", "ColorOption.selectColor": "Choisir la couleur {color}", "Comment.delete": "Supprimer", "CommentsList.send": "Envoyer", "ConfirmationDialog.cancel-action": "Annuler", "ConfirmationDialog.confirm-action": "Confirmer", "ContentBlock.Delete": "Supprimer", "ContentBlock.DeleteAction": "supprimer", "ContentBlock.addElement": "ajouter {type}", "ContentBlock.checkbox": "case à cocher", "ContentBlock.divider": "séparateur", "ContentBlock.editCardCheckbox": "case cochée", "ContentBlock.editCardCheckboxText": "éditer le texte de la carte", "ContentBlock.editCardText": "éditer le texte de la carte", "ContentBlock.editText": "Éditer le texte...", "ContentBlock.image": "image", "ContentBlock.insertAbove": "Insérer au-dessus", "ContentBlock.moveDown": "Déplacer vers le bas", "ContentBlock.moveUp": "Déplacer vers le haut", "ContentBlock.text": "texte", "DateRange.clear": "Supprimer", "DateRange.empty": "Vide", "DateRange.endDate": "Date de fin", "DateRange.today": "Aujourd'hui", "DeleteBoardDialog.confirm-cancel": "Annuler", "DeleteBoardDialog.confirm-delete": "Supprimer", "DeleteBoardDialog.confirm-info": "Êtes-vous sûr de vouloir supprimer le tableau «{boardTitle}» ? Cela supprimera toutes les cartes dans ce tableau.", "DeleteBoardDialog.confirm-info-template": "Voulez-vous vraiment supprimer le modèle de tableau “{boardTitle}” ?", "DeleteBoardDialog.confirm-tite": "Confirmer la suppression du tableau", "DeleteBoardDialog.confirm-tite-template": "Confirmer la suppression du modèle", "Dialog.closeDialog": "Fermer la boîte de dialogue", "EditableDayPicker.today": "Aujourd'hui", "Error.mobileweb": "La prise en charge de la version mobile est actuellement en version bêta. Certaines fonctionnalités peuvent manquer.", "Error.websocket-closed": "Connexion au websocket fermé, connexion interrompue. Si cela persiste, vérifiez la configuration de votre serveur ou de votre proxy web.", "Filter.contains": "contient", "Filter.ends-with": "se termine par", "Filter.includes": "inclus", "Filter.is": "est", "Filter.is-empty": "est vide", "Filter.is-not-empty": "n'est pas vide", "Filter.is-not-set": "n'est pas renseigné", "Filter.is-set": "est renseigné", "Filter.not-contains": "ne contient pas", "Filter.not-ends-with": "ne se termine pas par", "Filter.not-includes": "n'inclut pas", "Filter.not-starts-with": "ne commence pas par", "Filter.starts-with": "commence par", "FilterByText.placeholder": "filtre de texte", "FilterComponent.add-filter": "+ Ajouter un filtre", "FilterComponent.delete": "Supprimer", "FindBoardsDialog.IntroText": "Rechercher des tableaux", "FindBoardsDialog.NoResultsFor": "Pas de résultats pour \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Vérifiez l'orthographe ou essayez une autre recherche.", "FindBoardsDialog.SubTitle": "Recherchez ci-dessous pour trouver un tableau. Utilisez HAUT/BAS pour naviguer. ENTRER pour sélectionner, ECHAP pour annuler", "FindBoardsDialog.Title": "Rechercher des tableaux", "GroupBy.hideEmptyGroups": "Masquer {count} groupes vides", "GroupBy.showHiddenGroups": "Afficher les {count} groupes masqués", "GroupBy.ungroup": "Dégrouper", "HideBoard.MenuOption": "Cacher le tableau", "KanbanCard.untitled": "Sans titre", "Mutator.new-board-from-template": "nouveau tableau à partir du modèle", "Mutator.new-card-from-template": "nouvelle carte depuis un modèle", "Mutator.new-template-from-card": "nouveau modèle depuis une carte", "OnboardingTour.AddComments.Body": "Vous pouvez commenter les bugs et même @mentionner les utilisateurs de Mattermost pour attirer leur attention.", "OnboardingTour.AddComments.Title": "Ajouter des commentaires", "OnboardingTour.AddDescription.Body": "Ajouter une description à votre carte pour que votre équipe sachent de quoi il s'agit.", "OnboardingTour.AddDescription.Title": "Ajouter une description", "OnboardingTour.AddProperties.Body": "Ajouter diverses propriétés aux cartes pour les rendre plus efficace !", "OnboardingTour.AddProperties.Title": "Ajouter des propriétés", "OnboardingTour.AddView.Body": "Allez ici pour créer une nouvelle vue pour organiser votre tableau en utilisant différentes mises en page.", "OnboardingTour.AddView.Title": "Ajouter une nouvelle vue", "OnboardingTour.CopyLink.Body": "Vous pouvez partager vos cartes avec votre équipe en copiant le lien et en le collant dans un canal, un message direct ou un message de groupe.", "OnboardingTour.CopyLink.Title": "Copier le lien", "OnboardingTour.OpenACard.Body": "Ouvrir une carte pour découvrir les façons dont les tableaux peuvent vous aider à organiser votre travail.", "OnboardingTour.OpenACard.Title": "Ouvrir une carte", "OnboardingTour.ShareBoard.Body": "Vous pouvez partager votre tableau en interne, au sein de votre équipe ou le publier publiquement pour une visibilité en dehors de votre organisation.", "OnboardingTour.ShareBoard.Title": "Partager un tableau", "PropertyMenu.Delete": "Supprimer", "PropertyMenu.changeType": "Changer le type de la propriété", "PropertyMenu.selectType": "Sélectionner le type de propriété", "PropertyMenu.typeTitle": "Type", "PropertyType.Checkbox": "Case à cocher", "PropertyType.CreatedBy": "Créé par", "PropertyType.CreatedTime": "Date de création", "PropertyType.Date": "Date", "PropertyType.Email": "Adresse e-mail", "PropertyType.MultiSelect": "Sélection multiple", "PropertyType.Number": "Nombre", "PropertyType.Person": "Personne", "PropertyType.Phone": "Téléphone", "PropertyType.Select": "Liste", "PropertyType.Text": "Texte", "PropertyType.Unknown": "Inconnue", "PropertyType.UpdatedBy": "Dernière mise à jour par", "PropertyType.UpdatedTime": "Date de dernière mise à jour", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Vide", "RegistrationLink.confirmRegenerateToken": "Ceci va désactiver les liens de partages existants. Continuer ?", "RegistrationLink.copiedLink": "Copié !", "RegistrationLink.copyLink": "Copier le lien", "RegistrationLink.description": "Partagez ce lien avec des personnes pour leur permettre de créer un compte :", "RegistrationLink.regenerateToken": "Générer un nouveau jeton", "RegistrationLink.tokenRegenerated": "Un nouveau lien d'inscription a été créé", "ShareBoard.PublishDescription": "Publiez et partagez un lien en lecture seule avec tout le monde sur le Web.", "ShareBoard.PublishTitle": "Publier sur le web", "ShareBoard.ShareInternal": "Partager en interne", "ShareBoard.ShareInternalDescription": "Les utilisateurs qui ont des autorisations pourront utiliser ce lien.", "ShareBoard.Title": "Partager le tableau", "ShareBoard.confirmRegenerateToken": "Ceci va désactiver les liens de partages existants. Continuer ?", "ShareBoard.copiedLink": "Copié !", "ShareBoard.copyLink": "Copier le lien", "ShareBoard.regenerate": "Régénérer le jeton", "ShareBoard.searchPlaceholder": "Rechercher des membres et des canaux", "ShareBoard.teamPermissionsText": "Tout le monde à l'équipe {teamName}", "ShareBoard.tokenRegenrated": "Le jeton a été recréé", "ShareBoard.userPermissionsRemoveMemberText": "Supprimer un membre", "ShareBoard.userPermissionsYouText": "(Vous)", "ShareTemplate.Title": "Partager un modèle", "ShareTemplate.searchPlaceholder": "Recherche de personnes", "Sidebar.about": "À propos de Focalboard", "Sidebar.add-board": "+ Ajouter un tableau", "Sidebar.changePassword": "Modifier le mot de passe", "Sidebar.delete-board": "Supprimer le tableau", "Sidebar.duplicate-board": "Dupliquer une carte", "Sidebar.export-archive": "Exporter une archive", "Sidebar.import": "Importer", "Sidebar.import-archive": "Importer une archive", "Sidebar.invite-users": "Inviter des utilisateurs", "Sidebar.logout": "Se déconnecter", "Sidebar.no-boards-in-category": "Aucun tableaux", "Sidebar.product-tour": "Visite guidée", "Sidebar.random-icons": "Icônes aléatoires", "Sidebar.set-language": "Choisir la langue", "Sidebar.set-theme": "Choisir le thème", "Sidebar.settings": "Réglages", "Sidebar.template-from-board": "Nouveau modèle de tableau", "Sidebar.untitled-board": "(Tableau sans titre)", "Sidebar.untitled-view": "(Vue sans titre)", "SidebarCategories.BlocksMenu.Move": "Déplacer vers ...", "SidebarCategories.CategoryMenu.CreateNew": "Créer une nouvelle catégorie", "SidebarCategories.CategoryMenu.Delete": "Supprimer la catégorie", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Les tableaux de {categoryName} reviendront à la catégorie par défaut. Aucun des tableaux ne seront supprimés.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Supprimer cette catégorie ?", "SidebarCategories.CategoryMenu.Update": "Renommer la catégorie", "SidebarTour.ManageCategories.Body": "Créez et gérez des catégories personnalisées. Les catégories sont spécifiques à l'utilisateur, donc déplacer un tableau vers votre catégorie n'aura pas d'impact sur les autres membres utilisant le même tableau.", "SidebarTour.ManageCategories.Title": "Gérer les catégories", "SidebarTour.SearchForBoards.Body": "Ouvrez le sélecteur de tableau (Cmd/Ctrl + K) pour rechercher et ajouter rapidement des tableaux à votre barre latérale.", "SidebarTour.SearchForBoards.Title": "Rechercher des cartes", "SidebarTour.SidebarCategories.Body": "Tous vos tableaux sont maintenant organisés sous votre nouvelle barre latérale. Plus besoin de basculer entre les espaces de travail. Des catégories personnalisées uniques basées sur vos espaces de travail précédents peuvent avoir été automatiquement créées pour vous dans le cadre de la mise à jour 7.2. Ceux-ci peuvent être supprimés ou modifiés selon vos préférences.", "SidebarTour.SidebarCategories.Link": "En savoir plus", "SidebarTour.SidebarCategories.Title": "Catégories de la barre latérale", "TableComponent.add-icon": "Ajouter une icône", "TableComponent.name": "Nom", "TableComponent.plus-new": "+ Nouveau", "TableHeaderMenu.delete": "Supprimer", "TableHeaderMenu.duplicate": "Dupliquer", "TableHeaderMenu.hide": "Cacher", "TableHeaderMenu.insert-left": "Insérer à gauche", "TableHeaderMenu.insert-right": "Insérer à droite", "TableHeaderMenu.sort-ascending": "Tri ascendant", "TableHeaderMenu.sort-descending": "Tri descendant", "TableRow.open": "Ouvrir", "TopBar.give-feedback": "Donner un avis", "URLProperty.copiedLink": "Copié !", "URLProperty.copy": "Copie", "URLProperty.edit": "Modifier", "UndoRedoHotKeys.canRedo": "Refaire", "UndoRedoHotKeys.canRedo-with-description": "Refaire {description}", "UndoRedoHotKeys.canUndo": "Annuler", "UndoRedoHotKeys.canUndo-with-description": "Annuler {description}", "UndoRedoHotKeys.cannotRedo": "Rien à refaire", "UndoRedoHotKeys.cannotUndo": "Rien à annuler", "ValueSelector.noOptions": "Aucune option. Commencez à taper pour ajouter la première !", "ValueSelector.valueSelector": "Sélecteur de value", "ValueSelectorLabel.openMenu": "Ouvrir le menu", "VersionMessage.help": "Découvrez les nouveautés de cette version.", "View.AddView": "Ajouter une vue", "View.Board": "Tableau", "View.DeleteView": "Supprimer la vue", "View.DuplicateView": "Dupliquer la vue", "View.Gallery": "Galerie", "View.NewBoardTitle": "Vue en tableau", "View.NewCalendarTitle": "Vue calendrier", "View.NewGalleryTitle": "Vue en galerie", "View.NewTableTitle": "Vue en table", "View.NewTemplateDefaultTitle": "Modèle sans titre", "View.NewTemplateTitle": "Sans titre", "View.Table": "Table", "ViewHeader.add-template": "Nouveau modèle", "ViewHeader.delete-template": "Supprimer", "ViewHeader.display-by": "Afficher par : {property}", "ViewHeader.edit-template": "Éditer", "ViewHeader.empty-card": "Carte vide", "ViewHeader.export-board-archive": "Exporter une archive du tableau", "ViewHeader.export-complete": "Export terminé !", "ViewHeader.export-csv": "Exporter au format CSV", "ViewHeader.export-failed": "L'export a échoué !", "ViewHeader.filter": "Filtre", "ViewHeader.group-by": "Grouper par : {property}", "ViewHeader.new": "Nouveau", "ViewHeader.properties": "Propriétés", "ViewHeader.properties-menu": "Menu propriétés", "ViewHeader.search-text": "Rechercher des cartes", "ViewHeader.select-a-template": "Sélectionner un modèle", "ViewHeader.set-default-template": "Définir par défaut", "ViewHeader.sort": "Trier", "ViewHeader.untitled": "Sans titre", "ViewHeader.view-header-menu": "Afficher le menu d'en-tête", "ViewHeader.view-menu": "Afficher le menu", "ViewLimitDialog.Heading": "Limite de vues par tableau atteinte", "ViewLimitDialog.PrimaryButton.Title.Admin": "Mise à niveau", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Notifier l'Admin", "ViewLimitDialog.Subtext.Admin": "Passez à notre offre Professionnel ou Entreprise pour afficher les cartes archivées, avoir des vues illimitées par tableau, des cartes illimitées et plus encore.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "En savoir plus sur nos offres.", "ViewLimitDialog.Subtext.RegularUser": "Informez votre administrateur qu'il peut passer à notre offre professionnel ou d'entreprise pour avoir un nombre illimité de vues par tableau, un nombre illimité de cartes, et plus encore.", "ViewLimitDialog.UpgradeImg.AltText": "mise à jour de l'image", "ViewLimitDialog.notifyAdmin.Success": "Votre administrateur a été notifié", "ViewTitle.hide-description": "cacher la description", "ViewTitle.pick-icon": "Choisir une icône", "ViewTitle.random-icon": "Aléatoire", "ViewTitle.remove-icon": "Supprimer l'icône", "ViewTitle.show-description": "montrer la description", "ViewTitle.untitled-board": "Tableau sans titre", "WelcomePage.Description": "Boards est un outil de gestion de projet qui permet d'organiser, de suivre et de gérer le travail entre équipes en utilisant des tableaux Kanban.", "WelcomePage.Explore.Button": "Tutoriel", "WelcomePage.Heading": "Bienvenue sur Boards", "WelcomePage.NoThanks.Text": "Non merci, je vais me renseigner moi-même", "WelcomePage.StartUsingIt.Text": "Commencez à l'utiliser", "Workspace.editing-board-template": "Vous éditez un modèle de tableau.", "badge.guest": "Invité", "boardSelector.confirm-link-board": "Lier la carte au canal", "boardSelector.confirm-link-board-button": "Oui, lier ce tableau", "boardSelector.confirm-link-board-subtext": "Lorsque vous liez \"{boardName}\" au canal, tous les membres du canal (existants et nouveaux) pourront le modifier. Vous pouvez dissocier un tableau d'un canal à tout moment.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Lorsque vous liez \"{boardName}\" au canal, tous les membres du canal (existants et nouveaux) pourront le modifier.{lineBreak} Ce tableau est actuellement lié à un autre canal. Il sera dissocié si vous choisissez de le lier ici.", "boardSelector.create-a-board": "Créer un tableau", "boardSelector.link": "Lien", "boardSelector.search-for-boards": "Rechercher des tableaux", "boardSelector.title": "Lier les tableaux", "boardSelector.unlink": "Détacher", "calendar.month": "Mois", "calendar.today": "AUJOURD'HUI", "calendar.week": "Semaine", "cloudMessage.learn-more": "En savoir plus", "createImageBlock.failed": "Impossible de télécharger le fichier. Limite de taille de fichier atteinte.", "default-properties.badges": "Commentaires et description", "default-properties.title": "Titre", "error.back-to-home": "Retour à la page d'accueil", "error.back-to-team": "Retour à l'équipe", "error.board-not-found": "Tableau non trouvé.", "error.go-login": "Connexion", "error.invalid-read-only-board": "Vous n'avez pas accès à ce tableau. Connectez-vous pour y accéder.", "error.not-logged-in": "Votre session a peut-être expiré ou vous n’êtes pas connecté. Connectez-vous à nouveau pour accéder aux tableaux d'administration.", "error.page.title": "Désolé, quelque chose s'est mal passé", "error.team-undefined": "Ce n'est pas une équipe valable.", "error.unknown": "Une erreur s'est produite.", "generic.previous": "Précédent", "guest-no-board.subtitle": "Vous n'avez pas encore accès à un tableau dans cette équipe, veuillez patienter jusqu'à ce que quelqu'un vous ajoute à un tableau.", "guest-no-board.title": "Pas encore de tableaux", "imagePaste.upload-failed": "Certains fichiers n'ont pas été téléchargés. Limite de taille de fichier atteinte", "limitedCard.title": "Cartes cachées", "login.log-in-button": "Connexion", "login.log-in-title": "Connexion", "login.register-button": "ou créez un compte si vous n'en avez pas", "notification-box-card-limit-reached.close-tooltip": "Oublier pendant 10 jours", "notification-box-card-limit-reached.contact-link": "informez votre administrateur", "notification-box-card-limit-reached.link": "Passer à une offre payante", "notification-box-card-limit-reached.title": "{cards} cartes cachées du tableau", "notification-box-cards-hidden.title": "Cette action a caché une autre carte", "notification-box.card-limit-reached.not-admin.text": "Pour accéder aux cartes archivées, vous pouvez {contactLink} pour passer à une offre payante.", "notification-box.card-limit-reached.text": "Limite de cartes atteinte, pour afficher les anciennes cartes, {link}", "person.add-user-to-board": "Ajouter {username} au tableau", "person.add-user-to-board-confirm-button": "Ajouter au tableau", "person.add-user-to-board-permissions": "Permissions", "person.add-user-to-board-question": "Voulez-vous ajouter {username} au tableau ?", "person.add-user-to-board-warning": "{username} n'est pas membre du conseil d'administration et ne recevra aucune notification à ce sujet.", "register.login-button": "ou connectez-vous si vous avez déjà un compte", "register.signup-title": "Inscrivez-vous pour créer un compte", "rhs-board-non-admin-msg": "Vous n'êtes pas administrateur du forum", "rhs-boards.add": "Ajouter", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "ce message direct", "rhs-boards.header.gm": "ce message de groupe", "rhs-boards.last-update-at": "Dernière mise à jour à : {datetime}", "rhs-boards.link-boards-to-channel": "Lier les tableaux à {channelName}", "rhs-boards.linked-boards": "Tableaux liés", "rhs-boards.no-boards-linked-to-channel": "Aucun tableau n'est lié à {channelName} pour le moment", "rhs-boards.no-boards-linked-to-channel-description": "Boards est un outil de gestion de projet qui permet d'organiser, de suivre et de gérer le travail entre équipes en utilisant des tableaux Kanban.", "rhs-boards.unlink-board": "Dissocier le tableau", "rhs-boards.unlink-board1": "Dissocier le tableau", "rhs-channel-boards-header.title": "Tableaux", "share-board.publish": "Publier", "share-board.share": "Partager", "shareBoard.channels-select-group": "Canaux", "shareBoard.confirm-change-team-role.body": "Tous les membres de ce forum avec une autorisation inférieure au rôle \"{role}\" seront maintenant promus au {role}. Êtes-vous sûr de vouloir modifier le rôle minimum ?", "shareBoard.confirm-link-channel": "Lier le tableau au canal", "shareBoard.confirm-link-channel-button": "Lien canal", "shareBoard.confirm-link-channel-button-with-other-channel": "Dissocier et lier ici", "shareBoard.confirm-link-channel-subtext": "Lorsque vous liez une chaîne à un tableau, tous les membres de la chaîne (existants et nouveaux) pourront le modifier.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Lorsque vous liez un canal à un tableau, tous les membres du canal (existants et nouveaux) pourront le modifier.{lineBreak}Ce tableau est actuellement lié à un autre canal. Il sera dissocié si vous choisissez de le lier ici.", "shareBoard.confirm-unlink.body": "Lorsque vous dissociez une chaîne d'un tableau, tous les membres de la chaîne (existants et nouveaux) n'y auront plus accès, sauf s'ils en ont reçu l'autorisation séparément.", "shareBoard.confirm-unlink.confirmBtnText": "Dissocier le canal", "shareBoard.confirm-unlink.title": "Dissocier le canal du tableau", "shareBoard.lastAdmin": "Les conseils doivent avoir au moins un administrateur", "shareBoard.members-select-group": "Membres", "shareBoard.unknown-channel-display-name": "Canal inconnu", "tutorial_tip.finish_tour": "Terminé", "tutorial_tip.got_it": "J'ai compris", "tutorial_tip.ok": "Suivant", "tutorial_tip.out": "Désactiver les conseils.", "tutorial_tip.seen": "Déjà vu ?" } ================================================ FILE: webapp/i18n/he.json ================================================ { "BoardComponent.add-a-group": "+ הוספת קבוצה", "BoardComponent.delete": "מחיקה", "BoardComponent.hidden-columns": "עמודות מוסתרות", "BoardComponent.hide": "הסתרה", "BoardComponent.new": "+ חדש", "BoardComponent.no-property": "ללא {property}", "BoardComponent.no-property-title": "יופיעו כאן פריטים עם מאפיין {property} ריק. עמודה זו אינה ניתנת להסרה.", "BoardComponent.show": "הצגה", "BoardMember.schemeAdmin": "מנהל מערכת", "BoardMember.schemeCommenter": "משתמש מגיב", "BoardMember.schemeEditor": "עורך", "BoardMember.schemeNone": "ללא", "BoardMember.schemeViewer": "צופה", "BoardMember.unlinkChannel": "ניתוק", "BoardPage.newVersion": "ישנה גרסה חדשה וזמינה של לוחות, לטעינה מחדש לחץ כאן.", "BoardPage.syncFailed": "ייתכן שהלוח נמחק או שנשללה ממך גישה", "BoardTemplateSelector.add-template": "יצירת תבנית חדשה", "BoardTemplateSelector.create-empty-board": "יצירת לוח ריק", "BoardTemplateSelector.delete-template": "מחיקה", "BoardTemplateSelector.description": "הוספת לוח לתפריט הצד באמצעות אחת התבניות, או יצירת תבנית חדשה", "BoardTemplateSelector.edit-template": "עריכה", "BoardTemplateSelector.plugin.no-content-description": "הוספת לוח לתפריט הצד באמצעות אחת מן התבניות שהוגדרו או יצירת תבנית חדשה.", "BoardTemplateSelector.plugin.no-content-title": "יצירת לוח", "BoardTemplateSelector.title": "יצירת לוח", "BoardTemplateSelector.use-this-template": "שימוש בתבנית זו", "BoardsSwitcher.Title": "חיפוש לוחות", "BoardsUnfurl.Limited": "פרטים נוספים מוסתרים מאחר והכרטיס הועבר לארכיון", "BoardsUnfurl.Remainder": "עוד {remainder}", "BoardsUnfurl.Updated": "{time} מעודכן", "Calculations.Options.average.displayName": "ממוצע", "Calculations.Options.average.label": "ממוצע", "Calculations.Options.count.displayName": "כמות", "Calculations.Options.count.label": "כמות", "Calculations.Options.countChecked.displayName": "סומנו", "Calculations.Options.countChecked.label": "כמות שנבחרו", "Calculations.Options.countUnchecked.displayName": "הסרת סימון", "Calculations.Options.countUnchecked.label": "כמות שלא סומנו", "Calculations.Options.countUniqueValue.displayName": "ייחודי", "Calculations.Options.countUniqueValue.label": "כמות ערכים יחידניים", "Calculations.Options.countValue.displayName": "ערכים", "Calculations.Options.countValue.label": "כמות ערכים", "Calculations.Options.dateRange.displayName": "טווח", "Calculations.Options.dateRange.label": "טווח", "Calculations.Options.earliest.displayName": "המוקדם ביותר", "Calculations.Options.earliest.label": "המוקדם ביותר", "Calculations.Options.latest.displayName": "המאוחר ביותר", "Calculations.Options.latest.label": "המאוחר ביותר", "Calculations.Options.max.displayName": "מקסימום", "Calculations.Options.max.label": "מקסימום", "Calculations.Options.median.displayName": "חציון", "Calculations.Options.median.label": "חציון", "Calculations.Options.min.displayName": "מינימום", "Calculations.Options.min.label": "מינימום", "Calculations.Options.none.displayName": "חישוב", "Calculations.Options.none.label": "ללא", "Calculations.Options.percentChecked.displayName": "סומן", "Calculations.Options.percentChecked.label": "אחוז ערכים שסומנו", "Calculations.Options.percentUnchecked.displayName": "לא סומנו", "Calculations.Options.percentUnchecked.label": "אחוזים שלא סומנו", "Calculations.Options.range.displayName": "טווח", "Calculations.Options.range.label": "טווח", "Calculations.Options.sum.displayName": "סכום", "Calculations.Options.sum.label": "סכום", "CalendarCard.untitled": "ללא כותרת", "CardActionsMenu.copiedLink": "הועתק!", "CardActionsMenu.copyLink": "העתקת קישור", "CardActionsMenu.delete": "מחיקה", "CardActionsMenu.duplicate": "שיכפול", "CardBadges.title-checkboxes": "תיבות סימון", "CardBadges.title-comments": "הערות", "CardBadges.title-description": "כרטיס זה מכיל תיאור", "CardDetail.Follow": "מעקב", "CardDetail.Following": "עוקב", "CardDetail.add-content": "הוספת מידע", "CardDetail.add-icon": "הוספת אייקון", "CardDetail.add-property": "+ הוספת תכונה", "CardDetail.addCardText": "הוספת מלל הכרטיס", "CardDetail.limited-body": "שדרג למסלול Professional או למסלול Enterprise ותוכל לצפות בכרטיסיות שהועברו לארכיון, לצפות במבטים על לוחות ללא הגבלה, לפתוח כרטיסיות ללא הגבלה ועוד.", "CardDetail.limited-button": "שדרוג", "CardDetail.limited-title": "כרטיס זה מוסתר", "CardDetail.moveContent": "הזזת תוכן הכרטיס", "CardDetail.new-comment-placeholder": "הוספת הערה...", "CardDetailProperty.confirm-delete-heading": "אישור מחיקת תכונה", "CardDetailProperty.confirm-delete-subtext": "האם הינך בטוח שאתה מעוניין במחיקת התכונה \"{propertyName}\"? מחיקת התכונה תגרור את מחיקתה בכל הכרטיסים שבלוח זה.", "CardDetailProperty.confirm-property-name-change-subtext": "האם הינך בטוח ברצונך לשנות את התכונה \"{propertyName}\"' \"{customText}\"? זה ישפיע על ערך.ים לאורך {numOfCards} כרטיס.ים בלוח זה, ויכול להוביל לאיבוד מידע.", "CardDetailProperty.confirm-property-type-change": "אישור שינוי סוג תכונה", "CardDetailProperty.delete-action-button": "מחיקה", "CardDetailProperty.property-change-action-button": "שינוי תכונה", "CardDetailProperty.property-changed": "תכונה השתנתה בהצלחה!", "CardDetailProperty.property-deleted": "מחיקת {propertyName} בוצעה בהצלחה!", "CardDetailProperty.property-name-change-subtext": "סוג מ \"{oldPropType}\" אל \"{newPropType}\"", "CardDetial.limited-link": "מידע נוסף לגבי המסלולים שלנו.", "CardDialog.delete-confirmation-dialog-button-text": "מחיקה", "CardDialog.delete-confirmation-dialog-heading": "מחיקת כרטיס מאושרת!", "CardDialog.editing-template": "הינך מעדכן תבנית.", "CardDialog.nocard": "כרטיס זה אינו קיים או ללא גישה.", "Categories.CreateCategoryDialog.CancelText": "ביטול", "Categories.CreateCategoryDialog.CreateText": "יצירה", "Categories.CreateCategoryDialog.Placeholder": "שם הקטגוריה שלך", "Categories.CreateCategoryDialog.UpdateText": "עדכון", "CenterPanel.Login": "כניסה", "CenterPanel.Share": "שיתוף", "ColorOption.selectColor": "בחירת צבע {color}", "Comment.delete": "מחיקה", "CommentsList.send": "שליחה", "ConfirmationDialog.cancel-action": "ביטול", "ConfirmationDialog.confirm-action": "אישור", "ContentBlock.Delete": "מחיקה", "ContentBlock.DeleteAction": "מחיקה", "ContentBlock.addElement": "הוספת {type}", "ContentBlock.checkbox": "תיבת סימון", "ContentBlock.divider": "מפריד", "ContentBlock.editCardCheckbox": "תיבת סימון מתחלפת", "ContentBlock.editCardCheckboxText": "עריכת מלל כרטיס", "ContentBlock.editCardText": "עריכת מלל כרטיס" } ================================================ FILE: webapp/i18n/hr.json ================================================ { "AdminBadge.SystemAdmin": "Administrator", "AdminBadge.TeamAdmin": "Tim administratora", "AppBar.Tooltip": "Uklj./Isklj. povezane ploče", "Attachment.Attachment-title": "Prilog", "AttachmentBlock.DeleteAction": "izbriši", "AttachmentBlock.addElement": "dodaj {type}", "AttachmentBlock.delete": "Prilog je izbrisan.", "AttachmentBlock.failed": "Nije moguće prenijeti datoteku jer je dosegnuta granica veličine datoteke.", "AttachmentBlock.upload": "Prijenos priloga.", "AttachmentBlock.uploadSuccess": "Prilog je prenesen.", "AttachmentElement.delete-confirmation-dialog-button-text": "Izbriši", "AttachmentElement.download": "Preuzmi", "AttachmentElement.upload-percentage": "Prijenos … ({uploadPercent} %)", "BoardComponent.add-a-group": "+ Dodaj grupu", "BoardComponent.delete": "Izbriši", "BoardComponent.hidden-columns": "Skriveni stupci", "BoardComponent.hide": "Sakrij", "BoardComponent.new": "+ Novo", "BoardComponent.no-property": "Bez svojstva {property}", "BoardComponent.no-property-title": "Elementi s praznim svojstvom {property} smjestit će se ovdje. Ovaj se stupac ne može ukloniti.", "BoardComponent.show": "Prikaži", "BoardMember.schemeAdmin": "Administrator", "BoardMember.schemeCommenter": "Komentator", "BoardMember.schemeEditor": "Urednik", "BoardMember.schemeNone": "Ništa", "BoardMember.schemeViewer": "Gledatelj", "BoardMember.unlinkChannel": "Odspoji", "BoardPage.newVersion": "Dostupna je nova verzija za „Ploče”. Pritisni ovdje za ponovno učitavanje.", "BoardPage.syncFailed": "Ploča se može izbrisati ili pristup opozvati.", "BoardTemplateSelector.add-template": "Stvori novi predložak", "BoardTemplateSelector.create-empty-board": "Stvori praznu ploču", "BoardTemplateSelector.delete-template": "Izbriši", "BoardTemplateSelector.description": "Za početak odaberi predložak. Prilagodi predložak kako bi odgovarao tvojim potrebama ili stvori praznu ploču.", "BoardTemplateSelector.edit-template": "Uredi", "BoardTemplateSelector.plugin.no-content-description": "Dodaj ploču u bočnu traku koristeći bilo koji od niže dolje definiranih predložaka ili počni ispočetka.", "BoardTemplateSelector.plugin.no-content-title": "Stvori ploču", "BoardTemplateSelector.title": "Stvori ploču", "BoardTemplateSelector.use-this-template": "Koristi ovaj predložak", "BoardsSwitcher.Title": "Pronađi ploče", "BoardsUnfurl.Limited": "Dodatni detalji su skriveni jer je kartica arhivirana", "BoardsUnfurl.Remainder": "+ još {remainder}", "BoardsUnfurl.Updated": "Aktulaizirano {time}", "Calculations.Options.average.displayName": "Prosjek", "Calculations.Options.average.label": "Prosjek", "Calculations.Options.count.displayName": "Broji", "Calculations.Options.count.label": "Broji", "Calculations.Options.countChecked.displayName": "Označeno", "Calculations.Options.countChecked.label": "Broji označene", "Calculations.Options.countUnchecked.displayName": "Neoznačeno", "Calculations.Options.countUnchecked.label": "Broji neoznačene", "Calculations.Options.countUniqueValue.displayName": "Jedinstveno", "Calculations.Options.countUniqueValue.label": "Broji jedinstvene vrijednosti", "Calculations.Options.countValue.displayName": "Vrijednosti", "Calculations.Options.countValue.label": "Broji vrijednost", "Calculations.Options.dateRange.displayName": "Raspon", "Calculations.Options.dateRange.label": "Raspon", "Calculations.Options.earliest.displayName": "Najraniji", "Calculations.Options.earliest.label": "Najraniji", "Calculations.Options.latest.displayName": "Najnoviji", "Calculations.Options.latest.label": "Najnoviji", "Calculations.Options.max.displayName": "Maks.", "Calculations.Options.max.label": "Maks.", "Calculations.Options.median.displayName": "Medijan", "Calculations.Options.median.label": "Medijan", "Calculations.Options.min.displayName": "Min.", "Calculations.Options.min.label": "Min.", "Calculations.Options.none.displayName": "Izračunaj", "Calculations.Options.none.label": "Ništa", "Calculations.Options.percentChecked.displayName": "Provjereno", "Calculations.Options.percentChecked.label": "Provjeren postotak", "Calculations.Options.percentUnchecked.displayName": "Neprovjereno", "Calculations.Options.percentUnchecked.label": "Neprovjeren postotak", "Calculations.Options.range.displayName": "Raspon", "Calculations.Options.range.label": "Raspon", "Calculations.Options.sum.displayName": "Zbroj", "Calculations.Options.sum.label": "Zbroj", "CalendarCard.untitled": "Bez naslova", "CardActionsMenu.copiedLink": "Kopirano!", "CardActionsMenu.copyLink": "Kopiraj poveznicu", "CardActionsMenu.delete": "Izbriši", "CardActionsMenu.duplicate": "Dupliciraj", "CardBadges.title-checkboxes": "Označiva polja", "CardBadges.title-comments": "Komentari", "CardBadges.title-description": "Ova kartica ima opis", "CardDetail.Attach": "Priloži", "CardDetail.Follow": "Prati", "CardDetail.Following": "Pratiš", "CardDetail.add-content": "Dodaj sadržaj", "CardDetail.add-icon": "Dodaj ikonu", "CardDetail.add-property": "+ Dodaj svojstvo", "CardDetail.addCardText": "dodaj tekst kartice", "CardDetail.limited-body": "Nadogradi na našu profesionalnu tarifu ili na tarifu za poduzeća.", "CardDetail.limited-button": "Nadogradi", "CardDetail.limited-title": "Ova je kartica skrivena", "CardDetail.moveContent": "Pomakni sadržaj kartice", "CardDetail.new-comment-placeholder": "Dodaj komentar …", "CardDetailProperty.confirm-delete-heading": "Potvrdi brisanje svojstva", "CardDetailProperty.confirm-delete-subtext": "Stvarno želiš izbrisati svojstvo „{propertyName}”? Brisanjem će se izbrisati svojstvo sa svih kartica na ovoj ploči.", "CardDetailProperty.confirm-property-name-change-subtext": "Stvarno želiš promijeniti svojstvo „{propertyName}” {customText}? To će utjecati na vrijednosti na {numOfCards} kartica na ovoj ploči i može prouzročiti gubitak podataka.", "CardDetailProperty.confirm-property-type-change": "Potvrdi promjenu vrste svojstva", "CardDetailProperty.delete-action-button": "Izbriši", "CardDetailProperty.property-change-action-button": "Promijeni svojstvo", "CardDetailProperty.property-changed": "Promjena svojstva uspjela!", "CardDetailProperty.property-deleted": "Svojstvo {propertyName} uspješno izbrisano!", "CardDetailProperty.property-name-change-subtext": "vrste „{oldPropType}” u „{newPropType}”", "CardDetial.limited-link": "Saznaj više o našim tarifma.", "CardDialog.delete-confirmation-dialog-attachment": "Potvrdi brisanje priloga", "CardDialog.delete-confirmation-dialog-button-text": "Izbriši", "CardDialog.delete-confirmation-dialog-heading": "Potvrdi brisanje kartice", "CardDialog.editing-template": "Uređuješ predložak.", "CardDialog.nocard": "Ova kartica ne postoji ili je nedostupna.", "Categories.CreateCategoryDialog.CancelText": "Odustani", "Categories.CreateCategoryDialog.CreateText": "Stvori", "Categories.CreateCategoryDialog.Placeholder": "Zadaj ime kategoriji", "Categories.CreateCategoryDialog.UpdateText": "Aktualiziraj", "CenterPanel.Login": "Prijava", "CenterPanel.Share": "Dijeli", "ChannelIntro.CreateBoard": "Stvori ploču", "ColorOption.selectColor": "Odaberi boju {color}", "Comment.delete": "Izbriši", "CommentsList.send": "Pošalji", "ConfirmPerson.empty": "Prazno", "ConfirmPerson.search": "Traži …", "ConfirmationDialog.cancel-action": "Odustani", "ConfirmationDialog.confirm-action": "Potvrdi", "ContentBlock.Delete": "Izbriši", "ContentBlock.DeleteAction": "izbriši", "ContentBlock.addElement": "dodaj {type}", "ContentBlock.checkbox": "označivo polje", "ContentBlock.divider": "razdjeljivač", "ContentBlock.editCardCheckbox": "uklj./isklj. označivo polje", "ContentBlock.editCardCheckboxText": "uredi tekst kartice", "ContentBlock.editCardText": "uredi tekst kartice", "ContentBlock.editText": "Uredi tekst …", "ContentBlock.image": "slika", "ContentBlock.insertAbove": "Umetni iznad", "ContentBlock.moveBlock": "premjesti sadržaj kartice", "ContentBlock.moveDown": "Pomakni dolje", "ContentBlock.moveUp": "Pomakni gore", "ContentBlock.text": "tekst", "DateFilter.empty": "Prazno", "DateRange.clear": "Isprazni", "DateRange.empty": "Prazno", "DateRange.endDate": "Datum kraja", "DateRange.today": "Danas", "DeleteBoardDialog.confirm-cancel": "Odustani", "DeleteBoardDialog.confirm-delete": "Izbriši", "DeleteBoardDialog.confirm-info": "Stvarno želiš izbrisati ploču „{boardTitle}”? Brisanjem će se izbrisati sve kartice na ploči.", "DeleteBoardDialog.confirm-info-template": "Stvrano želiš izbrisati predložak ploče „{boardTitle}”?", "DeleteBoardDialog.confirm-tite": "Potvrdi brisanje ploče", "DeleteBoardDialog.confirm-tite-template": "Potvrdi brisanje predloška za ploče", "Dialog.closeDialog": "Zatvori dijalog", "EditableDayPicker.today": "Danas", "Error.mobileweb": "Web podrška za mobilne uređaje trenutačno se nalazi u ranoj beta verziji. Nekih funkcionalsnosti možda još nema.", "Error.websocket-closed": "Veza s websocketom je zatvorena, veza je prekinuta. Ako problem ustraje, provjeri konfiguraciju poslužitelja ili web proxyja.", "Filter.contains": "sadrži", "Filter.ends-with": "završava sa", "Filter.includes": "uključuje", "Filter.is": "je", "Filter.is-after": "je nakon", "Filter.is-before": "je prije", "Filter.is-empty": "je prazno", "Filter.is-not-empty": "nije prazno", "Filter.is-not-set": "nije postavljeno", "Filter.is-set": "je postavljeno", "Filter.isafter": "je nakon", "Filter.isbefore": "je prije", "Filter.not-contains": "ne sadrži", "Filter.not-ends-with": "ne završava sa", "Filter.not-includes": "ne uključuje", "Filter.not-starts-with": "ne počinje sa", "Filter.starts-with": "počinje sa", "FilterByText.placeholder": "filtriraj tekst", "FilterComponent.add-filter": "+ Dodaj filtar", "FilterComponent.delete": "Izbriši", "FilterValue.empty": "(prazno)", "FindBoardsDialog.IntroText": "Traži ploče", "FindBoardsDialog.NoResultsFor": "Nema rezultata za „{searchQuery}”", "FindBoardsDialog.NoResultsSubtext": "Provjeri pravopis ili pretraži s jednim drugim pojmom.", "FindBoardsDialog.SubTitle": "Utipkaj ime za pronalaženje ploče. Koristi GORE/DOLJE za pretraživanje. ENTER za odabiranje, ESC za prekid", "FindBoardsDialog.Title": "Pronađi ploče", "GroupBy.hideEmptyGroups": "Sakrij {count} prazne grupe", "GroupBy.showHiddenGroups": "Prikaži {count} skrivene grupe", "GroupBy.ungroup": "Razgrupiraj", "HideBoard.MenuOption": "Sakrij ploču", "KanbanCard.untitled": "Bez naslova", "MentionSuggestion.is-not-board-member": "(nije član u ploči)", "Mutator.new-board-from-template": "nova ploča iz predloška", "Mutator.new-card-from-template": "nova kartica iz predloška", "Mutator.new-template-from-card": "novi predložak iz kartice", "OnboardingTour.AddComments.Body": "Probleme možeš komentirati. Možeš čak i @spomenuti svoje Mattermost kolege za privlačenje njihove pozornosti.", "OnboardingTour.AddComments.Title": "Dodaj komentare", "OnboardingTour.AddDescription.Body": "Dodaj opis za tvoju karticu kako bi tvoji članovi tima znali o čemu se radi.", "OnboardingTour.AddDescription.Title": "Dodaj opis", "OnboardingTour.AddProperties.Body": "Dodaj razna svojstva karticama kako bi bile još snažnije.", "OnboardingTour.AddProperties.Title": "Dodaj svojstva", "OnboardingTour.AddView.Body": "Prijeđi ovamo za stvaranje novog prikaza za organiziranje tvoje ploče koristeći različite rasporede.", "OnboardingTour.AddView.Title": "Dodaj novi prikaz", "OnboardingTour.CopyLink.Body": "Svoje kartice možeš dijeliti s članovima tima pomuću kopiranja i umetanja poveznice u kanal, izravnu poruku ili grupnu poruku.", "OnboardingTour.CopyLink.Title": "Kopiraj poveznicu", "OnboardingTour.OpenACard.Body": "Otvori jednu karticu i istraži načine kako ti Ploče mogu pomoći organizirati tvoj rad.", "OnboardingTour.OpenACard.Title": "Otvori jednu karticu", "OnboardingTour.ShareBoard.Body": "Tvoju ploču možeš dijeliti interno, unutar tvog tima ili je javno objaviti radi vidljivosti izvan tvoje organizacije.", "OnboardingTour.ShareBoard.Title": "Dijeli ploču", "PersonProperty.board-members": "Članovi ploče", "PersonProperty.me": "Ja", "PersonProperty.non-board-members": "Ne članovi ploče", "PropertyMenu.Delete": "Izbriši", "PropertyMenu.changeType": "Promijei vrstu svojstva", "PropertyMenu.selectType": "Odaberi vrstu svojstva", "PropertyMenu.typeTitle": "Vrsta", "PropertyType.Checkbox": "Označivo polje", "PropertyType.CreatedBy": "Stvoreno od", "PropertyType.CreatedTime": "Vrijeme stvaranja", "PropertyType.Date": "Datum", "PropertyType.Email": "E-mail adresa", "PropertyType.MultiPerson": "Više osoba", "PropertyType.MultiSelect": "Višestruki odabir", "PropertyType.Number": "Broj", "PropertyType.Person": "Osoba", "PropertyType.Phone": "Telefon", "PropertyType.Select": "Odaberi", "PropertyType.Text": "Tekst", "PropertyType.Unknown": "Nepoznato", "PropertyType.UpdatedBy": "Autor zadnjeg aktualiziranja", "PropertyType.UpdatedTime": "Vrijme zadnjeg aktualiziranja", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Prazno", "RegistrationLink.confirmRegenerateToken": "Ovo će poništiti prethodno dijeljene poveznice. Nastaviti?", "RegistrationLink.copiedLink": "Kopirano!", "RegistrationLink.copyLink": "Kopiraj poveznicu", "RegistrationLink.description": "Dijeli ovu poveznicu kako bi drugi mogli stvoriti račune:", "RegistrationLink.regenerateToken": "Ponovo generiraj token", "RegistrationLink.tokenRegenerated": "Poveznica za registraciju je ponovo generirana", "ShareBoard.PublishDescription": "Objavi i dijeli poveznicu „samo za čitanje” sa svima na webu.", "ShareBoard.PublishTitle": "Objavi na webu", "ShareBoard.ShareInternal": "Dijeli interno", "ShareBoard.ShareInternalDescription": "Korisnici koji imaju prava moći će koristiti ovu poveznicu.", "ShareBoard.Title": "Dijeli ploču", "ShareBoard.confirmRegenerateToken": "Ovo će poništiti prethodno dijeljene poveznice. Nastaviti?", "ShareBoard.copiedLink": "Kopirano!", "ShareBoard.copyLink": "Kopiraj poveznicu", "ShareBoard.regenerate": "Ponovo generiraj token", "ShareBoard.searchPlaceholder": "Traži ljude", "ShareBoard.teamPermissionsText": "Svatko u timu {teamName}", "ShareBoard.tokenRegenrated": "Token je ponovo generiran", "ShareBoard.userPermissionsRemoveMemberText": "Ukloni člana", "ShareBoard.userPermissionsYouText": "(Ti)", "ShareTemplate.Title": "Dijeli predložak", "ShareTemplate.searchPlaceholder": "Traži osobe", "Sidebar.about": "O programu Focalboard", "Sidebar.add-board": "+ Dodaj ploču", "Sidebar.changePassword": "Promijeni lozinku", "Sidebar.delete-board": "Izbriši ploču", "Sidebar.duplicate-board": "Dupliciraj ploču", "Sidebar.export-archive": "Izvezi arhivu", "Sidebar.import": "Uvezi", "Sidebar.import-archive": "Uvezi arhivu", "Sidebar.invite-users": "Pozovi korisnika", "Sidebar.logout": "Odjavi se", "Sidebar.new-category.badge": "Nova", "Sidebar.new-category.drag-boards-cta": "Povuci ploče ovamo …", "Sidebar.no-boards-in-category": "Nema ploča u kategoriji", "Sidebar.product-tour": "Pregled proizvoda", "Sidebar.random-icons": "Slučajne ikone", "Sidebar.set-language": "Postavi jezik", "Sidebar.set-theme": "Postavi temu", "Sidebar.settings": "Postavke", "Sidebar.template-from-board": "Novi predložak iz ploče", "Sidebar.untitled-board": "(Ploča bez naslova)", "Sidebar.untitled-view": "(Neimenovani prikaz)", "SidebarCategories.BlocksMenu.Move": "Premjesti u …", "SidebarCategories.CategoryMenu.CreateNew": "Stvori novu kategoriju", "SidebarCategories.CategoryMenu.Delete": "Izbriši kategoriju", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Ploče u kategoriji {categoryName} vratit će se u kategorije ploča. Nećeš biti uklonjen/a s nijedne ploče.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Izbrisati ovu kategoriju?", "SidebarCategories.CategoryMenu.Update": "Preimenuj kategoriju", "SidebarTour.ManageCategories.Body": "Stvori vlastite kategorije i upravljaj njima. Kategorije se spremaju za svakog korisnika zasebno, tako da premještanje ploče u tvoju kategoriju neće utjecati na druge članove koji koriste istu ploču.", "SidebarTour.ManageCategories.Title": "Upravljaj kategorijama", "SidebarTour.SearchForBoards.Body": "Otvori sklopku ploča (Cmd/Ctrl + K) za brzo pretraživanje i dodavanje ploča u bočnu traku.", "SidebarTour.SearchForBoards.Title": "Traži ploče", "SidebarTour.SidebarCategories.Body": "Sve tvoje ploče se sada nalaze u tvojoj novoj bočnoj traci. Nema više prebacivanja između radnih prostora. Jednokratne prilagođene kategorije temeljene na tvojim prethodnim radnim prostorima su možda automatski stvorene tijekom nadogradnje na v7.2. Ako želiš, možeš ih ukloniti ili urediti.", "SidebarTour.SidebarCategories.Link": "Saznaj više", "SidebarTour.SidebarCategories.Title": "Kategorije u bočnoj traci", "SiteStats.total_boards": "Ukupno ploča", "SiteStats.total_cards": "Ukupno kartica", "TableComponent.add-icon": "Dodaj ikonu", "TableComponent.name": "Ime", "TableComponent.plus-new": "+ Novo", "TableHeaderMenu.delete": "Izbriši", "TableHeaderMenu.duplicate": "Dupliciraj", "TableHeaderMenu.hide": "Sakrij", "TableHeaderMenu.insert-left": "Umetni lijevo", "TableHeaderMenu.insert-right": "Umetni desno", "TableHeaderMenu.sort-ascending": "Razvrstaj uzlazno", "TableHeaderMenu.sort-descending": "Razvrstaj silazno", "TableRow.DuplicateCard": "dupliciraj karticu", "TableRow.MoreOption": "Daljnje radnje", "TableRow.open": "Otvori", "TopBar.give-feedback": "Pošalji povratne informacije", "URLProperty.copiedLink": "Kopirano!", "URLProperty.copy": "Kopiraj", "URLProperty.edit": "Uredi", "UndoRedoHotKeys.canRedo": "Ponovi", "UndoRedoHotKeys.canRedo-with-description": "Ponovi {description}", "UndoRedoHotKeys.canUndo": "Poništi", "UndoRedoHotKeys.canUndo-with-description": "Poništi {description}", "UndoRedoHotKeys.cannotRedo": "Ništa se ne može ponoviti", "UndoRedoHotKeys.cannotUndo": "Ništa se ne može poništiti", "ValueSelector.noOptions": "Nema opcija. Za dodavanje prve opcije počni tipkati!", "ValueSelector.valueSelector": "Selektor vrijednosti", "ValueSelectorLabel.openMenu": "Otvori izbornik", "VersionMessage.help": "Provjeri što je novo u ovoj verziji.", "VersionMessage.learn-more": "Saznaj više", "View.AddView": "Dodaj prikaz", "View.Board": "Ploča", "View.DeleteView": "Izbriši prikaz", "View.DuplicateView": "Dupliciraj prikaz", "View.Gallery": "Galerija", "View.NewBoardTitle": "Prikaz ploče", "View.NewCalendarTitle": "Prikaz kalendara", "View.NewGalleryTitle": "Prikaz galerije", "View.NewTableTitle": "Prikaz tablice", "View.NewTemplateDefaultTitle": "Predložak bez naslova", "View.NewTemplateTitle": "Bez naslova", "View.Table": "Tablica", "ViewHeader.add-template": "Novi predložak", "ViewHeader.delete-template": "Izbriši", "ViewHeader.display-by": "Prikaži prema svojstvu: {property}", "ViewHeader.edit-template": "Uredi", "ViewHeader.empty-card": "Prazna kartica", "ViewHeader.export-board-archive": "Izvezi arhivu ploče", "ViewHeader.export-complete": "Izvoz je završen!", "ViewHeader.export-csv": "Izvezi u CSV", "ViewHeader.export-failed": "Izvoz neuspio!", "ViewHeader.filter": "Filtar", "ViewHeader.group-by": "Grupiraj prema svojstvu: {property}", "ViewHeader.new": "Novo", "ViewHeader.properties": "Svojstva", "ViewHeader.properties-menu": "Izbornik svojstava", "ViewHeader.search-text": "Traži kartice", "ViewHeader.select-a-template": "Odaberi predložak", "ViewHeader.set-default-template": "Postavi kao zadano", "ViewHeader.sort": "Razvrstaj", "ViewHeader.untitled": "Bez naslova", "ViewHeader.view-header-menu": "Prikaz izbornika zaglavlja", "ViewHeader.view-menu": "Prikaz izbornika", "ViewLimitDialog.Heading": "Ograničenje prikaza po ploči dosegnuta", "ViewLimitDialog.PrimaryButton.Title.Admin": "Nadogradi", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Obavijesti aministratora", "ViewLimitDialog.Subtext.Admin": "Nadogradi na našu profesionalnu tarifu ili na tarifu za poduzeća.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Saznaj više o našim tarifama.", "ViewLimitDialog.Subtext.RegularUser": "Obavijesti svog administratora da nadogradi na našu profesionalnu tarifu ili na tarifu za poduzeća.", "ViewLimitDialog.UpgradeImg.AltText": "nadogradi sliku", "ViewLimitDialog.notifyAdmin.Success": "Tvoj je administrator obaviješten", "ViewTitle.hide-description": "sakrij opis", "ViewTitle.pick-icon": "Odaberi ikonu", "ViewTitle.random-icon": "Slučajno", "ViewTitle.remove-icon": "Ukloni ikonu", "ViewTitle.show-description": "prikaži opis", "ViewTitle.untitled-board": "Bezimena ploča", "WelcomePage.Description": "„Ploče” je alat za upravljanje projektima koji pomaže definirati, organizirati, pratiti i upravljati radovima timova, koristeći poznati Kanban prikaz ploče.", "WelcomePage.Explore.Button": "Uvod u rad programa", "WelcomePage.Heading": "Dobro došao, dobro došla u „Ploče”", "WelcomePage.NoThanks.Text": "Ne hvala", "WelcomePage.StartUsingIt.Text": "Počni ga koristiti", "Workspace.editing-board-template": "Uređuješ predložak ploče.", "badge.guest": "Gost", "boardPage.confirm-join-button": "Pridruži se", "boardPage.confirm-join-text": "Pridružit ćeš se privatnoj ploči bez da te je administrator ploče izričito dodao. Stvarno se želiš pridružiti ovoj privatnoj ploči?", "boardPage.confirm-join-title": "Pridruži se privatnoj ploči", "boardSelector.confirm-link-board": "Poveži ploču s kanalom", "boardSelector.confirm-link-board-button": "Da, poveži ploču", "boardSelector.confirm-link-board-subtext": "Kad povežeš ploču „{boardName}” s kanalom, svi članovi kanala (postojeći i novi) moći će je uređivati. To ne vrijedi za članove koji su gosti. Vezu između ploče i kanala možeš raskinuti u bilo kojem trenutku.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Kad povežeš ploču „{boardName}” s kanalom, svi članovi kanala (postojeći i novi) moći će ga uređivati. To ne vrijedi za članove koji su gosti.{lineBreak}Ova je ploča trenutačno povezana s drugim kanalom. Veza će se raskinuti ako odlučiš je ovdje povezati.", "boardSelector.create-a-board": "Stvori ploču", "boardSelector.link": "Poveži", "boardSelector.search-for-boards": "Traži ploče", "boardSelector.title": "Poveži ploče", "boardSelector.unlink": "Odspoji", "calendar.month": "Mjesec", "calendar.today": "DANAS", "calendar.week": "Tjedan", "centerPanel.undefined": "Bez {propertyName}", "centerPanel.unknown-user": "Nepoznat korisnik", "cloudMessage.learn-more": "Saznaj više", "createImageBlock.failed": "Nije moguće prenijeti ovu datoteku jer je dosegnuta granica veličine datoteke.", "default-properties.badges": "Komentari i opis", "default-properties.title": "Naslov", "error.back-to-home": "Natrag na početnu stranicu", "error.back-to-team": "Natrag u tim", "error.board-not-found": "Ploča nije pronađena.", "error.go-login": "Prijavi se", "error.invalid-read-only-board": "Nemaš pristup ovoj ploči. Prijavi se za pristup pločama.", "error.not-logged-in": "Tvoja sesija je možda istekla ili nisi prijavljen/a. Ponovo se prijavi za pristup pločama.", "error.page.title": "Oprosti, dogodila se greška", "error.team-undefined": "Nije valjani tim.", "error.unknown": "Dogodila se greška.", "generic.previous": "Prethodno", "guest-no-board.subtitle": "Još nemaš pristup nijednoj ploči u ovom timu, pričekaj dok te netko ne doda u bilo koju ploču.", "guest-no-board.title": "Još nema ploča", "imagePaste.upload-failed": "Neke datoteke nisu prenesene jer je dosegnuta granica veličine datoteke.", "limitedCard.title": "Skrivene kartice", "login.log-in-button": "Prijavi se", "login.log-in-title": "Prijavi se", "login.register-button": "ili stvori račun, ako ga još nemaš", "new_channel_modal.create_board.empty_board_description": "Stvori novu praznu ploču", "new_channel_modal.create_board.empty_board_title": "Prazna ploča", "new_channel_modal.create_board.select_template_placeholder": "Odaberi predložak", "new_channel_modal.create_board.title": "Stvori ploču za ovaj kanal", "notification-box-card-limit-reached.close-tooltip": "Postavi pripravno stanje na 10 dana", "notification-box-card-limit-reached.contact-link": "obavijesti svog administratora", "notification-box-card-limit-reached.link": "Nadogradi na plaćenu tarifu", "notification-box-card-limit-reached.title": "Skrivene kartice iz ploče: {cards}", "notification-box-cards-hidden.title": "Ova radnja je sakrlia jednu drugu karticu", "notification-box.card-limit-reached.not-admin.text": "Za pristupanje arhiviranim karticama, možeš {contactLink} za nadogradnju na plaćenu tarifu.", "notification-box.card-limit-reached.text": "Dosegnuto je ograničenje broja kartica. Za pregled starijih kartica, {link}", "person.add-user-to-board": "Dodaj korisničko ime {username} u ploču", "person.add-user-to-board-confirm-button": "Dodaj u ploču", "person.add-user-to-board-permissions": "Prava", "person.add-user-to-board-question": "Želiš li dodati korisničko ime {username} u ploču?", "person.add-user-to-board-warning": "{username} nije član ploče i neće primati obavijesti o njoj.", "register.login-button": "ili se prijavi ako već imaš račun", "register.signup-title": "Prijavi se na svoj račun", "rhs-board-non-admin-msg": "Nisi administrator ploče", "rhs-boards.add": "Dodaj", "rhs-boards.dm": "DP", "rhs-boards.gm": "GP", "rhs-boards.header.dm": "ovu izravnu poruku", "rhs-boards.header.gm": "ovu grupnu poruku", "rhs-boards.last-update-at": "Zadnje aktualiziranje: {datetime}", "rhs-boards.link-boards-to-channel": "Poveži ploče s kanalom {channelName}", "rhs-boards.linked-boards": "Povezane ploče", "rhs-boards.no-boards-linked-to-channel": "Do sada nije povezana nijedna ploča s kanalom {channelName}", "rhs-boards.no-boards-linked-to-channel-description": "Ploče su alat za upravljanje projektima koji pomaže definirati, organizirati, pratiti i upravljati rad timova, koristeći poznati kanban prikaz ploče.", "rhs-boards.unlink-board": "Odspoji ploču", "rhs-boards.unlink-board1": "Odspoji ploču", "rhs-channel-boards-header.title": "Ploče", "share-board.publish": "Objavi", "share-board.share": "Dijeli", "shareBoard.channels-select-group": "Kanali", "shareBoard.confirm-change-team-role.body": "Svatko na ovoj ploči s manje prava od uloge „{role}” će sada biti promaknut u {role}. Stvarno želiš promijeniti najmanju ulogu za ploču?", "shareBoard.confirm-change-team-role.confirmBtnText": "Promijeni najmanju ulogu ploče", "shareBoard.confirm-change-team-role.title": "Promijeni najmanju ulogu ploče", "shareBoard.confirm-link-channel": "Poveži ploču s kanalom", "shareBoard.confirm-link-channel-button": "Poveži kanal", "shareBoard.confirm-link-channel-button-with-other-channel": "Odspoji i poveži ovamo", "shareBoard.confirm-link-channel-subtext": "Kad povežeš kanal s pločom, svi članovi kanala (postojeći i novi) moći će ga uređivati. To ne vrijedi za članove koji su gosti.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Kad povežeš kanal s pločom, svi članovi kanala (postojeći i novi) moći će ga uređivati. To ne vrijedi za članove koji su gosti.{lineBreak}Ova je ploča trenutačno povezana s drugim kanalom. Veza će se raskinuti ako odlučiš je ovdje povezati.", "shareBoard.confirm-unlink.body": "Kad odspojiš kanal od ploče, svi članovi kanala (postojeći i novi) izgubit će pristup kanalu, osim ako su im se prava dala zasebno.", "shareBoard.confirm-unlink.confirmBtnText": "Odspoji kanal", "shareBoard.confirm-unlink.title": "Odspoji kanal od ploče", "shareBoard.lastAdmin": "Ploče moraju imati barem jednog administratora", "shareBoard.members-select-group": "Članovi", "shareBoard.unknown-channel-display-name": "Nepoznat kanal", "tutorial_tip.finish_tour": "Gotovo", "tutorial_tip.got_it": "Razumijem", "tutorial_tip.ok": "Dalje", "tutorial_tip.out": "Deaktiviraj ove savjete.", "tutorial_tip.seen": "Ovaj savjet već poznaš?" } ================================================ FILE: webapp/i18n/hu.json ================================================ { "AppBar.Tooltip": "Kapcsolt táblák kapcsolása", "Attachment.Attachment-title": "Melléklet", "AttachmentBlock.DeleteAction": "törlés", "AttachmentBlock.addElement": "{type} hozzáadása", "AttachmentBlock.delete": "Melléklet sikeresen törölve.", "AttachmentBlock.failed": "Nem sikerült feltölteni a fájlt. A csatolmány mérete túl nagy.", "AttachmentBlock.upload": "Melléklet feltöltése.", "AttachmentBlock.uploadSuccess": "A melléklet feltöltése sikeres.", "AttachmentElement.delete-confirmation-dialog-button-text": "Törlés", "AttachmentElement.download": "Letöltés", "AttachmentElement.upload-percentage": "Feltöltés...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Csoport hozzáadása", "BoardComponent.delete": "Törlés", "BoardComponent.hidden-columns": "Rejtett oszlopok", "BoardComponent.hide": "Elrejtés", "BoardComponent.new": "+ Új", "BoardComponent.no-property": "Nincs {property}", "BoardComponent.no-property-title": "Elemek üres {property} tulajdonsággal kerülnek ide. Ez az oszlop nem eltávolítható.", "BoardComponent.show": "Mutat", "BoardMember.schemeAdmin": "Admin", "BoardMember.schemeCommenter": "Véleményező", "BoardMember.schemeEditor": "Szerkesztő", "BoardMember.schemeNone": "Nincs", "BoardMember.schemeViewer": "Megtekintő", "BoardMember.unlinkChannel": "Leválasztás", "BoardPage.newVersion": "Elérhető a Táblák egy új verziója, kattintson ide az újratöltéshez.", "BoardPage.syncFailed": "A tábla törölve lett vagy hozzáférés vissza lett vonva.", "BoardTemplateSelector.add-template": "Új sablon létrehozása", "BoardTemplateSelector.create-empty-board": "Üres tábla készítése", "BoardTemplateSelector.delete-template": "Törlés", "BoardTemplateSelector.description": "Adjon hozzá egy táblát az oldalsávhoz az alább meghatározott sablonok bármelyikével, vagy kezdje elölről.", "BoardTemplateSelector.edit-template": "Szerkesztés", "BoardTemplateSelector.plugin.no-content-description": "Adjon hozzá egy táblát az oldalsávhoz az alább megadott sablonok bármelyikével, vagy kezdje elölről.", "BoardTemplateSelector.plugin.no-content-title": "Tábla létrehozása", "BoardTemplateSelector.title": "Tábla létrehozása", "BoardTemplateSelector.use-this-template": "Használja ezt a sablont", "BoardsSwitcher.Title": "Táblák keresése", "BoardsUnfurl.Limited": "A kártya archiválása miatt a további részletek rejtve vannak", "BoardsUnfurl.Remainder": "+{remainder} további", "BoardsUnfurl.Updated": "Frissítve {time}", "Calculations.Options.average.displayName": "Átlag", "Calculations.Options.average.label": "Átlag", "Calculations.Options.count.displayName": "Mennyiség", "Calculations.Options.count.label": "Mennyiség", "Calculations.Options.countChecked.displayName": "Kijelölt", "Calculations.Options.countChecked.label": "Kijelöltek száma", "Calculations.Options.countUnchecked.displayName": "Nem kijelölt", "Calculations.Options.countUnchecked.label": "Nem kijelöltek száma", "Calculations.Options.countUniqueValue.displayName": "Egyedi", "Calculations.Options.countUniqueValue.label": "Egyedi értékek száma", "Calculations.Options.countValue.displayName": "Értékek", "Calculations.Options.countValue.label": "Értékek száma", "Calculations.Options.dateRange.displayName": "Tartomány", "Calculations.Options.dateRange.label": "Tartomány", "Calculations.Options.earliest.displayName": "Korábbi", "Calculations.Options.earliest.label": "Korábbi", "Calculations.Options.latest.displayName": "Későbbi", "Calculations.Options.latest.label": "Későbbi", "Calculations.Options.max.displayName": "Max", "Calculations.Options.max.label": "Max", "Calculations.Options.median.displayName": "Közép", "Calculations.Options.median.label": "Közép", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Kiszámítás", "Calculations.Options.none.label": "Nincs", "Calculations.Options.percentChecked.displayName": "Kijelölt", "Calculations.Options.percentChecked.label": "Kijelöltek aránya", "Calculations.Options.percentUnchecked.displayName": "Nem kijelölt", "Calculations.Options.percentUnchecked.label": "Nem kijelöltek aránya", "Calculations.Options.range.displayName": "Tartomány", "Calculations.Options.range.label": "Tartomány", "Calculations.Options.sum.displayName": "Összeg", "Calculations.Options.sum.label": "Összeg", "CalendarCard.untitled": "Névtelen", "CardActionsMenu.copiedLink": "Másolva!", "CardActionsMenu.copyLink": "Link másolása", "CardActionsMenu.delete": "Törlés", "CardActionsMenu.duplicate": "Duplikálás", "CardBadges.title-checkboxes": "Teendők", "CardBadges.title-comments": "Megjegyzések", "CardBadges.title-description": "Ennek a kártyának van leírása", "CardDetail.Attach": "Csatolás", "CardDetail.Follow": "Követés", "CardDetail.Following": "Követés", "CardDetail.add-content": "Tartalom hozzáadása", "CardDetail.add-icon": "Ikon hozzáadása", "CardDetail.add-property": "+ Tulajdonság hozzáadása", "CardDetail.addCardText": "kártya szövegének hozzáadása", "CardDetail.limited-body": "Az archivált kártyák megtekintéséhez, a táblánkénti korlátlan megtekintéshez, korlátlan számú kártyához és még sok máshoz váltson a Professional vagy Enterprise csomagra.", "CardDetail.limited-button": "Kiadás váltása", "CardDetail.limited-title": "Ez a kártya rejtett", "CardDetail.moveContent": "Kártya tartalmának mozgatása", "CardDetail.new-comment-placeholder": "Megjegyzés hozzáadása...", "CardDetailProperty.confirm-delete-heading": "Tulajdonság törlésének jóváhagyása", "CardDetailProperty.confirm-delete-subtext": "Biztos benne, hogy törölni szeretné a \"{propertyName}\" tulajdonságot? A törléssel a tulajdonság minden kártyáról el lesz távolítva.", "CardDetailProperty.confirm-property-name-change-subtext": "Biztosan szeretné megváltoztatni a \"{propertyName}\" tulajdonság {customText}? Ez érint {numOfCards} kártya adatát ebben a táblában, és akár adatvesztéssel is járhat.", "CardDetailProperty.confirm-property-type-change": "Hagyja jóvá a tulajdonság típusának módosítását", "CardDetailProperty.delete-action-button": "Törlés", "CardDetailProperty.property-change-action-button": "Tulajdonság módosítása", "CardDetailProperty.property-changed": "Tulajdonság sikeresen módosult!", "CardDetailProperty.property-deleted": "{propertyName} törlése sikeres!", "CardDetailProperty.property-name-change-subtext": "típus erről: \"{oldPropType}\" erre: \"{newPropType}\"", "CardDetial.limited-link": "Tudjon meg többet csomagjainkról.", "CardDialog.delete-confirmation-dialog-attachment": "Erősítse meg a csatolmány törlését!", "CardDialog.delete-confirmation-dialog-button-text": "Törlés", "CardDialog.delete-confirmation-dialog-heading": "Hagyja jóvá a kártya törlését!", "CardDialog.editing-template": "Ön egy sablont szerkeszt.", "CardDialog.nocard": "Ez a kártya nem létezik vagy elérhetetlen.", "Categories.CreateCategoryDialog.CancelText": "Mégsem", "Categories.CreateCategoryDialog.CreateText": "Létrehozás", "Categories.CreateCategoryDialog.Placeholder": "Nevezze el a kategóriáját", "Categories.CreateCategoryDialog.UpdateText": "Frissítés", "CenterPanel.Login": "Bejelentkezés", "CenterPanel.Share": "Megosztás", "ColorOption.selectColor": "{color} szín kiválasztása", "Comment.delete": "Törlés", "CommentsList.send": "Küldés", "ConfirmationDialog.cancel-action": "Mégsem", "ConfirmationDialog.confirm-action": "Jóváhagyás", "ContentBlock.Delete": "Törlés", "ContentBlock.DeleteAction": "törlés", "ContentBlock.addElement": "{type} hozzáadása", "ContentBlock.checkbox": "jelölőnégyzet", "ContentBlock.divider": "elválasztó", "ContentBlock.editCardCheckbox": "három állású jelölőnégyzet", "ContentBlock.editCardCheckboxText": "kártya szövegének szerkesztése", "ContentBlock.editCardText": "kártya szövegének szerkesztése", "ContentBlock.editText": "Szöveg szerkesztése...", "ContentBlock.image": "kép", "ContentBlock.insertAbove": "Beszúrás fölé", "ContentBlock.moveBlock": "kártya tartalmának áthelyezése", "ContentBlock.moveDown": "Mozgatás le", "ContentBlock.moveUp": "Mozgatás fel", "ContentBlock.text": "szöveg", "DateRange.clear": "Törlés", "DateRange.empty": "Üres", "DateRange.endDate": "Vége dátum", "DateRange.today": "Ma", "DeleteBoardDialog.confirm-cancel": "Mégsem", "DeleteBoardDialog.confirm-delete": "Törlés", "DeleteBoardDialog.confirm-info": "Biztos benne, hogy törölni szeretné a “{boardTitle}” táblát? A törlésével az összes benne lévő kártya is törlődni fog.", "DeleteBoardDialog.confirm-info-template": "Biztos, hogy törölni szeretné a \"{boardTitle}\" tábla sablont?", "DeleteBoardDialog.confirm-tite": "Tábla törlésének jóváhagyása", "DeleteBoardDialog.confirm-tite-template": "Tábla sablon törlésének jóváhagyása", "Dialog.closeDialog": "Ablak bezárása", "EditableDayPicker.today": "Ma", "Error.mobileweb": "Mobil web támogatás jelenleg előzetes béta állapotban van. Nem minden funkcionalitás érhető el.", "Error.websocket-closed": "Websocket kapcsolat bezárult, kapcsolat megszakadt, Ha ez továbbra is fennáll, akkor ellenőrizze le a kiszolgáló vagy web proxy beállítását.", "Filter.contains": "tartalmazza", "Filter.ends-with": "végződik", "Filter.includes": "tartalmazza", "Filter.is": "egy", "Filter.is-empty": "üres", "Filter.is-not-empty": "nem üres", "Filter.is-not-set": "nincs megadva", "Filter.is-set": "meg van adva", "Filter.not-contains": "nem tartalmazza", "Filter.not-ends-with": "nem végződik", "Filter.not-includes": "nem tartalmazza", "Filter.not-starts-with": "nem kezdődik", "Filter.starts-with": "kezdődik", "FilterByText.placeholder": "szöveg szűrése", "FilterComponent.add-filter": "+ Szűrő hozzáadása", "FilterComponent.delete": "Törlés", "FindBoardsDialog.IntroText": "Táblák keresése", "FindBoardsDialog.NoResultsFor": "Nincs találat a \"{searchQuery}\" kereséshez", "FindBoardsDialog.NoResultsSubtext": "Ellenőrizze az elgépelést vagy próbáljon egy új keresést.", "FindBoardsDialog.SubTitle": "Gépeljen, hogy megtalálja a táblát. Használja a FEL/LE gombokat a böngészéshez. ENTER gombot a kiválasztáshoz és ESC gombot az eldobáshoz", "FindBoardsDialog.Title": "Táblák keresése", "GroupBy.hideEmptyGroups": "{count} üres csoport elrejtése", "GroupBy.showHiddenGroups": "{count} rejtett csoport megjelenítése", "GroupBy.ungroup": "Csoportosítás megszüntetése", "HideBoard.MenuOption": "Tábla elrejtése", "KanbanCard.untitled": "Névtelen", "MentionSuggestion.is-not-board-member": "(nem tagja a táblának)", "Mutator.new-board-from-template": "új tábla sablon alapján", "Mutator.new-card-from-template": "új kártya sablonból", "Mutator.new-template-from-card": "új sablon kártyából", "OnboardingTour.AddComments.Body": "Hozzászólhat a témákhoz, sőt, más Mattermost felhasználó társát is @megemlítheti, hogy felhívja a figyelmüket.", "OnboardingTour.AddComments.Title": "Megjegyzés hozzáadása", "OnboardingTour.AddDescription.Body": "Adjon leírást a kártyájához, hogy csapattársai tudják, miről szól a kártya.", "OnboardingTour.AddDescription.Title": "Leírás hozzáadása", "OnboardingTour.AddProperties.Body": "Adjon hozzá különböző tulajdonságokat a kártyákhoz, hogy hatékonyabbá tegye őket!", "OnboardingTour.AddProperties.Title": "Tulajdonságok hozzáadása", "OnboardingTour.AddView.Body": "Menjen ide, és hozzon létre új nézetet, hogy különböző elrendezésekkel rendszerezze a tábláját.", "OnboardingTour.AddView.Title": "Új nézet hozzáadása", "OnboardingTour.CopyLink.Body": "Megoszthatja kártyáit csapattársaival a link másolásával és beillesztésével egy csatornán, közvetlen üzenetben vagy csoportos üzenetben.", "OnboardingTour.CopyLink.Title": "Link másolása", "OnboardingTour.OpenACard.Body": "Nyisson meg egy kártyát, hogy felfedezze, milyen hatékony módon segíthetnek a táblák a munkája megszervezésében.", "OnboardingTour.OpenACard.Title": "Kártya megnyitása", "OnboardingTour.ShareBoard.Body": "A táblát megoszthatja belsőleg, a csapatán belül, vagy nyilvánosan is közzéteheti, hogy a szervezeten kívül is látható legyen.", "OnboardingTour.ShareBoard.Title": "Tábla megosztása", "PersonProperty.board-members": "Tábla tagjai", "PersonProperty.non-board-members": "Nem tábla tagok", "PropertyMenu.Delete": "Törlés", "PropertyMenu.changeType": "Tulajdonság típusának módosítása", "PropertyMenu.selectType": "Tulajdonság típusának kiválasztása", "PropertyMenu.typeTitle": "Típus", "PropertyType.Checkbox": "Jelölőnégyzet", "PropertyType.CreatedBy": "Létrehozta", "PropertyType.CreatedTime": "Létrehozás ideje", "PropertyType.Date": "Dátum", "PropertyType.Email": "E-mail", "PropertyType.MultiPerson": "Több személy", "PropertyType.MultiSelect": "Több kiválasztós", "PropertyType.Number": "Szám", "PropertyType.Person": "Személy", "PropertyType.Phone": "Telefon", "PropertyType.Select": "Kiválasztás", "PropertyType.Text": "Szöveg", "PropertyType.Unknown": "Ismeretlen", "PropertyType.UpdatedBy": "Utoljára frissítette", "PropertyType.UpdatedTime": "Utolsó frissítés ideje", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Üres", "RegistrationLink.confirmRegenerateToken": "Ez érvényteleníteni fogja a korábban megosztott linkeket. Folytassuk?", "RegistrationLink.copiedLink": "Másolt!", "RegistrationLink.copyLink": "Link másolása", "RegistrationLink.description": "Ossza meg ezt a linket másokkat a fiók létrehozásához:", "RegistrationLink.regenerateToken": "Token újragenerálása", "RegistrationLink.tokenRegenerated": "Regisztrációs link újragenerálva", "ShareBoard.PublishDescription": "Csak olvasható link közzététele és megosztása mindenkivel a weben.", "ShareBoard.PublishTitle": "Közzététel a webre", "ShareBoard.ShareInternal": "Megosztás belsőleg", "ShareBoard.ShareInternalDescription": "A jogosultságokkal rendelkező felhasználók használhatják ezt a linket.", "ShareBoard.Title": "Tábla megosztása", "ShareBoard.confirmRegenerateToken": "Ez érvényteleníteni fogja a korábban megosztott linkeket. Folytassuk?", "ShareBoard.copiedLink": "Másolt!", "ShareBoard.copyLink": "Link másolása", "ShareBoard.regenerate": "Token újragenerálása", "ShareBoard.searchPlaceholder": "Személyek és csatornák keresése", "ShareBoard.teamPermissionsText": "Mindenki a {teamName} Csapatban", "ShareBoard.tokenRegenrated": "Token újragenerálva", "ShareBoard.userPermissionsRemoveMemberText": "Tag eltávolítása", "ShareBoard.userPermissionsYouText": "(Ön)", "ShareTemplate.Title": "Sablon megosztása", "ShareTemplate.searchPlaceholder": "Személyek keresése", "Sidebar.about": "Focalboard névjegye", "Sidebar.add-board": "+ Tábla hozzáadása", "Sidebar.changePassword": "Jelszó módosítása", "Sidebar.delete-board": "Tábla törlése", "Sidebar.duplicate-board": "Tábla duplikálása", "Sidebar.export-archive": "Archiváltak exportálása", "Sidebar.import": "Importálás", "Sidebar.import-archive": "Archiváltak importálása", "Sidebar.invite-users": "Felhasználók meghívása", "Sidebar.logout": "Kijelentkezés", "Sidebar.new-category.badge": "Új", "Sidebar.new-category.drag-boards-cta": "Húzza a táblákat ide...", "Sidebar.no-boards-in-category": "Nincsennek bent táblák", "Sidebar.product-tour": "Termék bemutató", "Sidebar.random-icons": "Véletlen ikonok", "Sidebar.set-language": "Nyelv megadása", "Sidebar.set-theme": "Téma megadása", "Sidebar.settings": "Beállítások", "Sidebar.template-from-board": "Új sablon a táblából", "Sidebar.untitled-board": "(Névtelen tábla)", "Sidebar.untitled-view": "(Névtelen nézet)", "SidebarCategories.BlocksMenu.Move": "Áthelyezés...", "SidebarCategories.CategoryMenu.CreateNew": "Új kategória létrehozása", "SidebarCategories.CategoryMenu.Delete": "Kategória törlése", "SidebarCategories.CategoryMenu.DeleteModal.Body": "A {categoryName} kategóriában lévő táblák visszakerülnek a Táblák kategóriákba. Ön egyik táblából sem lesz eltávolítva.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Törli ezt a kategóriát?", "SidebarCategories.CategoryMenu.Update": "Kategória átnevezése", "SidebarTour.ManageCategories.Body": "Egyéni kategóriák létrehozása és kezelése. A kategóriák felhasználó-specifikusak, így egy tábla áthelyezése a kategóriájába nem befolyásolja az ugyanazt a táblát használó többi tagot.", "SidebarTour.ManageCategories.Title": "Kategóriák kezelése", "SidebarTour.SearchForBoards.Body": "A táblaváltó megnyitásával (Cmd/Ctrl + K) gyorsan kereshet és adhat hozzá táblákat az oldalsávjához.", "SidebarTour.SearchForBoards.Title": "Tábla keresése", "SidebarTour.SidebarCategories.Body": "Az összes tábláját mostantól az új oldalsávja alá rendezi. Nincs többé váltás a munkaterületek között. A v7.2 frissítés részeként automatikusan létrejöhettek az Ön számára a korábbi munkaterületek alapján létrehozott egyszeri egyéni kategóriák. Ezeket eltávolíthatja vagy szerkesztheti tetszése szerint.", "SidebarTour.SidebarCategories.Link": "Tudjon meg többet", "SidebarTour.SidebarCategories.Title": "Oldalsáv kategóriák", "SiteStats.total_boards": "Összes tábla", "SiteStats.total_cards": "Összes kártya", "TableComponent.add-icon": "Ikon hozzáadása", "TableComponent.name": "Név", "TableComponent.plus-new": "+ Új", "TableHeaderMenu.delete": "Törlés", "TableHeaderMenu.duplicate": "Duplikálás", "TableHeaderMenu.hide": "Elrejtés", "TableHeaderMenu.insert-left": "Beillesztés balra", "TableHeaderMenu.insert-right": "Beillesztés jobbra", "TableHeaderMenu.sort-ascending": "Rendezés növekvő sorrendben", "TableHeaderMenu.sort-descending": "Rendezés csökkenő sorrendben", "TableRow.MoreOption": "További műveletek", "TableRow.open": "Megnyitás", "TopBar.give-feedback": "Visszajelzés", "URLProperty.copiedLink": "Másolva!", "URLProperty.copy": "Másolás", "URLProperty.edit": "Szerkesztés", "UndoRedoHotKeys.canRedo": "Újracsinálás", "UndoRedoHotKeys.canRedo-with-description": "Újracsinálás {description}", "UndoRedoHotKeys.canUndo": "Visszavonás", "UndoRedoHotKeys.canUndo-with-description": "Visszavonás {description}", "UndoRedoHotKeys.cannotRedo": "Nincs mit újracsinálni", "UndoRedoHotKeys.cannotUndo": "Nincs mit visszavonni", "ValueSelector.noOptions": "Nincsenek lehetőségek. Kezdjen el gépelni, hogy hozzáadja az elsőt!", "ValueSelector.valueSelector": "Érték kiválasztó", "ValueSelectorLabel.openMenu": "Menü megnyitása", "VersionMessage.help": "Tekintse meg ezen verzió újdonságait.", "View.AddView": "Nézet hozzáadása", "View.Board": "Tábla", "View.DeleteView": "Nézet törlése", "View.DuplicateView": "Nézet duplikálása", "View.Gallery": "Galéria", "View.NewBoardTitle": "Tábla nézet", "View.NewCalendarTitle": "Naptár nézet", "View.NewGalleryTitle": "Galéria nézet", "View.NewTableTitle": "Táblázat nézet", "View.NewTemplateDefaultTitle": "Névtelen sablon", "View.NewTemplateTitle": "Névtelen", "View.Table": "Táblázat", "ViewHeader.add-template": "Új sablon", "ViewHeader.delete-template": "Törlés", "ViewHeader.display-by": "Rendezés: {property}", "ViewHeader.edit-template": "Szerkesztés", "ViewHeader.empty-card": "Üres kártya", "ViewHeader.export-board-archive": "Archivált tábla exportálása", "ViewHeader.export-complete": "Exportálás kész!", "ViewHeader.export-csv": "Exportálás CSV-be", "ViewHeader.export-failed": "Exportálás meghiúsult!", "ViewHeader.filter": "Szűrő", "ViewHeader.group-by": "Csoportosítás: {property}", "ViewHeader.new": "Új", "ViewHeader.properties": "Tulajdonságok", "ViewHeader.properties-menu": "Tulajdonságok menü", "ViewHeader.search-text": "Kártya keresése", "ViewHeader.select-a-template": "Sablon kiválasztása", "ViewHeader.set-default-template": "Beállítás alapértelmezettnek", "ViewHeader.sort": "Rendezés", "ViewHeader.untitled": "Névtelen", "ViewHeader.view-header-menu": "Fejléc menü megjelenítése", "ViewHeader.view-menu": "Megtekintés menü", "ViewLimitDialog.Heading": "Táblánkénti megtekintések korlátja elérve", "ViewLimitDialog.PrimaryButton.Title.Admin": "Előfizetés váltása", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Admin értesítése", "ViewLimitDialog.Subtext.Admin": "A Professional vagy Enterprise csomagra való váltással korlátlan számú megtekintést kaphat táblánként, korlátlan számú kártyát és még sok mást.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Tudjon meg többet a csomagjainkról.", "ViewLimitDialog.Subtext.RegularUser": "Értesítse a rendszergazdát, hogy frissíthessen a Professional vagy Enterprise csomagra, hogy korlátlan megtekintést kapjon táblánként, korlátlan számú kártyát és még többet.", "ViewLimitDialog.UpgradeImg.AltText": "előfizetés váltás kép", "ViewLimitDialog.notifyAdmin.Success": "A rendszergazdája értesítve lett", "ViewTitle.hide-description": "leírás elrejtése", "ViewTitle.pick-icon": "Válasszon ikont", "ViewTitle.random-icon": "Véletlen", "ViewTitle.remove-icon": "Ikon eltávolítása", "ViewTitle.show-description": "leírás mutatása", "ViewTitle.untitled-board": "Névtelen tábla", "WelcomePage.Description": "A Táblák egy projekt kezelő segédeszköz ami segít azonosítani, rendezni, követni és vezetni a munkát csapatok között, egy ismerős kanban táblás nézet segítségével.", "WelcomePage.Explore.Button": "Nézze meg a bemutatót", "WelcomePage.Heading": "Üdvözöljük a Táblákban", "WelcomePage.NoThanks.Text": "Nem, köszönöm, majd kitalálom magam", "WelcomePage.StartUsingIt.Text": "Kezdjük el használni", "Workspace.editing-board-template": "Ön egy sablon táblát szerkeszt.", "badge.guest": "Vendég", "boardSelector.confirm-link-board": "Kösse össze a táblát csatornával", "boardSelector.confirm-link-board-button": "Igen, kösse össze a táblát", "boardSelector.confirm-link-board-subtext": "Ha a \"{boardName}\" táblát összekapcsolja a csatornával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt. Ez nem vonatkozik a vendég tagokra. A táblát bármikor leválaszthatja a csatornáról.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Amikor a \"{boardName}\" táblát összekapcsolja a csatornával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt. Ez nem vonatkozik a vendég tagokra.{lineBreak} A tábla jelenleg egy másik csatornához van kapcsolva. Le lesz választva, amennyiben úgy dönt, hogy ide kapcsolja.", "boardSelector.create-a-board": "Tábla létrehozása", "boardSelector.link": "Összekapcsolás", "boardSelector.search-for-boards": "Táblák keresése", "boardSelector.title": "Táblák összekapcsolása", "boardSelector.unlink": "Leválasztás", "calendar.month": "Hónap", "calendar.today": "MA", "calendar.week": "Hét", "cloudMessage.learn-more": "Tudjon meg többet", "createImageBlock.failed": "Nem sikerült feltölteni a fájlt. Fájlméret korlát elérve.", "default-properties.badges": "Megjegyzések és leírás", "default-properties.title": "Cím", "error.back-to-home": "Vissza a kezdőlapra", "error.back-to-team": "Vissza a csapatba", "error.board-not-found": "Tábla nem található.", "error.go-login": "Bejelentkezés", "error.invalid-read-only-board": "Önnek nincs hozzáférése ehhez a táblához. Jelentkezz be a Táblák eléréséhez.", "error.not-logged-in": "Lehet, hogy lejárt a munkamenete, vagy nincs bejelentkezve. Jelentkezzen be újra a Táblákhoz való hozzáféréshez.", "error.page.title": "Sajnálom, valami rosszul sikerült", "error.team-undefined": "Nem egy érvényes csapat.", "error.unknown": "Hiba lépett fel.", "generic.previous": "Előző", "guest-no-board.subtitle": "Ebben a csapatban még nincs hozzáférése egyik táblához sem, kérjük, várjon, amíg valaki felveszi Önt bármelyik táblához.", "guest-no-board.title": "Még nincsenek táblák", "imagePaste.upload-failed": "Néhány fájl nem került feltöltésre. Fájlméret korlát elérve", "limitedCard.title": "Rejtett kártyák", "login.log-in-button": "Bejelentkezés", "login.log-in-title": "Bejelentkezés", "login.register-button": "vagy hozzon létre egy fiókot ha még nincs", "notification-box-card-limit-reached.close-tooltip": "Altatás 10 napig", "notification-box-card-limit-reached.contact-link": "értesítheti a rendszergazdát", "notification-box-card-limit-reached.link": "Váltson fizetős csomagra", "notification-box-card-limit-reached.title": "{cards} kártya rejtve a táblán", "notification-box-cards-hidden.title": "Ez a művelet elrejtett egy másik kártyát", "notification-box.card-limit-reached.not-admin.text": "Az archivált kártyák eléréséhez {contactLink}, hogy váltson fizetős csomagra.", "notification-box.card-limit-reached.text": "A kártyák limitjét elérte, a régebbi kártyák megtekintéséhez {link}", "person.add-user-to-board": "{username} hozzáadása a táblához", "person.add-user-to-board-confirm-button": "Hozzáadás a táblához", "person.add-user-to-board-permissions": "Jogosultságok", "person.add-user-to-board-question": "Biztosan hozzá szeretné adni {username} felhasználót a táblához?", "person.add-user-to-board-warning": "{username} nem tagja a táblának, és nem kap értesítést róla.", "register.login-button": "vagy jelentkezzen be ha már van fiókja", "register.signup-title": "Regisztráljon fiókjáért", "rhs-board-non-admin-msg": "Ön nem rendszergazdája a táblának", "rhs-boards.add": "Hozzáadás", "rhs-boards.dm": "KÜ", "rhs-boards.gm": "CSÜ", "rhs-boards.header.dm": "ez egy Közvetlen Üzenet", "rhs-boards.header.gm": "ez egy Csoportos Üzenet", "rhs-boards.last-update-at": "Utolsó frissítés: {datetime}", "rhs-boards.link-boards-to-channel": "Táblák összekapcsolása a {channelName} csatornával", "rhs-boards.linked-boards": "Összekapcsolt kártyák", "rhs-boards.no-boards-linked-to-channel": "Még nincs {channelName} csatornához kapcsolódó tábla", "rhs-boards.no-boards-linked-to-channel-description": "A Boards egy projekt kezelő eszköz, amely segít meghatározni, szervezni, nyomon követni és kezelni a munkát a csapatokon belül, egy ismerős kanban tábla nézet segítségével.", "rhs-boards.unlink-board": "Tábla leválasztása", "rhs-boards.unlink-board1": "Tábla leválasztása", "rhs-channel-boards-header.title": "Táblák", "share-board.publish": "Közzététel", "share-board.share": "Megosztás", "shareBoard.channels-select-group": "Csatornák", "shareBoard.confirm-change-team-role.body": "Mindenki ebben a táblában, akinek alacsonyabb jogosultsága van, mint a \"{role}\" szerepkör, mostantól {role} lesz. Biztos, hogy meg akarja változtatni a tábla minimális szerepkörét?", "shareBoard.confirm-change-team-role.confirmBtnText": "Minimális szerepkör módosítása", "shareBoard.confirm-change-team-role.title": "Minimális szerepkör módosítása", "shareBoard.confirm-link-channel": "Tábla összekapcsolása csatornával", "shareBoard.confirm-link-channel-button": "Csatorna összekapcsolása", "shareBoard.confirm-link-channel-button-with-other-channel": "Leválasztás és ide kapcsolás", "shareBoard.confirm-link-channel-subtext": "Ha egy csatornát összekapcsol egy táblával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt. Ez nem vonatkozik a vendég tagokra.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Ha egy csatornát összekapcsol egy táblával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt. Ez nem vonatkozik a vendég tagokra.{lineBreak}A tábla jelenleg egy másik csatornához van kapcsolva. Le lesz választva, amennyiben úgy dönt, hogy ide kapcsolja.", "shareBoard.confirm-unlink.body": "Ha egy csatornát leválaszt egy tábláról, a csatorna minden tagja (meglévő és új) elveszíti a hozzáférést, kivéve, ha külön engedélyt kapott rá.", "shareBoard.confirm-unlink.confirmBtnText": "Csatorna leválasztása", "shareBoard.confirm-unlink.title": "Csatorna leválasztása a tábláról", "shareBoard.lastAdmin": "A tábláknak legalább egy Adminisztárorral kell rendelkezniük", "shareBoard.members-select-group": "Tagok", "shareBoard.unknown-channel-display-name": "Ismeretlen csatorna", "tutorial_tip.finish_tour": "Kész", "tutorial_tip.got_it": "Értettem", "tutorial_tip.ok": "Következő", "tutorial_tip.out": "Ezen tippek kikapcsolása.", "tutorial_tip.seen": "Ezt látta már?" } ================================================ FILE: webapp/i18n/id.json ================================================ { "BoardComponent.add-a-group": "+ Tambahkan kelompok", "BoardComponent.delete": "Hapus", "BoardComponent.hidden-columns": "Kolom-kolom yang disembunyikan", "BoardComponent.hide": "Sembunyikan", "BoardComponent.new": "+ Buat", "BoardComponent.no-property": "Tidak ada {property}", "BoardComponent.no-property-title": "Item dengan properti {property} yang kosong akan berpindah ke sini. Kolom ini tidak dapat dihapus.", "BoardComponent.show": "Tampilkan", "BoardPage.newVersion": "Versi baru Board tersedia, klik di sini untuk memuat ulang.", "BoardPage.syncFailed": "Papan mungkin terhapus atau akses Anda ke papan ditolak.", "BoardTemplateSelector.add-template": "Template baru", "BoardTemplateSelector.create-empty-board": "Buat board kosong", "BoardTemplateSelector.delete-template": "Hapus", "BoardTemplateSelector.description": "Pilih template untuk membantu Anda memulai. Sesuaikan template dengan mudah agar sesuai dengan kebutuhan Anda, atau buat papan kosong untuk memulai dari awal.", "BoardTemplateSelector.edit-template": "Ubah", "BoardTemplateSelector.plugin.no-content-description": "Tambahkan papan ke bilah sisi menggunakan salah satu kerangka yang ditentukan di bawah atau mulai dari awal.{lineBreak} Anggota \"{workspaceName}\" akan memiliki akses ke papan yang dibuat di sini.", "BoardTemplateSelector.plugin.no-content-title": "Buat Papan di {workspaceName}", "BoardTemplateSelector.title": "Buat Papan", "CardDetail.add-content": "Tambahkan isi", "CardDetail.add-icon": "Tambahkan ikon", "CardDetail.add-property": "+ Tambahkan properti", "CardDetail.addCardText": "tambahkan teks kartu", "CardDetail.moveContent": "pindahkan isi kartu", "CardDetail.new-comment-placeholder": "Tambahkan komentar...", "CardDialog.editing-template": "Anda sedang menyunting sebuah template.", "CardDialog.nocard": "Kartu ini tidak ada atau tidak dapat diakses.", "ColorOption.selectColor": "Pilih Warna {color}", "Comment.delete": "Hapus", "CommentsList.send": "Kirim", "ContentBlock.Delete": "Hapus", "ContentBlock.DeleteAction": "hapus", "ContentBlock.addElement": "tambahkan {type}", "ContentBlock.checkbox": "kotak centang", "ContentBlock.divider": "pembagi", "ContentBlock.editCardCheckbox": "kotakpilihan-beralih", "ContentBlock.editCardCheckboxText": "sunting teks kartu", "ContentBlock.editCardText": "sunting teks kartu", "ContentBlock.editText": "Sunting teks...", "ContentBlock.image": "gambar", "ContentBlock.insertAbove": "Masukkan di atas", "ContentBlock.moveDown": "Turunkan", "ContentBlock.moveUp": "Naikkan", "ContentBlock.text": "teks", "Dialog.closeDialog": "Tutup dialog", "EditableDayPicker.today": "Hari Ini", "Error.websocket-closed": "Koneksi ke soket web tertutup, koneksi terganggu. Jika hal ini terus berlanjut, periksa konfigurasi server atau proxy web Anda.", "Filter.includes": "termasuk", "Filter.is-empty": "kosong", "Filter.is-not-empty": "tidak kosong", "Filter.not-includes": "tidak termasuk", "FilterComponent.add-filter": "+ Tambahkan saringan", "FilterComponent.delete": "Hapus", "GroupBy.ungroup": "Pisahkan grup", "KanbanCard.untitled": "Tidak berjudul", "Mutator.new-card-from-template": "kartu baru dari template", "Mutator.new-template-from-card": "template baru dari kartu", "PropertyMenu.Delete": "Hapus", "PropertyMenu.changeType": "Ubah jenis properti", "PropertyMenu.typeTitle": "Jenis", "PropertyType.Checkbox": "Kotak Centang", "PropertyType.CreatedBy": "Dibuat oleh", "PropertyType.CreatedTime": "Waktu dibuat", "PropertyType.Date": "Tanggal", "PropertyType.Email": "Surel", "PropertyType.MultiSelect": "Banyak Pilihan", "PropertyType.Number": "Angka", "PropertyType.Person": "Orang", "PropertyType.Phone": "Telepon", "PropertyType.Select": "Pilihan", "PropertyType.Text": "Teks", "PropertyType.UpdatedBy": "Diperbarui oleh", "PropertyType.UpdatedTime": "Waktu diperbarui", "RegistrationLink.confirmRegenerateToken": "Ini akan membuat tautan yang sebelumnya dibagikan tidak valid. Lanjutkan?", "RegistrationLink.copiedLink": "Disalin!", "RegistrationLink.copyLink": "Salin tautan", "RegistrationLink.description": "Bagikan tautan ini untuk membuat akun lainnya:", "RegistrationLink.regenerateToken": "Buat ulang token", "RegistrationLink.tokenRegenerated": "Tautan pendaftaran dibuat ulang", "ShareBoard.confirmRegenerateToken": "Ini akan membuat tautan yang sebelumnya dibagikan tidak valid. Lanjutkan?", "ShareBoard.copiedLink": "Disalin!", "ShareBoard.copyLink": "Salin tautan", "ShareBoard.tokenRegenrated": "Token dibuat ulang", "Sidebar.about": "Tentang Focalboard", "Sidebar.add-board": "+ Tambahkan papan", "Sidebar.changePassword": "Ubah kata sandi", "Sidebar.delete-board": "Hapus papan", "Sidebar.export-archive": "Ekpor arsip", "Sidebar.import-archive": "Impor arsip", "Sidebar.invite-users": "Undang pengguna", "Sidebar.logout": "Keluar", "Sidebar.random-icons": "Ikon acak", "Sidebar.set-language": "Tetapkan bahasa", "Sidebar.set-theme": "Tetapkan tema", "Sidebar.settings": "Pengaturan", "Sidebar.untitled-board": "(Papan Tak Berjudul)", "TableComponent.add-icon": "Tambahkan ikon", "TableComponent.name": "Nama", "TableComponent.plus-new": "+ Buat", "TableHeaderMenu.delete": "Hapus", "TableHeaderMenu.duplicate": "Duplikasikan", "TableHeaderMenu.hide": "Sembunyikan", "TableHeaderMenu.insert-left": "Masukkan di kiri", "TableHeaderMenu.insert-right": "Masukkan di kanan", "TableHeaderMenu.sort-ascending": "Urutkan ke atas", "TableHeaderMenu.sort-descending": "Urutkan ke bawah", "TableRow.open": "Buka", "TopBar.give-feedback": "Beri Masukan", "ValueSelector.valueSelector": "Pilihan nilai", "ValueSelectorLabel.openMenu": "Menu terbuka", "View.AddView": "Tambahkan tampilan", "View.Board": "Papan", "View.DeleteView": "Hapus tampilan", "View.DuplicateView": "Duplikasikan tampilan", "View.NewBoardTitle": "Tampilan papan", "View.NewGalleryTitle": "Tampilan galeri", "View.NewTableTitle": "Tampilan tabel", "View.Table": "Tabel", "ViewHeader.add-template": "Template baru", "ViewHeader.delete-template": "Hapus", "ViewHeader.edit-template": "Sunting", "ViewHeader.empty-card": "Kartu kosong", "ViewHeader.export-board-archive": "Ekspor arsip papan", "ViewHeader.export-complete": "Ekspor selesai!", "ViewHeader.export-csv": "Ekspor ke CSV", "ViewHeader.export-failed": "Ekspor gagal!", "ViewHeader.filter": "Penyaringan", "ViewHeader.group-by": "Kelompokkan berdasarkan: {property}", "ViewHeader.new": "Buat", "ViewHeader.properties": "Properti-properti", "ViewHeader.search-text": "Cari teks", "ViewHeader.select-a-template": "Pilih sebuah template", "ViewHeader.sort": "Pengurutan", "ViewHeader.untitled": "Tak berjudul", "ViewTitle.hide-description": "sembunyikan deskripsi", "ViewTitle.pick-icon": "Pilih ikon", "ViewTitle.random-icon": "Acak", "ViewTitle.remove-icon": "Hapus ikon", "ViewTitle.show-description": "tampilkan deskripsi", "ViewTitle.untitled-board": "Papan tak berjudul", "WelcomePage.Description": "Papan adalah alat manajemen proyek yang membantu Anda untuk mendefinisikan, mengatur, memantau, dan mengelola pekerjaan dalam tim, menggunakan tampilan kanban yang mudah dipahami", "WelcomePage.Explore.Button": "Jelajahi", "WelcomePage.Heading": "Selamat Datang di Papan", "Workspace.editing-board-template": "Anda sedang menyunting sebuah template papan.", "default-properties.title": "Judul", "login.log-in-button": "Masuk", "login.log-in-title": "Masuk", "login.register-button": "atau buat sebuah akun jika Anda belum memilikinya", "register.login-button": "atau masuk jika Anda sudah memiliki akun", "register.signup-title": "Daftar untuk mendapatkan akun Anda" } ================================================ FILE: webapp/i18n/it.json ================================================ { "BoardComponent.add-a-group": "+ Aggiungi un gruppo", "BoardComponent.delete": "Elimina", "BoardComponent.hidden-columns": "Campi nascosti", "BoardComponent.hide": "Nascondi", "BoardComponent.new": "+ Nuovo", "BoardComponent.no-property": "No {property}", "BoardComponent.no-property-title": "Gli oggetti senza alcuna proprietà {property} andranno qui. Questo campo non può essere rimosso.", "BoardComponent.show": "Mostra", "BoardMember.schemeAdmin": "Amministratore", "BoardMember.schemeCommenter": "Commentatore", "BoardMember.schemeEditor": "Editore", "BoardMember.schemeNone": "Niente", "BoardMember.schemeViewer": "Vista", "BoardMember.unlinkChannel": "Rimuovi collegamento", "BoardPage.newVersion": "Una nuova versione di Board è disponibile, clicca qui per ricaricare.", "BoardPage.syncFailed": "La board potrebbe essere cancellata o l'accesso revocato.", "BoardTemplateSelector.add-template": "Nuovo modello", "BoardTemplateSelector.create-empty-board": "Crea una board vuota", "BoardTemplateSelector.delete-template": "Elimina", "BoardTemplateSelector.description": "Scegli un modello per iniziare. Personalizza facilmente il modello in base alle tue esigenze o crea una board vuota per iniziare da zero.", "BoardTemplateSelector.edit-template": "Modifica", "BoardTemplateSelector.plugin.no-content-description": "Aggiungi una bacheca alla barra laterale utilizzando uno dei modelli definiti di seguito o inizia da zero.{lineBreak} I membri di \"{teamName}\" avranno accesso alle bacheche create qui.", "BoardTemplateSelector.plugin.no-content-title": "Crea una bacheca", "BoardTemplateSelector.title": "Crea una bacheca", "BoardTemplateSelector.use-this-template": "Usa questo modello", "BoardsSwitcher.Title": "Trova bacheche", "BoardsUnfurl.Limited": "Altri dettagli non sono nascosti in quanto la scheda è archiviata", "BoardsUnfurl.Remainder": "+{remainder} di più", "BoardsUnfurl.Updated": "Aggiornato {time}", "Calculations.Options.average.displayName": "Media", "Calculations.Options.average.label": "Media", "Calculations.Options.count.displayName": "Conta", "Calculations.Options.count.label": "Conta", "Calculations.Options.countChecked.displayName": "Controllato", "Calculations.Options.countChecked.label": "Conteggio selezionati", "Calculations.Options.countUnchecked.displayName": "Non selezionato", "Calculations.Options.countUnchecked.label": "Conteggio non selezionato", "Calculations.Options.countUniqueValue.displayName": "Unico", "Calculations.Options.countUniqueValue.label": "Conta i valori unici", "Calculations.Options.countValue.displayName": "Valori", "Calculations.Options.countValue.label": "Conteggio valori", "Calculations.Options.dateRange.displayName": "Intervallo", "Calculations.Options.dateRange.label": "Intervallo", "Calculations.Options.earliest.displayName": "Primo", "Calculations.Options.earliest.label": "Primo", "Calculations.Options.latest.displayName": "Ultimo", "Calculations.Options.latest.label": "Ultimo", "Calculations.Options.max.displayName": "Massimo", "Calculations.Options.max.label": "Massimo", "Calculations.Options.median.displayName": "Mediana", "Calculations.Options.median.label": "Mediana", "Calculations.Options.min.displayName": "Minimo", "Calculations.Options.min.label": "Minimo", "Calculations.Options.none.displayName": "Calcola", "Calculations.Options.none.label": "Nulla", "Calculations.Options.percentChecked.displayName": "Controllato", "Calculations.Options.percentChecked.label": "Percentuale selezionata", "Calculations.Options.percentUnchecked.displayName": "Non selezionato", "Calculations.Options.percentUnchecked.label": "Percentuale non selezionata", "Calculations.Options.range.displayName": "Intervallo", "Calculations.Options.range.label": "Intervallo", "Calculations.Options.sum.displayName": "Somma", "Calculations.Options.sum.label": "Somma", "CalendarCard.untitled": "Senza titolo", "CardActionsMenu.copiedLink": "Copiato!", "CardActionsMenu.copyLink": "Copia collegamento", "CardActionsMenu.delete": "Elimina", "CardActionsMenu.duplicate": "Duplica", "CardBadges.title-checkboxes": "Checkboxes", "CardBadges.title-comments": "Commenti", "CardBadges.title-description": "Questa scheda ha una descrizione", "CardDetail.Follow": "Segui", "CardDetail.Following": "Prossimo", "CardDetail.add-content": "Aggiungi contenuto", "CardDetail.add-icon": "Aggiungi icona", "CardDetail.add-property": "+ Aggiungi una proprietà", "CardDetail.addCardText": "aggiungi testo alla scheda", "CardDetail.limited-button": "Aggiorna", "CardDetail.limited-title": "Questa scheda è nascosta", "CardDetail.moveContent": "Sposta il contenuto della scheda", "CardDetail.new-comment-placeholder": "Aggiungi un commento...", "CardDetailProperty.confirm-delete-heading": "Conferma l'eliminazione della proprietà", "CardDetailProperty.confirm-delete-subtext": "Sei sicuro di voler eliminare la proprietà \"{propertyName}\"? Rimuovendola, verranno eliminate le proprietà da tutte le carte in questa board.", "CardDetailProperty.confirm-property-name-change-subtext": "Sei sicuro di voler cambiare la proprietà \"{propertyName}\" {customText}? Questo influirà su {numOfCards} schede in questa bacheca e alcuni dati potrebbero andare persi.", "CardDetailProperty.confirm-property-type-change": "Conferma la modifica del tipo di proprietà", "CardDetailProperty.delete-action-button": "Rimuovi", "CardDetailProperty.property-change-action-button": "Cambia proprietà", "CardDetailProperty.property-changed": "Cambiata proprietà con successo!", "CardDetailProperty.property-deleted": "{propertyName} rimossa con successo!", "CardDetailProperty.property-name-change-subtext": "tipo da \"{oldPropType}\" a \"{newPropType}\"", "CardDetial.limited-link": "Scopri di più sui nostri piani.", "CardDialog.delete-confirmation-dialog-button-text": "Elimina", "CardDialog.delete-confirmation-dialog-heading": "Conferma l'eliminazione della scheda!", "CardDialog.editing-template": "Stai modificando un template.", "CardDialog.nocard": "Questa scheda non esiste o è inaccessibile.", "Categories.CreateCategoryDialog.CancelText": "Cancella", "Categories.CreateCategoryDialog.CreateText": "Crea", "Categories.CreateCategoryDialog.Placeholder": "Nomina la tua categoria", "Categories.CreateCategoryDialog.UpdateText": "Aggiorna", "CenterPanel.Login": "Login", "CenterPanel.Share": "Condividi", "ColorOption.selectColor": "Seleziona{color} Colore", "Comment.delete": "Elimina", "CommentsList.send": "Invia", "ConfirmationDialog.cancel-action": "Annulla", "ConfirmationDialog.confirm-action": "Conferma", "ContentBlock.Delete": "Elimina", "ContentBlock.DeleteAction": "elimina", "ContentBlock.addElement": "aggiungi {type}", "ContentBlock.checkbox": "casella di controllo", "ContentBlock.divider": "divisore", "ContentBlock.editCardCheckbox": "casella di controllo spuntata", "ContentBlock.editCardCheckboxText": "modifica il testo della scheda", "ContentBlock.editCardText": "modifica il testo della scheda", "ContentBlock.editText": "Modifica il testo...", "ContentBlock.image": "immagine", "ContentBlock.insertAbove": "Inserisci sopra", "ContentBlock.moveDown": "Sposta giù", "ContentBlock.moveUp": "Sposta su", "ContentBlock.text": "testo", "DateRange.clear": "Pulisci", "DateRange.empty": "Vuoto", "DateRange.endDate": "Data di scadenza", "DateRange.today": "Oggi", "DeleteBoardDialog.confirm-cancel": "Annulla", "DeleteBoardDialog.confirm-delete": "Elimina", "DeleteBoardDialog.confirm-info": "Sei sicuro di voler eliminare la bacheca \"{boardTitle}\"? Eliminandola, rimuoverai tutte le schede in bacheca.", "DeleteBoardDialog.confirm-tite": "Conferma la rimozione della bacheca", "DeleteBoardDialog.confirm-tite-template": "Confermi di eliminare il template della bacheca", "Dialog.closeDialog": "Chiudi finestra di dialogo", "EditableDayPicker.today": "Oggi", "Error.mobileweb": "Il supporto web mobile è attualmente in fase di beta iniziale. Non tutte le funzionalità potrebbero essere presenti.", "Error.websocket-closed": "Connessione interrotta col Websocket. Se il problema persiste, controlla la configurazione del tuo server o del proxy web.", "Filter.contains": "contiene", "Filter.ends-with": "termina con", "Filter.includes": "include", "Filter.is": "è", "Filter.is-empty": "è vuoto", "Filter.is-not-empty": "non è vuoto", "Filter.is-not-set": "non configurato", "Filter.is-set": "configurato", "Filter.not-contains": "non contiene", "Filter.not-ends-with": "non termina con", "Filter.not-includes": "non include", "Filter.not-starts-with": "non inizia con", "Filter.starts-with": "inizia con", "FilterComponent.add-filter": "+ Aggiungi un filtro", "FilterComponent.delete": "Elimina", "FindBoardsDialog.IntroText": "Cerca per bacheca", "FindBoardsDialog.NoResultsFor": "Nessun risultato per \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Controlla l'ortografia o prova con un'altra ricerca.", "FindBoardsDialog.SubTitle": "Scrivi per trovare una bacheca. Utilizza i tasti SU/GIÙ per navigare. INVIO per selezionare, ESC per annullare", "FindBoardsDialog.Title": "Trova bacheche", "GroupBy.hideEmptyGroups": "Nascondi {count} gruppi vuoti", "GroupBy.showHiddenGroups": "Mostra {count} gruppi nascosti", "GroupBy.ungroup": "Dividi", "HideBoard.MenuOption": "Nascondi bacheca", "KanbanCard.untitled": "Senza titolo", "Mutator.new-board-from-template": "nuova bacheca da template", "Mutator.new-card-from-template": "nuova scheda da modello", "Mutator.new-template-from-card": "nuovo modello da scheda", "OnboardingTour.AddComments.Body": "Puoi commentare sui issues e anche @menzionare il tuo compagno su Mattermost per attirare la loro attenzione.", "OnboardingTour.AddComments.Title": "Aggiungi commenti", "OnboardingTour.AddDescription.Body": "Aggiungi una descrizione alla tua scheda in modo da far sapere che cosa riguarda.", "OnboardingTour.AddDescription.Title": "Aggiungi una descrizione", "OnboardingTour.AddProperties.Body": "Aggiungi varie proprietà alle schede per renderle ancora più potenti!", "OnboardingTour.AddProperties.Title": "Aggiungi proprietà", "OnboardingTour.AddView.Body": "Vai qui in modo da creare una nuova vista per organizzare le tue bacheche utilizzando differenti layout.", "OnboardingTour.AddView.Title": "Aggiungi una nuova vista", "OnboardingTour.CopyLink.Body": "Puoi condividere le schede con i tuoi colleghi copiando il link e incollandolo in un canale, messaggio privato o messaggio di gruppo.", "OnboardingTour.CopyLink.Title": "Copia link", "OnboardingTour.OpenACard.Body": "Apri una scheda per esplorare i potenti modi in cui una Bacheca può aiutarti nell'organizzare il lavoro.", "OnboardingTour.OpenACard.Title": "Apri una scheda", "OnboardingTour.ShareBoard.Body": "Puoi condividere la bacheca internamente, solo con il tuo team, oppure pubblicarlo per tutti gli utenti fuori dalla tua organizzazione.", "OnboardingTour.ShareBoard.Title": "Condividi bacheca", "PersonProperty.board-members": "Membri bacheca", "PropertyMenu.Delete": "Elimina", "PropertyMenu.changeType": "Cambia il tipo di proprietà", "PropertyMenu.selectType": "Seleziona il tipo di proprietà", "PropertyMenu.typeTitle": "Tipo", "PropertyType.Checkbox": "Casella di controllo", "PropertyType.CreatedBy": "Creato da", "PropertyType.CreatedTime": "Orario di creazione", "PropertyType.Date": "Data", "PropertyType.Email": "Email", "PropertyType.MultiSelect": "Selezione Multipla", "PropertyType.Number": "Numero", "PropertyType.Person": "Persona", "PropertyType.Phone": "Telefono", "PropertyType.Select": "Seleziona", "PropertyType.Text": "Testo", "PropertyType.UpdatedBy": "Aggiornato da", "PropertyType.UpdatedTime": "Ora di aggiornamento", "PropertyValueElement.empty": "Vuoto", "RegistrationLink.confirmRegenerateToken": "Questo invaliderà i link condivisi in precedenza. Continuare?", "RegistrationLink.copiedLink": "Copiato!", "RegistrationLink.copyLink": "Copia link", "RegistrationLink.description": "Condividi questo link per creare nuovi account:", "RegistrationLink.regenerateToken": "Rigenera il token", "RegistrationLink.tokenRegenerated": "Link di registrazione ricreato", "ShareBoard.PublishDescription": "Pubblica e condividi un link di sola lettura con chiunque", "ShareBoard.PublishTitle": "Pubblica", "ShareBoard.Title": "Condividi bacheca", "ShareBoard.confirmRegenerateToken": "Questo invaliderà i link condivisi in precedenza. Continuare?", "ShareBoard.copiedLink": "Copiato!", "ShareBoard.copyLink": "Copia link", "ShareBoard.regenerate": "Rigenera token", "ShareBoard.teamPermissionsText": "Tutti nel team {teamName}", "ShareBoard.tokenRegenrated": "Token rigenerato", "ShareBoard.userPermissionsRemoveMemberText": "Rimuovi utente", "ShareBoard.userPermissionsYouText": "(Tu)", "ShareTemplate.Title": "Condividi template", "Sidebar.about": "Informazioni su Focalboard", "Sidebar.add-board": "+ Aggiungi Contenitore", "Sidebar.changePassword": "Cambia password", "Sidebar.delete-board": "Elimina contenitore", "Sidebar.duplicate-board": "Duplica bacheca", "Sidebar.export-archive": "Esporta archivio", "Sidebar.import": "Importa", "Sidebar.import-archive": "Importa archivio", "Sidebar.invite-users": "Invita utenti", "Sidebar.logout": "Logout", "Sidebar.no-boards-in-category": "Nessuna bacheca all'interno", "Sidebar.random-icons": "Icone casuali", "Sidebar.set-language": "Imposta la lingua", "Sidebar.set-theme": "Imposta il tema", "Sidebar.settings": "Impostazioni", "Sidebar.template-from-board": "Nuovo template dalla bacheca", "Sidebar.untitled-board": "(Contenitore senza titolo)", "SidebarCategories.BlocksMenu.Move": "Sposta a...", "SidebarCategories.CategoryMenu.CreateNew": "Crea nuova categoria", "SidebarCategories.CategoryMenu.Delete": "Elimina categoria", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Le bacheche in {categoryName} andranno nelle categorie precedenti. Non hai rimosso alcuna bacheca.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Eliminare questa categoria?", "SidebarCategories.CategoryMenu.Update": "Rinomina categoria", "TableComponent.add-icon": "Aggiungi icona", "TableComponent.name": "Nome", "TableComponent.plus-new": "+ Nuovo", "TableHeaderMenu.delete": "Elimina", "TableHeaderMenu.duplicate": "Duplica", "TableHeaderMenu.hide": "Nascondi", "TableHeaderMenu.insert-left": "Inserisci a sinistra", "TableHeaderMenu.insert-right": "Inserisci a destra", "TableHeaderMenu.sort-ascending": "Ordine crescente", "TableHeaderMenu.sort-descending": "Ordine decrescente", "TableRow.open": "Apri", "TopBar.give-feedback": "Dai un feedback", "ValueSelector.noOptions": "Nessuna opzione. Inizia a digitare per aggiungere la prima!", "ValueSelector.valueSelector": "Seleziona valore", "ValueSelectorLabel.openMenu": "Apri menu", "View.AddView": "Aggiungi Vista", "View.Board": "Contenitore", "View.DeleteView": "Elimina Vista", "View.DuplicateView": "Duplica Vista", "View.Gallery": "Galleria", "View.NewBoardTitle": "Vista Contenitore", "View.NewCalendarTitle": "Vista calendario", "View.NewGalleryTitle": "Vista gallery", "View.NewTableTitle": "Vista tabella", "View.Table": "Tabella", "ViewHeader.add-template": "+ Nuovo modello", "ViewHeader.delete-template": "Elimina", "ViewHeader.display-by": "Visualizzato da: {property}", "ViewHeader.edit-template": "Modifica", "ViewHeader.empty-card": "Scheda vuota", "ViewHeader.export-board-archive": "Esporta archivio board", "ViewHeader.export-complete": "Esportazione completata!", "ViewHeader.export-csv": "Esporta in formato CSV", "ViewHeader.export-failed": "Esportazione fallita!", "ViewHeader.filter": "Filtro", "ViewHeader.group-by": "Raggruppa per: {property}", "ViewHeader.new": "Nuovo", "ViewHeader.properties": "Proprietà", "ViewHeader.properties-menu": "Menù delle proprietà", "ViewHeader.search-text": "Cerca testo", "ViewHeader.select-a-template": "Seleziona un modello", "ViewHeader.set-default-template": "Imposta come predefinito", "ViewHeader.sort": "Ordina", "ViewHeader.untitled": "Senza titolo", "ViewHeader.view-header-menu": "Vedi menù header", "ViewHeader.view-menu": "Visualizza menù", "ViewTitle.hide-description": "nascondi descrizione", "ViewTitle.pick-icon": "Scegli un'icona", "ViewTitle.random-icon": "Casuale", "ViewTitle.remove-icon": "Rimuovi icona", "ViewTitle.show-description": "mostra descrizione", "ViewTitle.untitled-board": "Contenitore senza titolo", "WelcomePage.Description": "Boards è uno strumento organizzativo per progetti che aiuta a definire, organizzare, tenere traccia e controllo del lavoro tra gruppi, usando una vista familiare a scheda Kanban", "WelcomePage.Explore.Button": "Esplora", "WelcomePage.Heading": "Benvenuto in Boards", "WelcomePage.NoThanks.Text": "No grazie, farò da solo", "Workspace.editing-board-template": "Stai modificando un modello di una bacheca.", "calendar.month": "Mese", "calendar.today": "OGGI", "calendar.week": "Settimana", "default-properties.badges": "Commenti e Descrizione", "default-properties.title": "Titolo", "error.page.title": "Mi dispiace, qualcosa è andato storto", "generic.previous": "Precedente", "login.log-in-button": "Login", "login.log-in-title": "Login", "login.register-button": "oppure crea un account se non ne hai già uno", "register.login-button": "oppure fai il login se hai un account", "register.signup-title": "Registrati per un tuo account", "shareBoard.lastAdmin": "Le bacheche devono avere almeno un amministratore", "tutorial_tip.finish_tour": "Fatto", "tutorial_tip.ok": "Prossimo", "tutorial_tip.out": "Togli i suggerimenti", "tutorial_tip.seen": "Hai mai visto questo prima d'ora?" } ================================================ FILE: webapp/i18n/ja.json ================================================ { "AppBar.Tooltip": "リンク先Boardの切替え", "Attachment.Attachment-title": "添付する", "AttachmentBlock.DeleteAction": "削除", "AttachmentBlock.addElement": "{type} を追加", "AttachmentBlock.delete": "添付ファイルを削除しました。", "AttachmentBlock.failed": "ファイルサイズの制限に達したため、ファイルをアップロードできませんでした。", "AttachmentBlock.upload": "添付ファイルをアップロードしています。", "AttachmentBlock.uploadSuccess": "添付ファイルをアップロードしました。", "AttachmentElement.delete-confirmation-dialog-button-text": "削除", "AttachmentElement.download": "ダウンロード", "AttachmentElement.upload-percentage": "アップロード中...({uploadPercent}%)", "BoardComponent.add-a-group": "+ グループを追加する", "BoardComponent.delete": "削除", "BoardComponent.hidden-columns": "非表示", "BoardComponent.hide": "非表示", "BoardComponent.new": "+ 新規", "BoardComponent.no-property": "{property} 無し", "BoardComponent.no-property-title": "{property}が空のアイテムがここに表示されます。このカラムは削除できません。", "BoardComponent.show": "表示", "BoardMember.schemeAdmin": "管理者", "BoardMember.schemeCommenter": "コメンター", "BoardMember.schemeEditor": "編集者", "BoardMember.schemeNone": "なし", "BoardMember.schemeViewer": "閲覧者", "BoardMember.unlinkChannel": "リンク解除", "BoardPage.newVersion": "Boardsの新しいバージョンが利用可能です。ここをクリックして再読み込みしてください。", "BoardPage.syncFailed": "Boardが削除されたか、アクセスが取り消されました。", "BoardTemplateSelector.add-template": "テンプレート新規作成", "BoardTemplateSelector.create-empty-board": "空のBoardを作成", "BoardTemplateSelector.delete-template": "削除する", "BoardTemplateSelector.description": "以下のテンプレートを使用するか、空の状態から作成することで、サイドバーにBoardを追加できます。", "BoardTemplateSelector.edit-template": "編集", "BoardTemplateSelector.plugin.no-content-description": "以下のテンプレートを使用するか、空の状態から作成することで、サイドバーにBoardを追加できます。", "BoardTemplateSelector.plugin.no-content-title": "Boardを作成する", "BoardTemplateSelector.title": "Boardを作成する", "BoardTemplateSelector.use-this-template": "このテンプレートを使う", "BoardsSwitcher.Title": "Board検索", "BoardsUnfurl.Limited": "カードがアーカイブされているため詳細は表示されません", "BoardsUnfurl.Remainder": "残り +{remainder}", "BoardsUnfurl.Updated": "更新日時 {time}", "Calculations.Options.average.displayName": "平均", "Calculations.Options.average.label": "平均", "Calculations.Options.count.displayName": "カウント", "Calculations.Options.count.label": "カウント", "Calculations.Options.countChecked.displayName": "チェック済み", "Calculations.Options.countChecked.label": "チェック済みの数", "Calculations.Options.countUnchecked.displayName": "未チェック", "Calculations.Options.countUnchecked.label": "未チェックの数", "Calculations.Options.countUniqueValue.displayName": "ユニーク", "Calculations.Options.countUniqueValue.label": "ユニーク値の数", "Calculations.Options.countValue.displayName": "値", "Calculations.Options.countValue.label": "値の数", "Calculations.Options.dateRange.displayName": "範囲", "Calculations.Options.dateRange.label": "範囲", "Calculations.Options.earliest.displayName": "最初", "Calculations.Options.earliest.label": "最初", "Calculations.Options.latest.displayName": "最新", "Calculations.Options.latest.label": "最新", "Calculations.Options.max.displayName": "最大", "Calculations.Options.max.label": "最大", "Calculations.Options.median.displayName": "中央値", "Calculations.Options.median.label": "中央値", "Calculations.Options.min.displayName": "最小", "Calculations.Options.min.label": "最小", "Calculations.Options.none.displayName": "計算", "Calculations.Options.none.label": "なし", "Calculations.Options.percentChecked.displayName": "チェック済み", "Calculations.Options.percentChecked.label": "チェック済みの割合", "Calculations.Options.percentUnchecked.displayName": "未チェック", "Calculations.Options.percentUnchecked.label": "未チェックの割合", "Calculations.Options.range.displayName": "範囲", "Calculations.Options.range.label": "範囲", "Calculations.Options.sum.displayName": "合計", "Calculations.Options.sum.label": "合計", "CalendarCard.untitled": "無題", "CardActionsMenu.copiedLink": "コピーしました!", "CardActionsMenu.copyLink": "リンクをコピー", "CardActionsMenu.delete": "削除", "CardActionsMenu.duplicate": "複製", "CardBadges.title-checkboxes": "チェックボックス", "CardBadges.title-comments": "コメント", "CardBadges.title-description": "このカードには説明があります", "CardDetail.Attach": "添付", "CardDetail.Follow": "フォローする", "CardDetail.Following": "フォロー中", "CardDetail.add-content": "内容を追加する", "CardDetail.add-icon": "アイコンを追加する", "CardDetail.add-property": "+ プロパティを追加", "CardDetail.addCardText": "カードテキストを追加する", "CardDetail.limited-body": "ProfessionalプランまたはEnterpriseプランにアップグレードしてください。", "CardDetail.limited-button": "アップグレード", "CardDetail.limited-title": "このカードは表示できません", "CardDetail.moveContent": "カード内容の移動", "CardDetail.new-comment-placeholder": "コメントを追加する...", "CardDetailProperty.confirm-delete-heading": "プロパティの削除を確定する", "CardDetailProperty.confirm-delete-subtext": "本当にプロパティ \"{propertyName}\" を削除しますか? 削除すると、このBoardのすべてのカードからそのプロパティが削除されます。", "CardDetailProperty.confirm-property-name-change-subtext": "本当にプロパティ \"{propertyName}\" の \"{customText}\" に変更しますか? これは、このBoardの{numOfCards}カード全体の値に影響し、データの損失につながる恐れがあります。", "CardDetailProperty.confirm-property-type-change": "プロパティ種別の変更を確定する", "CardDetailProperty.delete-action-button": "削除", "CardDetailProperty.property-change-action-button": "プロパティの変更", "CardDetailProperty.property-changed": "プロパティが変更されました!", "CardDetailProperty.property-deleted": "{propertyName} が正常に削除されました!", "CardDetailProperty.property-name-change-subtext": "種別を \"{oldPropType}\" から\"{newPropType}\" に", "CardDetial.limited-link": "各プランの詳細についてはこちらをご覧ください。", "CardDialog.delete-confirmation-dialog-attachment": "添付ファイルを削除する", "CardDialog.delete-confirmation-dialog-button-text": "削除", "CardDialog.delete-confirmation-dialog-heading": "カード削除の確認", "CardDialog.editing-template": "テンプレートを編集しています。", "CardDialog.nocard": "このカードは存在しないか、アクセスできません。", "Categories.CreateCategoryDialog.CancelText": "キャンセル", "Categories.CreateCategoryDialog.CreateText": "作成", "Categories.CreateCategoryDialog.Placeholder": "カテゴリ名を入力してください", "Categories.CreateCategoryDialog.UpdateText": "更新", "CenterPanel.Login": "ログイン", "CenterPanel.Share": "共有", "ChannelIntro.CreateBoard": "Boardを作成する", "ColorOption.selectColor": "{color} 色を選択", "Comment.delete": "削除", "CommentsList.send": "送信", "ConfirmPerson.empty": "空", "ConfirmPerson.search": "検索中...", "ConfirmationDialog.cancel-action": "キャンセル", "ConfirmationDialog.confirm-action": "確認", "ContentBlock.Delete": "削除", "ContentBlock.DeleteAction": "削除する", "ContentBlock.addElement": "{type} を追加する", "ContentBlock.checkbox": "チェックボックス", "ContentBlock.divider": "仕切り", "ContentBlock.editCardCheckbox": "切替えられたチェックボックス", "ContentBlock.editCardCheckboxText": "カードテキストの編集", "ContentBlock.editCardText": "カードテキストの編集", "ContentBlock.editText": "テキストを編集する...", "ContentBlock.image": "画像", "ContentBlock.insertAbove": "上に挿入する", "ContentBlock.moveBlock": "カード内容の移動", "ContentBlock.moveDown": "下へ移動する", "ContentBlock.moveUp": "上へ移動する", "ContentBlock.text": "テキスト", "DateRange.clear": "クリア", "DateRange.empty": "空", "DateRange.endDate": "終了日", "DateRange.today": "今日", "DeleteBoardDialog.confirm-cancel": "キャンセル", "DeleteBoardDialog.confirm-delete": "削除", "DeleteBoardDialog.confirm-info": "本当にBoard \"{boardTitle}\" を削除しますか? 削除すると、このBoardのすべてのカードが削除されます。", "DeleteBoardDialog.confirm-info-template": "Boardテンプレート \"{boardTitle}\" を本当に削除しますか?", "DeleteBoardDialog.confirm-tite": "Boardの削除を確定する", "DeleteBoardDialog.confirm-tite-template": "Boardテンプレートの削除を確定する", "Dialog.closeDialog": "ダイアログを閉じる", "EditableDayPicker.today": "今日", "Error.mobileweb": "モバイルウェブのサポートは現在、初期ベータ版です。一部の機能が利用できない場合があります。", "Error.websocket-closed": "ウェブソケット接続が閉じられ、接続が中断されました。この問題が解決しない場合は、サーバーまたはウェブプロキシの設定を確認してください。", "Filter.contains": "を含む", "Filter.ends-with": "で終わる", "Filter.includes": "を含む", "Filter.is": "と一致する", "Filter.is-empty": "が空である", "Filter.is-not-empty": "が空でない", "Filter.is-not-set": "が未設定", "Filter.is-set": "が設定済み", "Filter.not-contains": "を含まない", "Filter.not-ends-with": "で終わらない", "Filter.not-includes": "を含まない", "Filter.not-starts-with": "で始まらない", "Filter.starts-with": "で始まる", "FilterByText.placeholder": "フィルター文字列", "FilterComponent.add-filter": "+ フィルターを追加する", "FilterComponent.delete": "削除", "FilterValue.empty": "(空)", "FindBoardsDialog.IntroText": "Boardを検索", "FindBoardsDialog.NoResultsFor": "\"{searchQuery}\"に対する結果はありません", "FindBoardsDialog.NoResultsSubtext": "スペルを確認し、再度検索してください。", "FindBoardsDialog.SubTitle": "Boardを検索するために文字を入力してください。UP/DOWNで閲覧、ENTERで選択、ESCでキャンセル", "FindBoardsDialog.Title": "Boardを探す", "GroupBy.hideEmptyGroups": "{count} 個の空のグループを隠す", "GroupBy.showHiddenGroups": "{count} 個の非表示グループを表示する", "GroupBy.ungroup": "グループ解除", "HideBoard.MenuOption": "Boardを隠す", "KanbanCard.untitled": "無題", "MentionSuggestion.is-not-board-member": "(not board member)", "Mutator.new-board-from-template": "テンプレートからの新しいBoard", "Mutator.new-card-from-template": "テンプレートから新しいカードを作成", "Mutator.new-template-from-card": "カードから新しいテンプレートを作成", "OnboardingTour.AddComments.Body": "問題にコメントしたり、仲間のMattermostユーザーの注意を引くために@メンションすることもできます。", "OnboardingTour.AddComments.Title": "コメントを追加する", "OnboardingTour.AddDescription.Body": "カードに説明を追加し、チームメイトに何のカードかわかるようにしましょう。", "OnboardingTour.AddDescription.Title": "説明を追加する", "OnboardingTour.AddProperties.Body": "カードに様々なプロパティを追加することで、より便利になります。", "OnboardingTour.AddProperties.Title": "プロパティを追加する", "OnboardingTour.AddView.Body": "異なるレイアウトでBoardを整理するための新しいビューを作成するには、ここに移動します。", "OnboardingTour.AddView.Title": "新しいビューを追加する", "OnboardingTour.CopyLink.Body": "リンクをコピーしてチャンネル、ダイレクトメッセージ、グループメッセージに貼り付けることで、カードをチームメイトと共有することができます。", "OnboardingTour.CopyLink.Title": "リンクをコピー", "OnboardingTour.OpenACard.Body": "カードを開き、あなたの仕事を整理するのに役立つBoardの便利な使い方を探ってみてください。", "OnboardingTour.OpenACard.Title": "カードを開く", "OnboardingTour.ShareBoard.Body": "作成したBoardは、社内やチーム内で共有することも、組織外から見えるように公開することも可能です。", "OnboardingTour.ShareBoard.Title": "Boardを共有", "PersonProperty.board-members": "Board members", "PersonProperty.me": "私", "PersonProperty.non-board-members": "Not board members", "PropertyMenu.Delete": "削除", "PropertyMenu.changeType": "プロパティのタイプを変更する", "PropertyMenu.selectType": "プロパティタイプの選択", "PropertyMenu.typeTitle": "タイプ", "PropertyType.Checkbox": "チェックボックス", "PropertyType.CreatedBy": "作成者", "PropertyType.CreatedTime": "作成日時", "PropertyType.Date": "日付", "PropertyType.Email": "メールアドレス", "PropertyType.MultiPerson": "複数人", "PropertyType.MultiSelect": "マルチセレクト", "PropertyType.Number": "数字", "PropertyType.Person": "人物", "PropertyType.Phone": "電話番号", "PropertyType.Select": "セレクト", "PropertyType.Text": "テキスト", "PropertyType.Unknown": "不明", "PropertyType.UpdatedBy": "更新者", "PropertyType.UpdatedTime": "更新日時", "PropertyType.Url": "URL", "PropertyValueElement.empty": "空", "RegistrationLink.confirmRegenerateToken": "実行すると以前に共有されたリンクは無効になります。続行しますか?", "RegistrationLink.copiedLink": "コピーしました!", "RegistrationLink.copyLink": "リンクをコピー", "RegistrationLink.description": "アカウントを作成には、このリンクを共有してください:", "RegistrationLink.regenerateToken": "トークンを再生成する", "RegistrationLink.tokenRegenerated": "登録リンクが再生成されました", "ShareBoard.PublishDescription": "Web上の全員へ \"読み取り専用\" のリンクを公開および共有する。", "ShareBoard.PublishTitle": "Web上へ公開する", "ShareBoard.ShareInternal": "内部で共有する", "ShareBoard.ShareInternalDescription": "権限のあるユーザーは、このリンクを使用することができます。", "ShareBoard.Title": "Boardを共有", "ShareBoard.confirmRegenerateToken": "実行すると以前に共有されたリンクは無効になります。続行しますか?", "ShareBoard.copiedLink": "コピーしました!", "ShareBoard.copyLink": "リンクをコピー", "ShareBoard.regenerate": "トークンを再生成する", "ShareBoard.searchPlaceholder": "人とチャンネルを検索", "ShareBoard.teamPermissionsText": "{teamName}チームの全員", "ShareBoard.tokenRegenrated": "トークンが再生成されました", "ShareBoard.userPermissionsRemoveMemberText": "メンバーを削除する", "ShareBoard.userPermissionsYouText": "(あなた)", "ShareTemplate.Title": "テンプレートを共有する", "ShareTemplate.searchPlaceholder": "人を検索", "Sidebar.about": "Focalboardについて", "Sidebar.add-board": "+ Boardを追加", "Sidebar.changePassword": "パスワードを変更する", "Sidebar.delete-board": "Boardを削除", "Sidebar.duplicate-board": "Boardを複製する", "Sidebar.export-archive": "エクスポート", "Sidebar.import": "インポート", "Sidebar.import-archive": "インポート", "Sidebar.invite-users": "ユーザーを招待する", "Sidebar.logout": "ログアウト", "Sidebar.new-category.badge": "新規", "Sidebar.new-category.drag-boards-cta": "ここにBoardをドラッグ...", "Sidebar.no-boards-in-category": "カテゴリ内にBoardがありません", "Sidebar.product-tour": "プロダクトツアー", "Sidebar.random-icons": "ランダムアイコン", "Sidebar.set-language": "言語設定", "Sidebar.set-theme": "テーマ設定", "Sidebar.settings": "設定", "Sidebar.template-from-board": "Boardからの新しいテンプレート", "Sidebar.untitled-board": "(無題のBoard)", "Sidebar.untitled-view": "(無題のビュー)", "SidebarCategories.BlocksMenu.Move": "移動...", "SidebarCategories.CategoryMenu.CreateNew": "新しいカテゴリを作成する", "SidebarCategories.CategoryMenu.Delete": "カテゴリを削除する", "SidebarCategories.CategoryMenu.DeleteModal.Body": "{categoryName} にあるBoardは、Boards カテゴリに戻されます。どのBoardからも削除されることはありません。", "SidebarCategories.CategoryMenu.DeleteModal.Title": "このカテゴリを削除しますか?", "SidebarCategories.CategoryMenu.Update": "カテゴリ名を変更する", "SidebarTour.ManageCategories.Body": "カスタムカテゴリーを作成し、管理することができます。カテゴリはユーザーごとに設定されるため、Boardを自分のカテゴリに移動しても、同じBoardを使用している他のメンバーには影響がありません。", "SidebarTour.ManageCategories.Title": "カテゴリー管理", "SidebarTour.SearchForBoards.Body": "Board切替(Cmd/Ctrl + K)により、素早くBoardを検索し、サイドバーに追加することができます。", "SidebarTour.SearchForBoards.Title": "Boardを検索", "SidebarTour.SidebarCategories.Body": "すべてのBoardが新しいサイドバーの下に整理されました。もう、ワークスペースを切り替える必要はありません。v7.2へのアップグレードに伴い、以前のワークスペースに基づいたカスタムカテゴリーが自動的に作成されている場合があります。これらは、お好みで削除したり編集することができます。", "SidebarTour.SidebarCategories.Link": "詳細", "SidebarTour.SidebarCategories.Title": "サイドバーカテゴリー", "SiteStats.total_boards": "Board総数", "SiteStats.total_cards": "カード数", "TableComponent.add-icon": "アイコンを追加する", "TableComponent.name": "名前", "TableComponent.plus-new": "+ 新規", "TableHeaderMenu.delete": "削除", "TableHeaderMenu.duplicate": "複製", "TableHeaderMenu.hide": "非表示", "TableHeaderMenu.insert-left": "左に挿入", "TableHeaderMenu.insert-right": "右に挿入", "TableHeaderMenu.sort-ascending": "昇順でソート", "TableHeaderMenu.sort-descending": "降順でソート", "TableRow.DuplicateCard": "カードを複製する", "TableRow.MoreOption": "その他のアクション", "TableRow.open": "開く", "TopBar.give-feedback": "フィードバックを送る", "URLProperty.copiedLink": "コピーしました!", "URLProperty.copy": "コピー", "URLProperty.edit": "編集", "UndoRedoHotKeys.canRedo": "やり直す", "UndoRedoHotKeys.canRedo-with-description": "{description} をやり直す", "UndoRedoHotKeys.canUndo": "元に戻す", "UndoRedoHotKeys.canUndo-with-description": "{description} を元に戻す", "UndoRedoHotKeys.cannotRedo": "やり直しする操作がありません", "UndoRedoHotKeys.cannotUndo": "元に戻す操作がありません", "ValueSelector.noOptions": "オプションがありません。最初の一つを追加するために入力を開始してください!", "ValueSelector.valueSelector": "値選択", "ValueSelectorLabel.openMenu": "メニューを開く", "VersionMessage.help": "このバージョンの新機能を確認する。", "View.AddView": "ビューを追加", "View.Board": "Board", "View.DeleteView": "ビューを削除", "View.DuplicateView": "ビューを複製", "View.Gallery": "ギャラリー", "View.NewBoardTitle": "Board表示", "View.NewCalendarTitle": "カレンダー表示", "View.NewGalleryTitle": "ギャラリービュー", "View.NewTableTitle": "テーブル表示", "View.NewTemplateDefaultTitle": "無題のテンプレート", "View.NewTemplateTitle": "無題", "View.Table": "テーブル", "ViewHeader.add-template": "新しいテンプレート", "ViewHeader.delete-template": "削除", "ViewHeader.display-by": "表示対象: {property}", "ViewHeader.edit-template": "編集", "ViewHeader.empty-card": "空のカード", "ViewHeader.export-board-archive": "Boardアーカイブのエクスポート", "ViewHeader.export-complete": "エクスポートが完了しました!", "ViewHeader.export-csv": "CSVエクスポート", "ViewHeader.export-failed": "エクスポートが失敗しました!", "ViewHeader.filter": "フィルター", "ViewHeader.group-by": "{property} でグループ化", "ViewHeader.new": "新規", "ViewHeader.properties": "プロパティ", "ViewHeader.properties-menu": "プロパティメニュー", "ViewHeader.search-text": "カード検索", "ViewHeader.select-a-template": "テンプレート選択", "ViewHeader.set-default-template": "デフォルトとして設定", "ViewHeader.sort": "ソート", "ViewHeader.untitled": "無題", "ViewHeader.view-header-menu": "ヘッダーメニューを見る", "ViewHeader.view-menu": "メニューを見る", "ViewLimitDialog.Heading": "Boardごとのビュー数制限に達しました", "ViewLimitDialog.PrimaryButton.Title.Admin": "アップグレード", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "管理者に通知する", "ViewLimitDialog.Subtext.Admin": "ProfessionalプランまたはEnterpriseプランにアップグレードしてください。", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "各プランの詳細についてはこちらをご覧ください。", "ViewLimitDialog.Subtext.RegularUser": "ProfessionalプランまたはEnterpriseプランへアップグレードするよう管理者に連絡してください。", "ViewLimitDialog.UpgradeImg.AltText": "アップグレードイメージ", "ViewLimitDialog.notifyAdmin.Success": "管理者に通知されました", "ViewTitle.hide-description": "説明を非表示", "ViewTitle.pick-icon": "アイコンを選ぶ", "ViewTitle.random-icon": "ランダム", "ViewTitle.remove-icon": "アイコンを削除する", "ViewTitle.show-description": "説明を表示", "ViewTitle.untitled-board": "無題のBoard", "WelcomePage.Description": "Boardsは、よく知られたKanban形式のビューを使用して、チーム全体の作業を定義、整理、追跡、管理するためのプロジェクト管理ツールです。", "WelcomePage.Explore.Button": "ツアーに参加する", "WelcomePage.Heading": "Boardへようこそ", "WelcomePage.NoThanks.Text": "いいえ、自分で調べます", "WelcomePage.StartUsingIt.Text": "利用を開始する", "Workspace.editing-board-template": "Boardのテンプレートを編集しています。", "badge.guest": "ゲスト", "boardSelector.confirm-link-board": "Boardをチャンネルへリンク", "boardSelector.confirm-link-board-button": "はい、Boardをリンクします", "boardSelector.confirm-link-board-subtext": "\"{boardName}\" をチャンネルにリンクすると、チャンネルの(既存/新規)メンバー全員がBoardを編集できるようになります。ただし、ゲストユーザーは除外されます。Boardとチャンネルのリンク解除はいつでも可能です。", "boardSelector.confirm-link-board-subtext-with-other-channel": "\"{boardName}\" をチャンネルにリンクすると、チャンネルの(既存/新規)メンバー全員がBoardを編集できるようになります。ただし、ゲストユーザーは除外されます。{lineBreak} このBoardは現在他のチャンネルにリンクされています。ここにリンクさせると、他のチャンネルとのリンクは解除されます。", "boardSelector.create-a-board": "Boardを作成", "boardSelector.link": "リンク", "boardSelector.search-for-boards": "Boardを検索", "boardSelector.title": "Boardをリンク", "boardSelector.unlink": "リンク解除", "calendar.month": "月", "calendar.today": "今日", "calendar.week": "週", "centerPanel.undefined": "{propertyName} 無し", "centerPanel.unknown-user": "不明なユーザー", "cloudMessage.learn-more": "さらに詳しく", "createImageBlock.failed": "ファイルサイズの上限に達しているため、ファイルをアップロードできませんでした。", "default-properties.badges": "コメントと説明", "default-properties.title": "タイトル", "error.back-to-home": "ホームへ戻る", "error.back-to-team": "チームに戻る", "error.board-not-found": "Boardが見つかりませんでした。", "error.go-login": "ログイン", "error.invalid-read-only-board": "このBoardにアクセスできません。アクセスするにはBoardsにログインしてください。", "error.not-logged-in": "セッションの有効期限が切れているか、ログインしていない可能性があります。Boardsにアクセスするには再度ログインしてください。", "error.page.title": "申し訳ありませんが、何か問題が発生しました", "error.team-undefined": "有効なチームではありません。", "error.unknown": "エラーが発生しました。", "generic.previous": "前へ", "guest-no-board.subtitle": "あなたはまだこのチームのどのBoardにもアクセスできません。誰かがあなたをBoardに追加するまでお待ちください。", "guest-no-board.title": "まだBoardsはありません", "imagePaste.upload-failed": "ファイルサイズの制限に達しているため、一部のファイルをアップロードできませんでした。", "limitedCard.title": "非表示カード", "login.log-in-button": "ログイン", "login.log-in-title": "ログイン", "login.register-button": "アカウントをお持ちでない方はアカウントを作成してください", "new_channel_modal.create_board.empty_board_description": "空のBoardを新規作成する", "new_channel_modal.create_board.empty_board_title": "空のBoard", "new_channel_modal.create_board.select_template_placeholder": "テンプレートを選択", "new_channel_modal.create_board.title": "このチャンネル用のBoardを作成する", "notification-box-card-limit-reached.close-tooltip": "10日間のスヌーズ", "notification-box-card-limit-reached.contact-link": "管理者に通知する", "notification-box-card-limit-reached.link": "有料プランへのアップグレード", "notification-box-card-limit-reached.title": "Boardから {cards} カードが非表示になっています", "notification-box-cards-hidden.title": "このアクションにより他のカードが非表示になります", "notification-box.card-limit-reached.not-admin.text": "アーカイブされたカードにアクセスするには、{contactLink}から有料プランにアップグレードしてください。", "notification-box.card-limit-reached.text": "カード数の制限に達しました。古いカードを閲覧するには、{link}", "person.add-user-to-board": "{username} をBoardに追加", "person.add-user-to-board-confirm-button": "Boardに追加", "person.add-user-to-board-permissions": "権限", "person.add-user-to-board-question": "{username} をBoardに追加しますか?", "person.add-user-to-board-warning": "{username} はBoardのメンバーではないので、それに関する通知を受け取ることはありません。", "register.login-button": "または、すでにアカウントをお持ちの方はログインしてください", "register.signup-title": "アカウント登録", "rhs-board-non-admin-msg": "あなたはBoardの管理者ではありません", "rhs-boards.add": "追加", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "このダイレクトメッセージ", "rhs-boards.header.gm": "このグループメッセージ", "rhs-boards.last-update-at": "最終更新: {datetime}", "rhs-boards.link-boards-to-channel": "Boardsを{channelName}へリンクする", "rhs-boards.linked-boards": "リンク済みBoards", "rhs-boards.no-boards-linked-to-channel": "{channelName}にリンクされたBoardsはまだありません", "rhs-boards.no-boards-linked-to-channel-description": "Boardsは、よく知られたKanban形式のビューを使用して、チーム全体の作業を定義、生理、追跡、管理するためのプロジェクト管理ツールです。", "rhs-boards.unlink-board": "Boardのリンクを解除", "rhs-boards.unlink-board1": "Boardのリンクを解除", "rhs-channel-boards-header.title": "Boards", "share-board.publish": "公開", "share-board.share": "共有", "shareBoard.channels-select-group": "Channels", "shareBoard.confirm-change-team-role.body": "このBoardで \"{role}\" より弱い権限のユーザー全員が {role} に昇格します。本当にBoardの最低限のロールを変更しますか?", "shareBoard.confirm-change-team-role.confirmBtnText": "最低限のロールを変更", "shareBoard.confirm-change-team-role.title": "最低限のロールを変更", "shareBoard.confirm-link-channel": "Boardをチャンネルへリンク", "shareBoard.confirm-link-channel-button": "チャンネルにリンク", "shareBoard.confirm-link-channel-button-with-other-channel": "リンク解除とリンクはこちら", "shareBoard.confirm-link-channel-subtext": "チャンネルをBoardにリンクすると、チャンネルの(既存/新規)メンバー全員がBoardを編集できるようになります。ただし、ゲストユーザーは除外されます。", "shareBoard.confirm-link-channel-subtext-with-other-channel": "チャンネルをBoardにリンクすると、チャンネルの(既存/新規)メンバー全員がBoardを編集できるようになります。ただし、ゲストユーザーは除外されます。{lineBreak} このBoardは現在他のチャンネルにリンクされています。ここにリンクさせると、他のチャンネルとのリンクは解除されます。", "shareBoard.confirm-unlink.body": "Boardからチャンネルへのリンクを解除すると、別途権限を付与されない限り、チャンネルの(既存/新規)メンバー全員がBoardへアクセスできなくなります。", "shareBoard.confirm-unlink.confirmBtnText": "チャンネルとのリンクを解除", "shareBoard.confirm-unlink.title": "Boardからチャンネルへのリンクを解除する", "shareBoard.lastAdmin": "Boardsには少なくとも1名の管理者が必要です", "shareBoard.members-select-group": "メンバー", "shareBoard.unknown-channel-display-name": "不明なチャンネル", "tutorial_tip.finish_tour": "完了", "tutorial_tip.got_it": "了解", "tutorial_tip.ok": "次へ", "tutorial_tip.out": "これらのコツを表示しません。", "tutorial_tip.seen": "以前に見たことがありますか?" } ================================================ FILE: webapp/i18n/ka.json ================================================ { "AppBar.Tooltip": "დაკავშირებული დაფების გადართვა", "BoardComponent.add-a-group": "+ ჯგუფის დამატება", "BoardComponent.delete": "წაშლა", "BoardComponent.hidden-columns": "დამალული სვეტები", "BoardComponent.hide": "დამალვა", "BoardComponent.new": "+ ახალი", "BoardComponent.no-property": "არ არის {Property}", "BoardComponent.no-property-title": "ცარიელი {property} საკუთრების მქონე ელემენტები აქ წავა. ამ სვეტის წაშლა შეუძლებელია.", "BoardComponent.show": "ჩვენება", "BoardMember.schemeAdmin": "ადმინისტრატორი", "BoardMember.schemeCommenter": "კომენტატორი", "BoardMember.schemeEditor": "რედაქტორი", "BoardMember.schemeNone": "არცერთი", "BoardMember.schemeViewer": "მაყურებელი", "BoardMember.unlinkChannel": "კავშირის გაუქმება", "BoardPage.newVersion": "დაფების ახალი ვერსია ხელმისაწვდომია, დააწკაპუნეთ აქ გადასატვირთად.", "BoardPage.syncFailed": "დაფა შეიძლება წაშლილია ან გაუქმებულია წვდომა.", "BoardTemplateSelector.add-template": "ახალი შაბლონი", "BoardTemplateSelector.create-empty-board": "შექმენით ცარიელი დაფა", "BoardTemplateSelector.delete-template": "წაშლა", "BoardTemplateSelector.description": "დაამატეთ დაფა გვერდითა ზოლში ქვემოთ განსაზღვრული რომელიმე შაბლონის გამოყენებით ან დაიწყეთ ნულიდან.", "BoardTemplateSelector.edit-template": "რედაქტირება", "BoardTemplateSelector.plugin.no-content-description": "დაამატეთ დაფა გვერდითა ზოლში ქვემოთ განსაზღვრული რომელიმე შაბლონის გამოყენებით ან დაიწყეთ ნულიდან.", "BoardTemplateSelector.plugin.no-content-title": "შექმენით დაფა", "BoardTemplateSelector.title": "შექმენით დაფა", "BoardTemplateSelector.use-this-template": "გამოიყენეთ ეს შაბლონი", "BoardsSwitcher.Title": "დაფების ძებნა", "BoardsUnfurl.Limited": "დამატებითი დეტალები დამალულია ბარათის დაარქივების გამო", "BoardsUnfurl.Remainder": "+{remainder} მეტი", "BoardsUnfurl.Updated": "განახლებული {time}", "Calculations.Options.average.displayName": "საშუალო", "Calculations.Options.average.label": "საშუალო", "Calculations.Options.count.displayName": "დათვლა", "Calculations.Options.count.label": "დათვლა", "Calculations.Options.countChecked.displayName": "შემოწმებული", "Calculations.Options.countChecked.label": "რაოდენობა შემოწმებულია", "Calculations.Options.countUnchecked.displayName": "შეუმოწმებელი", "Calculations.Options.countUnchecked.label": "რაოდენობა შეუმოწმებელია", "Calculations.Options.countUniqueValue.displayName": "უნიკალური", "Calculations.Options.countUniqueValue.label": "უნიკალური ღირებულების დათვლა", "Calculations.Options.countValue.displayName": "ღირებულებები", "Calculations.Options.countValue.label": "დათვალეთ მნიშვნელობა", "Calculations.Options.dateRange.displayName": "დიაპაზონი", "Calculations.Options.dateRange.label": "დიაპაზონი", "Calculations.Options.earliest.displayName": "ყველაზე ადრეული", "Calculations.Options.earliest.label": "ყველაზე ადრეული", "Calculations.Options.latest.displayName": "უახლესი", "Calculations.Options.latest.label": "უახლესი", "Calculations.Options.max.displayName": "მაქსიმალური", "Calculations.Options.max.label": "მაქსიმალური", "Calculations.Options.median.displayName": "საშუალო", "Calculations.Options.median.label": "საშუალო", "Calculations.Options.min.displayName": "მინიმალური", "Calculations.Options.min.label": "მინიმალური", "Calculations.Options.none.displayName": "გამოთვლა", "Calculations.Options.none.label": "გამოთვლა", "Calculations.Options.percentChecked.displayName": "შემოწმებული", "Calculations.Options.percentChecked.label": "პროცენტი შემოწმებულია", "Calculations.Options.percentUnchecked.displayName": "შეუმოწმებელი", "Calculations.Options.percentUnchecked.label": "პროცენტი შეუმოწმებელია", "Calculations.Options.range.displayName": "დიაპაზონი", "Calculations.Options.range.label": "დიაპაზონი", "Calculations.Options.sum.displayName": "ჯამი", "Calculations.Options.sum.label": "ჯამი", "CalendarCard.untitled": "უსათაურო", "CardActionsMenu.copiedLink": "დაკოპირებულია!", "CardActionsMenu.copyLink": "Ბმულის კოპირება", "CardActionsMenu.delete": "წაშლა", "CardActionsMenu.duplicate": "დუბლიკატი", "CardBadges.title-checkboxes": "მოსანიშნი ველები", "CardBadges.title-comments": "კომენტარები", "CardBadges.title-description": "ამ ბარათს აქვს აღწერა", "CardDetail.Follow": "გაყოლა", "CardDetail.Following": "მომდევნო", "CardDetail.add-content": "დაამატეთ შინაარსი", "CardDetail.add-icon": "ხატულის დამატება", "CardDetail.add-property": "+ დაამატეთ Property", "CardDetail.addCardText": "ბარათის ტექსტის დამატება", "CardDetail.limited-body": "ბარათის ტექსტის დამატება განაახლეთ ჩვენს პროფესიონალურ ან საწარმოს გეგმაში დაარქივებული ბარათების სანახავად, თითო დაფაზე ულიმიტო ნახვები, ულიმიტო ბარათები და სხვა.", "CardDetail.limited-button": "განახლება", "CardDetail.limited-title": "ეს ბარათი დამალულია", "CardDetail.moveContent": "ბარათის შინაარსის გადატანა", "CardDetail.new-comment-placeholder": "კომენტარის დამატება...", "CardDetailProperty.confirm-delete-heading": "დაადასტურეთ Property-ის წაშლა", "CardDetailProperty.confirm-delete-subtext": "დარწმუნებული ხართ, რომ გსურთ წაშალოთ Property „{propertyName}“? მისი წაშლა წაშლის Property-ის ამ დაფის ყველა ბარათიდან.", "CardDetailProperty.confirm-property-name-change-subtext": "დარწმუნებული ხართ, რომ გსურთ შეცვალოთ თვისება „{propertyName}“ {customText}? ეს გავლენას მოახდენს მნიშვნელობა(ებ)ზე {numOfCards} ბარათ(ებ)ში ამ დაფაზე და შეიძლება გამოიწვიოს მონაცემთა დაკარგვა.", "CardDetailProperty.confirm-property-type-change": "დაადასტურეთ Property-ის ტიპის ცვლილება", "CardDetailProperty.delete-action-button": "წაშლა", "CardDetailProperty.property-change-action-button": "Property-ის შეცვლა", "CardDetailProperty.property-changed": "Property წარმატებით შეიცვალა!", "CardDetailProperty.property-deleted": "{propertyName} წარმატებით წაიშალა!", "CardDetailProperty.property-name-change-subtext": "აკრიფეთ „{oldPropType}“-დან „{newPropType}“-მდე", "CardDetial.limited-link": "შეიტყვეთ მეტი ჩვენი გეგმების შესახებ.", "CardDialog.delete-confirmation-dialog-button-text": "წაშლა", "CardDialog.delete-confirmation-dialog-heading": "დაადასტურეთ ბარათის წაშლა!", "CardDialog.editing-template": "თქვენ არედაქტირებთ შაბლონს.", "CardDialog.nocard": "ეს ბარათი არ არსებობს ან მიუწვდომელია.", "Categories.CreateCategoryDialog.CancelText": "გაუქმება", "Categories.CreateCategoryDialog.CreateText": "Შექმნა", "Categories.CreateCategoryDialog.Placeholder": "მიეცით სახელი თქვენს კატეგორიას", "Categories.CreateCategoryDialog.UpdateText": "განახლება", "CenterPanel.Login": "შესვლა", "CenterPanel.Share": "გაზიარება", "ColorOption.selectColor": "აირჩიეთ {color} ფერი", "Comment.delete": "წაშლა", "CommentsList.send": "გაგზავნა", "ConfirmationDialog.cancel-action": "გაუქმება", "ConfirmationDialog.confirm-action": "დადასტურება", "ContentBlock.Delete": "წაშლა", "ContentBlock.DeleteAction": "წაშლა", "ContentBlock.addElement": "დაამატეთ {type}", "ContentBlock.checkbox": "მონიშვნის ველი", "ContentBlock.divider": "გამყოფი", "ContentBlock.editCardCheckbox": "მონიშნული-მონიშვნის ველი", "ContentBlock.editCardCheckboxText": "ბარათის ტექსტის რედაქტირება", "ContentBlock.editCardText": "ბარათის ტექსტის რედაქტირება", "ContentBlock.editText": "ტექსტის რედაქტირება...", "ContentBlock.image": "გამოსახულება" } ================================================ FILE: webapp/i18n/kab.json ================================================ {} ================================================ FILE: webapp/i18n/kk.json ================================================ { "BoardComponent.add-a-group": "+ Гіруп қосу", "BoardComponent.delete": "Жою", "BoardComponent.hidden-columns": "Жасырын бағандар", "BoardComponent.hide": "Жасыру", "BoardComponent.new": "+ Жаңа", "BoardComponent.no-property": "{property} жоқ", "BoardComponent.no-property-title": "Бос {property} сипаты бар Item'дер осында болады. Бұл бағанды жою мүмкін емес.", "BoardComponent.show": "Көрсету", "BoardPage.newVersion": "Boards'тың жаңа нұсқасы қолжетімді, қайта жүктеу үшін осы жерді басыңыз.", "BoardPage.syncFailed": "Тақта жойылуы немесе кіруге тыйым салынуы мүмкін.", "BoardsUnfurl.Remainder": "+{remainder} көбірек", "BoardsUnfurl.Updated": "Жүктелді {time}", "Calculations.Options.average.displayName": "Орташа", "Calculations.Options.average.label": "Орташа", "Calculations.Options.count.displayName": "Санау", "Calculations.Options.count.label": "Санау", "Calculations.Options.countChecked.displayName": "Тексерілді", "Calculations.Options.countChecked.label": "Санақ Тексерілді", "Calculations.Options.countUnchecked.displayName": "Тексерілмеген", "Calculations.Options.countUnchecked.label": "Санақ Тексерілмеген", "Calculations.Options.countUniqueValue.displayName": "Бірегей", "Calculations.Options.countUniqueValue.label": "Бірегей Мәндерді Санау", "Calculations.Options.countValue.displayName": "Мәндер", "Calculations.Options.countValue.label": "Мәнін Есептеу", "Calculations.Options.dateRange.displayName": "Ранжы", "Calculations.Options.dateRange.label": "Ранжы", "Calculations.Options.earliest.displayName": "Бұрынғысы", "Calculations.Options.earliest.label": "Бұрынғысы", "Calculations.Options.latest.displayName": "Соңғы", "Calculations.Options.latest.label": "Соңғы", "Calculations.Options.max.displayName": "Max", "Calculations.Options.max.label": "Max", "Calculations.Options.median.displayName": "Median", "Calculations.Options.median.label": "Median", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Есептеу", "Calculations.Options.none.label": "Жоқ", "Calculations.Options.percentChecked.displayName": "Тексерілді", "Calculations.Options.percentChecked.label": "Пайыз Тексерілді", "Calculations.Options.percentUnchecked.displayName": "Тексерілмеген", "Calculations.Options.percentUnchecked.label": "Пайыз Тексерілмеген", "Calculations.Options.range.displayName": "Ранжы", "Calculations.Options.range.label": "Ранжы", "Calculations.Options.sum.displayName": "Сома", "Calculations.Options.sum.label": "Сома", "CardDetail.Follow": "Follow", "CardDetail.Following": "Following", "CardDetail.add-content": "Кәнтен қосу", "CardDetail.add-icon": "Икон қосу", "CardDetail.add-property": "+ Қасиет қосу", "CardDetail.addCardText": "Кәрте мәтінін қосу", "CardDetail.moveContent": "Кәрте кәнтенін жылжыту", "CardDetail.new-comment-placeholder": "Пікір қосу...", "CardDetailProperty.confirm-delete-heading": "Сипатты Жоюды Растаңыз", "CardDetailProperty.confirm-delete-subtext": "\"{propertyName}\" сипатын шынымен жойғыныз келе ме? Оны жойсаныз бұл сипат осы тақтадағы барлық кәртелерден жойылады.", "CardDetailProperty.confirm-property-name-change-subtext": "\"{propertyName}\" {customText} сипатты шынымен өзгерткініз келе ме? Бұл осы тақтадағы {numOfCards} кәрте(лердің) мән(дер)іне әсер етеді және деректердің жағалуына әкелуі мүмкін.", "CardDetailProperty.confirm-property-type-change": "Сипат түрін өзгертуді растаңыз!", "CardDetailProperty.delete-action-button": "Жою", "CardDetailProperty.property-change-action-button": "Property'ді Өзгерту", "CardDetailProperty.property-changed": "Property сәтті өзгертілді!", "CardDetailProperty.property-deleted": "{propertyName} Сәтті Жойылды!", "CardDetailProperty.property-name-change-subtext": "\"{oldPropType}\" тен \"{newPropType}\"'қа дейін теріңіз", "CardDialog.editing-template": "Сіз үлгіні өзгертудесіз.", "CardDialog.nocard": "Бұл кәрте жоқ немесе қолжетімсіз.", "ColorOption.selectColor": "{color} Түсті Танданыз", "Comment.delete": "Жою", "CommentsList.send": "Жіберу", "ConfirmationDialog.cancel-action": "Болдырмау", "ConfirmationDialog.confirm-action": "Растау", "ContentBlock.Delete": "Жою", "ContentBlock.DeleteAction": "жою", "ContentBlock.addElement": "{type} қосу", "ContentBlock.checkbox": "құсбелгі", "ContentBlock.divider": "бөлгіш", "ContentBlock.editCardCheckbox": "тандалған құсбелгі", "ContentBlock.editCardCheckboxText": "кәрте мәтінін өзгерту", "ContentBlock.editCardText": "кәрте мәтінін өзгерту", "ContentBlock.editText": "Мәтінді өзгерту...", "ContentBlock.image": "сурет", "ContentBlock.insertAbove": "Жоғарға еңгізу", "ContentBlock.moveDown": "Түсіру", "ContentBlock.moveUp": "Көтеру", "ContentBlock.text": "мәтін", "DeleteBoardDialog.confirm-cancel": "Болдырмау", "DeleteBoardDialog.confirm-delete": "Жою", "DeleteBoardDialog.confirm-info": "\"{boardTitle}\" тақтасын шынымен жойғыңыз келе ме? Оны жою тақтадағы барлық кәртелерді жояды.", "DeleteBoardDialog.confirm-tite": "Тақтаны Жоюды Растаңыз", "Dialog.closeDialog": "Диалогты жабу", "EditableDayPicker.today": "Бүгін", "Error.mobileweb": "Mobile web қолдау қазір бастапқы бетада. Барлық мүмкіншілктер болмауы мүмкін.", "Error.websocket-closed": "Websocket қосылымы жабылды, байланыс үзілді. Бұл әлі сақталса, серверді немесе web proxy кәнфиғуратенін тексеріңіз.", "Filter.includes": "кірістіреді", "Filter.is-empty": "бос", "Filter.is-not-empty": "іші бос емес", "Filter.not-includes": "кірістірмейді", "FilterComponent.add-filter": "+ Филтір қосу", "FilterComponent.delete": "Жою", "GroupBy.ungroup": "Гірупсіздендіру", "KanbanCard.untitled": "Атаусыз", "Mutator.new-card-from-template": "үлгіден жаңа кәрте жасау", "Mutator.new-template-from-card": "кәртеден жаңа үлгі", "PropertyMenu.Delete": "Жою", "PropertyMenu.changeType": "Property түрін өзгерту", "PropertyMenu.selectType": "Property түрін тандау", "PropertyMenu.typeTitle": "Түрі", "PropertyType.Checkbox": "Құсбелгі", "PropertyType.CreatedBy": "Жасаған", "PropertyType.CreatedTime": "Жасалған уақыты", "PropertyType.Date": "Даты", "PropertyType.Email": "Email", "PropertyType.MultiSelect": "Multi таңдау", "PropertyType.Number": "Нөмір", "PropertyType.Person": "Тұлға", "PropertyType.Phone": "Телефон", "PropertyType.Select": "Таңдау", "PropertyType.Text": "Мәтін", "PropertyType.UpdatedBy": "Соңғы өзгерткен", "PropertyType.UpdatedTime": "Соңғы өзгертілген уақыты", "PropertyValueElement.empty": "Бос", "RegistrationLink.confirmRegenerateToken": "Бұл бұрын таратылған сілтемелерді жарамсыз етеді. Жалғастырасыз ба?", "RegistrationLink.copiedLink": "Көшірілді!", "RegistrationLink.copyLink": "Сілтемені көшіру", "RegistrationLink.description": "Басқалар аққаунт жасау үшін осы сілтемені тарату:", "RegistrationLink.regenerateToken": "Токенді регенераттау", "RegistrationLink.tokenRegenerated": "Тіркелу сілтемесі регенератталды", "ShareBoard.confirmRegenerateToken": "Бұл бұрын таратылған сілтемелерді жарамсыз етеді. Жалғастырасыз ба?", "ShareBoard.copiedLink": "Көшірілді!", "ShareBoard.copyLink": "Сілтемені көшіру", "ShareBoard.tokenRegenrated": "Токен регенератталды", "Sidebar.about": "Focalboard туралы", "Sidebar.add-board": "+ Тақта қосу", "Sidebar.changePassword": "Кілтсөзді өзгерту", "Sidebar.delete-board": "Тақтаны жою", "Sidebar.export-archive": "Мұрағатты экспорттау", "Sidebar.import-archive": "Мұрағатты импорттау", "Sidebar.invite-users": "Қолданушыларды шақыру", "Sidebar.logout": "Шығу", "Sidebar.random-icons": "Рандом икондар", "Sidebar.set-language": "Тілді таңдау", "Sidebar.set-theme": "Теміні орнату", "Sidebar.settings": "Баптаулар", "Sidebar.untitled-board": "(Атаусыз Тақта)", "TableComponent.add-icon": "Иконды қосу", "TableComponent.name": "Атауы", "TableComponent.plus-new": "+ Қосу", "TableHeaderMenu.delete": "Жою", "TableHeaderMenu.duplicate": "Көшірмесін жасау", "TableHeaderMenu.hide": "Жасыру", "TableHeaderMenu.insert-left": "Солға еңгізу", "TableHeaderMenu.insert-right": "Оңға еңгізу", "TableHeaderMenu.sort-ascending": "Өсуі бойынша сұрыптау", "TableHeaderMenu.sort-descending": "Кему бойынша сұрыптау", "TableRow.open": "Ашу", "TopBar.give-feedback": "Feedback беру", "ValueSelector.noOptions": "Оптендер жоқ. Біріншісін қосу үшін теруді бастаныз!", "ValueSelector.valueSelector": "Мән селектірі", "ValueSelectorLabel.openMenu": "Мәзірді ашу", "View.AddView": "Көріністі қосу", "View.Board": "Тақта", "View.DeleteView": "Көріністі жою", "View.DuplicateView": "Көріністің көшірмесін жасау", "View.Gallery": "Гәлері", "View.NewBoardTitle": "Тақта көрінісі", "View.NewCalendarTitle": "Күнтізбе Көрінісі", "View.NewGalleryTitle": "Гәлері көрінісі", "View.NewTableTitle": "Тақта көрінісі", "View.Table": "Кесте", "ViewHeader.add-template": "Жаңа үлгі", "ViewHeader.delete-template": "Жою", "ViewHeader.display-by": "{property} бойынша көрсету", "ViewHeader.edit-template": "Өзгерту", "ViewHeader.empty-card": "Кәртені тазарту", "ViewHeader.export-board-archive": "Тақта мұрағатын экспорттау", "ViewHeader.export-complete": "Экспорт аяқталды!", "ViewHeader.export-csv": "CSV'ге экспорттау", "ViewHeader.export-failed": "Экспорт сәтсіз аяқталды!", "ViewHeader.filter": "Филтір", "ViewHeader.group-by": "{property} бойынша гіруптеу", "ViewHeader.new": "Жаңа", "ViewHeader.properties": "Property'лер", "ViewHeader.search-text": "Мәтінді іздеу", "ViewHeader.select-a-template": "Үлгіні таңдау", "ViewHeader.set-default-template": "Әдепкі ретінде орнату", "ViewHeader.sort": "Сұрыптау", "ViewHeader.untitled": "Атаусыз", "ViewTitle.hide-description": "сипаттаманы жасыру", "ViewTitle.pick-icon": "Иконды таңдау", "ViewTitle.random-icon": "Рандом", "ViewTitle.remove-icon": "Иконды кетіру", "ViewTitle.show-description": "Сипаттаманы көрсету", "ViewTitle.untitled-board": "Атаусыз тақта", "WelcomePage.Description": "Тақталар дегеніміз танымал қанбан (kanban) тақта көрінісін қолданып, тимдер арасындағы жұмысты анықтауға, ұйымдастыруға, қадағалауға және басқаруға көмектесетін жобаны басқару құралы", "WelcomePage.Explore.Button": "Зерттеу", "WelcomePage.Heading": "Тақталарға Қош Келдініз", "Workspace.editing-board-template": "Сіз тақта үлгісін өзгертудесіз.", "calendar.month": "Ай", "calendar.today": "БҮГІН", "calendar.week": "Апта", "default-properties.title": "Атау", "login.log-in-button": "Кіру", "login.log-in-title": "Кіру", "login.register-button": "немесе сізде жоқ болса аққаунт жасаныз", "register.login-button": "немесе аққаунтыныз болса кірініз", "register.signup-title": "Аққаунт жасау үшін тіркелініз" } ================================================ FILE: webapp/i18n/ko.json ================================================ { "AppBar.Tooltip": "링크된 보드로 이동", "Attachment.Attachment-title": "첨부", "AttachmentBlock.DeleteAction": "삭제", "AttachmentBlock.addElement": "{type} 추가", "AttachmentBlock.delete": "첨부 파일이 삭제되었습니다.", "AttachmentBlock.failed": "첨부 파일 크기 제한을 초과하기 때문에 업로드할 수 없습니다.", "AttachmentBlock.upload": "첨부 파일을 업로드 중입니다.", "AttachmentBlock.uploadSuccess": "첨부 파일이 성공적으로 업로드 되었습니다.", "AttachmentElement.delete-confirmation-dialog-button-text": "삭제", "AttachmentElement.download": "다운로드", "AttachmentElement.upload-percentage": "업로드 중...({uploadPercent}%)", "BoardComponent.add-a-group": "+ 그룹 추가하기", "BoardComponent.delete": "삭제하기", "BoardComponent.hidden-columns": "숨겨진 열", "BoardComponent.hide": "숨기기", "BoardComponent.new": "+ 추가하기", "BoardComponent.no-property": "{property} 속성 없음", "BoardComponent.no-property-title": "{property} 속성이 빈 항목은 여기로 이동됩니다. 이 열은 제거할 수 없습니다.", "BoardComponent.show": "보이기", "BoardMember.schemeAdmin": "관리자", "BoardMember.schemeCommenter": "댓글 작성자", "BoardMember.schemeEditor": "편집자", "BoardMember.schemeNone": "없음", "BoardMember.schemeViewer": "열람자", "BoardMember.unlinkChannel": "링크 해제", "BoardPage.newVersion": "새 버전의 보드가 존재합니다, 여기를 눌러 다시 불러오세요.", "BoardPage.syncFailed": "보드가 삭제되었거나 권한이 거부되었습니다.", "BoardTemplateSelector.add-template": "새 템플릿 만들기", "BoardTemplateSelector.create-empty-board": "빈 보드 만들기", "BoardTemplateSelector.delete-template": "삭제", "BoardTemplateSelector.description": "아래에 정의된 템플릿을 사용하여 사이드바에 보드를 추가하거나 처음부터 시작하십시오.", "BoardTemplateSelector.edit-template": "편집", "BoardTemplateSelector.plugin.no-content-description": "아래에 정의된 템플릿을 사용하여 사이드바에 보드를 추가하거나 처음부터 시작하십시오.", "BoardTemplateSelector.plugin.no-content-title": "보드 만들기", "BoardTemplateSelector.title": "보드 만들기", "BoardTemplateSelector.use-this-template": "이 템플릿 사용하기", "BoardsSwitcher.Title": "보드 찾기", "BoardsUnfurl.Limited": "보관 중인 카드로 인해 추가 세부정보가 숨겨져 있습니다", "BoardsUnfurl.Remainder": "+{remainder} 추가", "BoardsUnfurl.Updated": "{time}에 수정됨", "Calculations.Options.average.displayName": "평균", "Calculations.Options.average.label": "평균", "Calculations.Options.count.displayName": "개수", "Calculations.Options.count.label": "개수", "Calculations.Options.countChecked.displayName": "확인됨", "Calculations.Options.countChecked.label": "확인된 수", "Calculations.Options.countUnchecked.displayName": "확인되지 않음", "Calculations.Options.countUnchecked.label": "확인되지 않은 개수", "Calculations.Options.countUniqueValue.displayName": "고윳값", "Calculations.Options.countUniqueValue.label": "고유 값 계산", "Calculations.Options.countValue.displayName": "값", "Calculations.Options.countValue.label": "계산 값", "Calculations.Options.dateRange.displayName": "범위", "Calculations.Options.dateRange.label": "범위", "Calculations.Options.earliest.displayName": "이른 순으로", "Calculations.Options.earliest.label": "이른 순으로", "Calculations.Options.latest.displayName": "늦은 순으로", "Calculations.Options.latest.label": "늦은 순으로", "Calculations.Options.max.displayName": "최대", "Calculations.Options.max.label": "최대", "Calculations.Options.median.displayName": "중앙값", "Calculations.Options.median.label": "중앙값", "Calculations.Options.min.displayName": "최소", "Calculations.Options.min.label": "최소", "Calculations.Options.none.displayName": "계산하기", "Calculations.Options.none.label": "없음", "Calculations.Options.percentChecked.displayName": "확인됨", "Calculations.Options.percentChecked.label": "선택된 비율", "Calculations.Options.percentUnchecked.displayName": "확인되지 않음", "Calculations.Options.percentUnchecked.label": "선택되지 않은 비율", "Calculations.Options.range.displayName": "범위", "Calculations.Options.range.label": "범위", "Calculations.Options.sum.displayName": "더하기", "Calculations.Options.sum.label": "더하기", "CalendarCard.untitled": "제목 없음", "CardActionsMenu.copiedLink": "복사!", "CardActionsMenu.copyLink": "링크 복사하기", "CardActionsMenu.delete": "삭제", "CardActionsMenu.duplicate": "복제하기", "CardBadges.title-checkboxes": "체크박스", "CardBadges.title-comments": "댓글", "CardBadges.title-description": "이 카드에는 설명이 있습니다", "CardDetail.Attach": "첨부", "CardDetail.Follow": "팔로우하기", "CardDetail.Following": "팔로우 중", "CardDetail.add-content": "콘텐츠 추가하기", "CardDetail.add-icon": "아이콘 추가하기", "CardDetail.add-property": "+ 속성 추가하기", "CardDetail.addCardText": "카드 텍스트 추가하기", "CardDetail.limited-body": "Professional 또는 Enterprise 플랜으로 업그레이드하여 보관된 카드를 보거나 보드당 무제한 보기, 카드 무제한 보기 등을 할 수 있습니다.", "CardDetail.limited-button": "업그레이드", "CardDetail.limited-title": "숨겨진 카드가 있습니다", "CardDetail.moveContent": "카드 내용 이동하기", "CardDetail.new-comment-placeholder": "댓글 추가하기...", "CardDetailProperty.confirm-delete-heading": "속성 삭제 확인", "CardDetailProperty.confirm-delete-subtext": "정말로 \"{propertyName}\"속성을 삭제할까요? 보드의 모든 카드에서 이 속성이 삭제됩니다.", "CardDetailProperty.confirm-property-name-change-subtext": "정말로 \"{propertyName}\"속성을 {customText}로 바꾸시겠습니까? 이 보드에 있는 {numOfCards}개의 카드가 수정되며, 데이터가 손실될 수 있습니다.", "CardDetailProperty.confirm-property-type-change": "속성 유형 변경 확인하기", "CardDetailProperty.delete-action-button": "삭제하기", "CardDetailProperty.property-change-action-button": "속성 변경하기", "CardDetailProperty.property-changed": "성공적으로 속성이 변경되었습니다!", "CardDetailProperty.property-deleted": "{propertyName}을(를) 성공적으로 삭제했습니다!", "CardDetailProperty.property-name-change-subtext": "유형을 \"{oldPropType}\"에서 \"{newPropType}\"로", "CardDetial.limited-link": "우리 계획에 대해 더 알아보기.", "CardDialog.delete-confirmation-dialog-attachment": "첨부 파일 삭제 확인!", "CardDialog.delete-confirmation-dialog-button-text": "삭제", "CardDialog.delete-confirmation-dialog-heading": "카드 삭제 확인!", "CardDialog.editing-template": "템플릿을 수정하는 중입니다.", "CardDialog.nocard": "이 카드는 존재하지 않거나 사용할 수 없습니다.", "Categories.CreateCategoryDialog.CancelText": "취소", "Categories.CreateCategoryDialog.CreateText": "생성", "Categories.CreateCategoryDialog.Placeholder": "카테고리 이름 지정", "Categories.CreateCategoryDialog.UpdateText": "업데이트", "CenterPanel.Login": "로그인", "CenterPanel.Share": "공유", "ColorOption.selectColor": "{color} 색 선택하기", "Comment.delete": "삭제하기", "CommentsList.send": "보내기", "ConfirmationDialog.cancel-action": "취소하기", "ConfirmationDialog.confirm-action": "결정하기", "ContentBlock.Delete": "삭제하기", "ContentBlock.DeleteAction": "삭제하기", "ContentBlock.addElement": "{type} 추가하기", "ContentBlock.checkbox": "체크박스", "ContentBlock.divider": "구분선", "ContentBlock.editCardCheckbox": "토글 체크박스", "ContentBlock.editCardCheckboxText": "카드 텍스트 수정하기", "ContentBlock.editCardText": "카드 텍스트 수정하기", "ContentBlock.editText": "텍스트 수정하기...", "ContentBlock.image": "이미지", "ContentBlock.insertAbove": "위에 삽입하기", "ContentBlock.moveBlock": "카드 내용 이동", "ContentBlock.moveDown": "아래로 이동하기", "ContentBlock.moveUp": "위로 이동하기", "ContentBlock.text": "텍스트", "DateRange.clear": "지우기", "DateRange.empty": "비어 있음", "DateRange.endDate": "종료일자", "DateRange.today": "오늘", "DeleteBoardDialog.confirm-cancel": "취소", "DeleteBoardDialog.confirm-delete": "삭제", "DeleteBoardDialog.confirm-info": "“{boardTitle}” 보드를 삭제하시겠습니까? 이 보드에 있는 모든 카드들이 삭제됩니다.", "DeleteBoardDialog.confirm-info-template": "{boardTitle} 보드 템플릿을 삭제 하시겠습니까?", "DeleteBoardDialog.confirm-tite": "보드 삭제 확인하기", "DeleteBoardDialog.confirm-tite-template": "보드 템플릿 삭제 확인하기", "Dialog.closeDialog": "대화창 닫기", "EditableDayPicker.today": "오늘", "Error.mobileweb": "모바일 웹 지원은 현재 초기 베타 버전입니다. 모든 기능이 있는 것은 아닙니다.", "Error.websocket-closed": "웹소켓 연결이 닫혀서 연결이 중단되었습니다. 이 문제가 지속되면, 서버 또는 웹 프록시 구성을 확인하세요.", "Filter.contains": "필터를 포함하다", "Filter.ends-with": "~로 끝나다", "Filter.includes": "~를 포함한다", "Filter.is": "~이다", "Filter.is-empty": "비어있음", "Filter.is-not-empty": "비어 있지 않음", "Filter.is-not-set": "미설정", "Filter.is-set": "설정", "Filter.not-contains": "~를 포함하지 않음", "Filter.not-ends-with": "~로 끝나지 않음", "Filter.not-includes": "~를 포함하지 않음", "Filter.not-starts-with": "~로 시작하지 않음", "Filter.starts-with": "~로 시작함", "FilterByText.placeholder": "필터값", "FilterComponent.add-filter": "+ 필터 추가", "FilterComponent.delete": "삭제", "FindBoardsDialog.IntroText": "보드에서 검색", "FindBoardsDialog.NoResultsFor": "\"{searchQuery}\" 에 대한 검색 결과가 없습니다", "FindBoardsDialog.NoResultsSubtext": "글자를 확인 하시거나 다른 단어로 검색해 주세요.", "FindBoardsDialog.SubTitle": "보드를 찾으려면 입력하십시오. UP/DOWN 버튼을 이용해서 보드를 찾아주세요. 선택은 ENTER , 해제는 ESC", "FindBoardsDialog.Title": "보드 찾기", "GroupBy.hideEmptyGroups": "{count} 그룹 숨기기", "GroupBy.showHiddenGroups": "{count} 숨김 그룹 보기", "GroupBy.ungroup": "그룹 해제", "HideBoard.MenuOption": "보드 숨기기", "KanbanCard.untitled": "제목 없음", "MentionSuggestion.is-not-board-member": "(보드 멤버가 아님)", "Mutator.new-board-from-template": "템플릿에서 신규 보드 만들기", "Mutator.new-card-from-template": "템플릿에서 신규 카드 만들기", "Mutator.new-template-from-card": "카드에서 신규 템플릿 만들기", "OnboardingTour.AddComments.Body": "@mention 을 이용하여 이슈에 대해 메터머스트 사용자에게 알릴 수 있습니다.", "OnboardingTour.AddComments.Title": "댓글 작성", "OnboardingTour.AddDescription.Body": "팀원들이 카드의 내용을 알 수 있도록 카드에 설명을 추가합니다.", "OnboardingTour.AddDescription.Title": "설명 추가", "OnboardingTour.AddProperties.Body": "카드에 다양한 속성을 추가하여 더욱 강력해 질 수 있습니다!", "OnboardingTour.AddProperties.Title": "속성 추가", "OnboardingTour.AddView.Body": "다른 레이아웃을 사용하여 보드를 구성할 새 보기를 작성하려면 여기로 이동하십시오.", "OnboardingTour.AddView.Title": "뷰 추가", "OnboardingTour.CopyLink.Body": "링크를 복사하여 채널, 다이렉트 메시지 또는 그룹 메시지에 붙여넣어 팀원들과 카드를 공유할 수 있습니다.", "OnboardingTour.CopyLink.Title": "링크 복사", "OnboardingTour.OpenACard.Body": "카드를 열어 보드가 작업을 구성하는 데 도움이 될 수 있는 강력한 방법을 알아보십시오.", "OnboardingTour.OpenACard.Title": "카드 열기", "OnboardingTour.ShareBoard.Body": "보드를 팀 내에서 내부적으로 공유하거나 조직 외부에서 볼 수 있도록 공개적으로 게시할 수 있습니다.", "OnboardingTour.ShareBoard.Title": "보드 공유", "PersonProperty.board-members": "보드 멤버", "PersonProperty.non-board-members": "보드 멤버가 아님", "PropertyMenu.Delete": "삭제", "PropertyMenu.changeType": "속성 유형 변경", "PropertyMenu.selectType": "속성 유형 선택", "PropertyMenu.typeTitle": "유형", "PropertyType.Checkbox": "체크박스", "PropertyType.CreatedBy": "만든 사람", "PropertyType.CreatedTime": "생성 시간", "PropertyType.Date": "날짜", "PropertyType.Email": "전자우편", "PropertyType.MultiPerson": "다중 사용자", "PropertyType.MultiSelect": "다중 선택하기", "PropertyType.Number": "숫자", "PropertyType.Person": "사람", "PropertyType.Phone": "전화번호", "PropertyType.Select": "선택", "PropertyType.Text": "텍스트", "PropertyType.Unknown": "알 수 없는 유형", "PropertyType.UpdatedBy": "최근 수정한 사람", "PropertyType.UpdatedTime": "최근 수정 시간", "PropertyType.Url": "URL 주소", "PropertyValueElement.empty": "비어있음", "RegistrationLink.confirmRegenerateToken": "이전에 공유된 링크가 무효화됩니다. 계속하시겠습니까?", "RegistrationLink.copiedLink": "복사되었습니다!", "RegistrationLink.copyLink": "링크 복사", "RegistrationLink.description": "다른 구성원이 계정을 만들 수 있도록 이 링크를 공유하세요:", "RegistrationLink.regenerateToken": "토큰 재성성", "RegistrationLink.tokenRegenerated": "등록 링크가 재생성되었음", "ShareBoard.PublishDescription": "웹 상의 모든 사용자와 읽기 전용 링크를 게시하고 공유합니다.", "ShareBoard.PublishTitle": "웹에 게시하다", "ShareBoard.ShareInternal": "내부공유하기", "ShareBoard.ShareInternalDescription": "권한이 있는 사용자는 이 링크를 사용할 수 있습니다.", "ShareBoard.Title": "보드 공유", "ShareBoard.confirmRegenerateToken": "이전에 공유된 링크가 무효화됩니다. 계속하시겠습니까?", "ShareBoard.copiedLink": "복사되었습니다!", "ShareBoard.copyLink": "링크 복사", "ShareBoard.regenerate": "토큰 재생성하기", "ShareBoard.searchPlaceholder": "사용자 및 채널 검색", "ShareBoard.teamPermissionsText": "{teamName}팀의 모든 사용자", "ShareBoard.tokenRegenrated": "토큰이 재성생되었음", "ShareBoard.userPermissionsRemoveMemberText": "멤버 제외하기", "ShareBoard.userPermissionsYouText": "당신", "ShareTemplate.Title": "템플릿 공유", "ShareTemplate.searchPlaceholder": "사용자 검색", "Sidebar.about": "Focalboard에 대하여", "Sidebar.add-board": "+ 보드 추가", "Sidebar.changePassword": "패스워드 변경", "Sidebar.delete-board": "보드 삭제", "Sidebar.duplicate-board": "보드 복제", "Sidebar.export-archive": "아카이브 내보내기", "Sidebar.import": "사이드바 가져요기", "Sidebar.import-archive": "아카이브 들여오기", "Sidebar.invite-users": "사용자 초대", "Sidebar.logout": "로그아웃", "Sidebar.new-category.badge": "신규", "Sidebar.new-category.drag-boards-cta": "보드를 여기에 드래그하세요...", "Sidebar.no-boards-in-category": "해당 카테고리에 보드가 존재하지 않음", "Sidebar.product-tour": "상품 둘러보기", "Sidebar.random-icons": "임의 아이콘", "Sidebar.set-language": "언어 설정", "Sidebar.set-theme": "테마 설정", "Sidebar.settings": "설정", "Sidebar.template-from-board": "보드의 새 템플릿 추가", "Sidebar.untitled-board": "(제목 없는 보드)", "Sidebar.untitled-view": "(제목 없는 뷰)", "SidebarCategories.BlocksMenu.Move": "이동 ...", "SidebarCategories.CategoryMenu.CreateNew": "새 카테고리 만들기", "SidebarCategories.CategoryMenu.Delete": "카테고리 삭제하기", "SidebarCategories.CategoryMenu.DeleteModal.Body": "{categoryName}의 다시 보드 카테고리로 이동합니다. 당신은 어떤 보드에서도 제거되지 않았습니다.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "해당 카테고리를 삭제하시겠습니까?", "SidebarCategories.CategoryMenu.Update": "카테고리 이름 수정하기", "SidebarTour.ManageCategories.Body": "사용자 정의 카테고리를 만들고 관리합니다. 카테고리는 사용자별로 다르므로 보드를 사용자의 카레고리로 이동해도 동일한 보드를 사용하는 다른 구성원에게는 영향을 주지 않습니다.", "SidebarTour.ManageCategories.Title": "카테고리 관리하기", "SidebarTour.SearchForBoards.Body": "(Cmd/Ctrl + K)를 열어 보드를 빠르게 검색하고 사이드바에 추가합니다.", "SidebarTour.SearchForBoards.Title": "보드 검색하기", "SidebarTour.SidebarCategories.Body": "이제 모든 보드가 새 사이드바 아래에 정리됩니다. 워크스페이스 간 전환은 더 이상 필요 없습니다. v7.2 업그레이드의 일부로 이전 작업 공간을 기반으로 한 일회성 사용자 지정 카테고리가 자동으로 생성될 수 있습니다. 해당 기능을 통해 원하는 대로 카테고리를 제거하거나 편집할 수 있습니다.", "SidebarTour.SidebarCategories.Link": "더 배우기", "SidebarTour.SidebarCategories.Title": "사이드바 카테고리", "SiteStats.total_boards": "모든 보드", "SiteStats.total_cards": "모든 카드", "TableComponent.add-icon": "아이콘 추가", "TableComponent.name": "이름", "TableComponent.plus-new": "+ 생성", "TableHeaderMenu.delete": "삭제", "TableHeaderMenu.duplicate": "복제", "TableHeaderMenu.hide": "숨김", "TableHeaderMenu.insert-left": "왼쪽에 삽입", "TableHeaderMenu.insert-right": "오른쪽에 삽입", "TableHeaderMenu.sort-ascending": "오름차순 정렬", "TableHeaderMenu.sort-descending": "내림차순 정렬", "TableRow.MoreOption": "더 많은 행동", "TableRow.open": "열기", "TopBar.give-feedback": "피드백 하기", "URLProperty.copiedLink": "복사되었습니다!", "URLProperty.copy": "복사", "URLProperty.edit": "수정", "UndoRedoHotKeys.canRedo": "다시 실행하기", "UndoRedoHotKeys.canRedo-with-description": "{description} 다시 실행하기", "UndoRedoHotKeys.canUndo": "실행 취소하기", "UndoRedoHotKeys.canUndo-with-description": "{description} 실행 취소하기", "UndoRedoHotKeys.cannotRedo": "다시 실행하지 않기", "UndoRedoHotKeys.cannotUndo": "실행취소 하지 않기", "ValueSelector.noOptions": "옵션이 없습니다. 새로 추가하려면 입력을 시작하세요!", "ValueSelector.valueSelector": "값 선택", "ValueSelectorLabel.openMenu": "메뉴 열기", "VersionMessage.help": "이 버전의 새로운 기능을 확인하십시오.", "View.AddView": "뷰 추가", "View.Board": "보드", "View.DeleteView": "뷰 삭제", "View.DuplicateView": "뷰 복제", "View.Gallery": "갤러리", "View.NewBoardTitle": "보드 형태로 보기", "View.NewCalendarTitle": "달력 형태로 보기", "View.NewGalleryTitle": "갤리리 형태로 보기", "View.NewTableTitle": "표 형태로 보기", "View.NewTemplateDefaultTitle": "무제 템플릿", "View.NewTemplateTitle": "제목 없음", "View.Table": "표", "ViewHeader.add-template": "새 템플릿", "ViewHeader.delete-template": "삭제", "ViewHeader.display-by": "표시 대상: {property}", "ViewHeader.edit-template": "수정", "ViewHeader.empty-card": "빈 카드", "ViewHeader.export-board-archive": "보드 아카이브 내보내기", "ViewHeader.export-complete": "내보내기가 완료되었습니다!", "ViewHeader.export-csv": "CSV로 내보내기", "ViewHeader.export-failed": "내보내기가 실패했습니다!", "ViewHeader.filter": "필터", "ViewHeader.group-by": "{property}로 그룹화", "ViewHeader.new": "생성", "ViewHeader.properties": "속성", "ViewHeader.properties-menu": "속성 메뉴", "ViewHeader.search-text": "카드 검색하기", "ViewHeader.select-a-template": "템플릿 선택", "ViewHeader.set-default-template": "기본으로 설정", "ViewHeader.sort": "정렬", "ViewHeader.untitled": "제목 없음", "ViewHeader.view-header-menu": "머리글 메뉴 보기", "ViewHeader.view-menu": "뷰 메뉴", "ViewLimitDialog.Heading": "보드 당 조회 수 제한에 도달했습니다", "ViewLimitDialog.PrimaryButton.Title.Admin": "업그레이드", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "관리자에게 알리기", "ViewLimitDialog.Subtext.Admin": "Professional 또는 Enterprise 플랜으로 업그레이드하여 보드당 무제한 보기, 카드 무제한 등을 이용할 수 있습니다.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "우리의 계획에 대해 더 알아보기.", "ViewLimitDialog.Subtext.RegularUser": "관리자에게 통지하여 프로페셔널 또는 엔터프라이즈 플랜으로 업그레이드하여 보드당 무제한 보기, 카드 무제한 등을 사용할 수 있습니다.", "ViewLimitDialog.UpgradeImg.AltText": "이미지 업그레이드", "ViewLimitDialog.notifyAdmin.Success": "관리자에게 알림이 왔습니다", "ViewTitle.hide-description": "설명 숨기기", "ViewTitle.pick-icon": "아이콘 선택", "ViewTitle.random-icon": "임의", "ViewTitle.remove-icon": "아이콘 제거", "ViewTitle.show-description": "설명 보기", "ViewTitle.untitled-board": "제목 없는 보드", "WelcomePage.Description": "보드는 친숙한 칸반 보드 형태를 사용하여 팀간의 업무를 정의, 구성 및 추적하고 관리하는 프로젝트 관리 도구입니다.", "WelcomePage.Explore.Button": "탐색하기", "WelcomePage.Heading": "보드에 오신 것을 환영합니다", "WelcomePage.NoThanks.Text": "아뇨, 제가 알아서 해결하겠습니다", "WelcomePage.StartUsingIt.Text": "사용 시작하기", "Workspace.editing-board-template": "보드 템플릿을 수정하는 중입니다.", "badge.guest": "게스트", "boardSelector.confirm-link-board": "채널에 보드 연결하기", "boardSelector.confirm-link-board-button": "예. 보드 연결하기", "boardSelector.confirm-link-board-subtext": "{boardName}을(를) 채널에 연결하면 게스트를 제외한 채널의 모든 구성원(기존 및 새)이 해당 채널을 편집할 수 있습니다. 언제든지 채널에서 보드의 연결을 해제할 수 있습니다.", "boardSelector.confirm-link-board-subtext-with-other-channel": "{boardName}을(를) 채널에 연결하면 게스트를 제외한 채널의 모든 구성원(기존 및 새)이 해당 채널을 편집할 수 있습니다.{lineBreak} 이 보드는 현재 다른 채널에 연결되어 있습니다. 여기에 연결을 선택하면 연결이 해제됩니다.", "boardSelector.create-a-board": "보드 만들기", "boardSelector.link": "연결하기", "boardSelector.search-for-boards": "보드 검색하기", "boardSelector.title": "보드 연결하기", "boardSelector.unlink": "연결 해제하기", "calendar.month": "월", "calendar.today": "오늘", "calendar.week": "주", "cloudMessage.learn-more": "더 배우기", "createImageBlock.failed": "파일을 업로드할 수 없습니다. 파일 크기 제한에 도달했습니다.", "default-properties.badges": "댓글 및 설명", "default-properties.title": "제목", "error.back-to-home": "홈으로 돌아가기", "error.back-to-team": "팀으로 돌아가기", "error.board-not-found": "보드를 찾을 수 없습니다.", "error.go-login": "로그인", "error.invalid-read-only-board": "이 보드에 액세스할 수 없습니다. 보드에 액세스하려면 로그인하십시오.", "error.not-logged-in": "세션이 만료되었거나 로그인하지 않았을 수 있습니다. 보드에 액세스하려면 다시 로그인하십시오.", "error.page.title": "죄송해요, 뭔가 잘못되었어요", "error.team-undefined": "유효한 팀이 아닙니다.", "error.unknown": "오류가 발생했습니다.", "generic.previous": "이전", "guest-no-board.subtitle": "당신은 이 팀의 어느 보드에도 접속하고 있지 않습니다. 누군가 당신을 보드에 추가해 줄 때까지 기다려주세요.", "guest-no-board.title": "보드에 참여중이 아님", "imagePaste.upload-failed": "일부 파일이 업로드 되지 않았습니다. 파일 크기 제한에 도달했습니다", "limitedCard.title": "숨겨진 카드", "login.log-in-button": "로그인", "login.log-in-title": "로그인", "login.register-button": "계정이 없다면 계정을 만드세요", "notification-box-card-limit-reached.close-tooltip": "10일 동안 잠자기", "notification-box-card-limit-reached.contact-link": "관리자에게 알리기", "notification-box-card-limit-reached.link": "유료 요금제로 업그레이드하기", "notification-box-card-limit-reached.title": "{cards}개의 숨겨진 카드가 보드에 있습니다", "notification-box-cards-hidden.title": "이 작업으로 인해 다른 카드가 숨겨졌습니다", "notification-box.card-limit-reached.not-admin.text": "아카이브된 카드에 액세스하려면 {contactLink}을(를) 사용하여 유료 요금제로 업그레이드하십시오.", "notification-box.card-limit-reached.text": "카드 제한에 도달했습니다,이전 카드를 보려면 {link}를 눌러주세요", "person.add-user-to-board": "{username}을 보드에 추가", "person.add-user-to-board-confirm-button": "보드에 추가", "person.add-user-to-board-permissions": "권한", "person.add-user-to-board-question": "{username}을 보드에 추가하시겠습니까?", "person.add-user-to-board-warning": "{username}은 보드에 참여중이지 않으며, 관련될 알림을 수신하지 않습니다.", "register.login-button": "이미 계정이 있다면 로그인하세요", "register.signup-title": "계정 등록", "rhs-board-non-admin-msg": "당신은 보드의 관리자가 아닙니다", "rhs-boards.add": "추가하기", "rhs-boards.dm": "다이렉트 메시지", "rhs-boards.gm": "그룹 메시지", "rhs-boards.header.dm": "이 개인 메시지", "rhs-boards.header.gm": "이 그룹 메시지", "rhs-boards.last-update-at": "마지막 업데이트 시간: {datetime}", "rhs-boards.link-boards-to-channel": "{channelName}에 보드 연결하기", "rhs-boards.linked-boards": "연결된 보드", "rhs-boards.no-boards-linked-to-channel": "{channelName}에 아직 연결된 보드가 없음", "rhs-boards.no-boards-linked-to-channel-description": "Boards는 익숙한 Kanban 보드 뷰를 사용하여 팀 전체의 작업을 정의, 구성, 추적 및 관리하는 데 도움이 되는 프로젝트 관리 도구입니다.", "rhs-boards.unlink-board": "보드 연결해제하기", "rhs-boards.unlink-board1": "연결되지 않은 보드", "rhs-channel-boards-header.title": "보드", "share-board.publish": "게재하기", "share-board.share": "공유하기", "shareBoard.channels-select-group": "채널", "shareBoard.confirm-change-team-role.body": "이 보드 사용자 중 {role}보다 낮은 권한을 가진 사용자들은 {role}권한으로 승급합니다. 이 보드의 최소 권한을 변경하시겠습니까?", "shareBoard.confirm-change-team-role.confirmBtnText": "최소 권한 수정", "shareBoard.confirm-change-team-role.title": "최소 권한 수정", "shareBoard.confirm-link-channel": "채널에 보드 연결하기", "shareBoard.confirm-link-channel-button": "채널 연결하기", "shareBoard.confirm-link-channel-button-with-other-channel": "연결 및 연결해제하기", "shareBoard.confirm-link-channel-subtext": "채널을 보드에 연결하면 게스트를 제외한 채널의 모든 구성원(기존 및 신규)이 해당 채널을 편집할 수 있습니다.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "채널을 보드에 연결하면 게스트를 제외한 채널의 모든 구성원(기존 및 신규)이 해당 채널을 편집할 수 있습니다.{lineBreak}이 보드는 현재 다른 채널에 연결되어 있습니다. 여기에 연결을 선택하면 연결이 해제됩니다.", "shareBoard.confirm-unlink.body": "보드에서 채널 연결을 해제하면 채널의 모든 구성원(기존 및 새)이 개별적으로 권한이 부여되지 않는 한 해당 채널에 대한 액세스 권한을 잃게 됩니다.", "shareBoard.confirm-unlink.confirmBtnText": "채널 연결 해제하기", "shareBoard.confirm-unlink.title": "보드에서 채널 연결 해제하기", "shareBoard.lastAdmin": "보드에는 최소한 한명 이상의 관리자 있어야 합니다", "shareBoard.members-select-group": "멤버", "shareBoard.unknown-channel-display-name": "알수없는 채널", "tutorial_tip.finish_tour": "완료", "tutorial_tip.got_it": "알겠습니다", "tutorial_tip.ok": "다음", "tutorial_tip.out": "이 도움말을 선택 해제합니다.", "tutorial_tip.seen": "전에 본적 있나요?" } ================================================ FILE: webapp/i18n/lt.json ================================================ {} ================================================ FILE: webapp/i18n/ml.json ================================================ { "BoardComponent.add-a-group": "+ ഒരു ഗ്രൂപ്പ് ചേർക്കുക", "BoardComponent.delete": "നീക്കം ചെയ്യുക", "BoardComponent.hidden-columns": "മറഞ്ഞിരിക്കുന്ന നിരകൾ", "BoardComponent.hide": "മറയ്ക്കുക", "BoardComponent.new": "+ പുതിയത്", "BoardComponent.no-property": "ഇല്ല {property}", "BoardComponent.no-property-title": "ശൂന്യമായ {property} പ്രോപ്പർട്ടി ഉള്ള ഇനങ്ങൾ ഇവിടെ പോകും. ഈ കോളം നീക്കം ചെയ്യാൻ കഴിയില്ല.", "BoardComponent.show": "കാണിക്കുക", "BoardMember.schemeAdmin": "അഡ്മിൻ", "BoardMember.schemeCommenter": "കമന്റേറ്റർ", "BoardMember.schemeEditor": "എഡിറ്റർ", "BoardMember.schemeNone": "ഒന്നുമില്ല", "BoardMember.schemeViewer": "കാഴ്ചക്കാരൻ", "BoardPage.newVersion": "ബോർഡുകളുടെ ഒരു പുതിയ പതിപ്പ് ലഭ്യമാണ്, റീലോഡ് ചെയ്യാൻ ഇവിടെ ക്ലിക്ക് ചെയ്യുക.", "BoardPage.syncFailed": "ബോർഡ് ഇല്ലാതാക്കുകയോ ആക്സസ് റദ്ദാക്കുകയോ ചെയ്യാം.", "BoardTemplateSelector.add-template": "പുതിയ ടെംപ്ലേറ്റ്", "BoardTemplateSelector.create-empty-board": "ശൂന്യമായ ബോർഡ് സൃഷ്ടിക്കുക", "BoardTemplateSelector.delete-template": "ഇല്ലാതാക്കുക", "BoardTemplateSelector.description": "ആരംഭിക്കാൻ നിങ്ങളെ സഹായിക്കുന്നതിന് ഒരു ടെംപ്ലേറ്റ് തിരഞ്ഞെടുക്കുക. നിങ്ങളുടെ ആവശ്യങ്ങൾക്ക് അനുയോജ്യമായ രീതിയിൽ ടെംപ്ലേറ്റ് എളുപ്പത്തിൽ ഇച്ഛാനുസൃതമാക്കുക, അല്ലെങ്കിൽ ആദ്യം മുതൽ ആരംഭിക്കാൻ ഒരു ശൂന്യമായ ബോർഡ് സൃഷ്ടിക്കുക.", "BoardTemplateSelector.edit-template": "എഡിറ്റ് ചെയ്യുക", "BoardTemplateSelector.plugin.no-content-description": "ചുവടെ നിർവചിച്ചിരിക്കുന്ന ഏതെങ്കിലും ടെംപ്ലേറ്റുകൾ ഉപയോഗിച്ച് സൈഡ്ബാറിലേക്ക് ഒരു ബോർഡ് ചേർക്കുക അല്ലെങ്കിൽ ആദ്യം മുതൽ ആരംഭിക്കുക.", "BoardTemplateSelector.plugin.no-content-title": "ഒരു ബോർഡ് ഉണ്ടാക്കുക", "BoardTemplateSelector.title": "ഒരു ബോർഡ് ഉണ്ടാക്കുക", "BoardTemplateSelector.use-this-template": "ഈ ടെംപ്ലേറ്റ് ഉപയോഗിക്കുക", "BoardsSwitcher.Title": "ബോർഡുകൾ കണ്ടെത്തുക", "BoardsUnfurl.Remainder": "+{remainder} കൂടുതൽ", "BoardsUnfurl.Updated": "പുതുക്കിയത് {time}", "Calculations.Options.average.displayName": "ശരാശരി", "Calculations.Options.average.label": "ശരാശരി", "Calculations.Options.count.displayName": "എണ്ണുക", "Calculations.Options.count.label": "എണ്ണുക", "Calculations.Options.countChecked.displayName": "പരിശോധിച്ചു", "Calculations.Options.countChecked.label": "എണ്ണം പരിശോധിച്ചു", "Calculations.Options.countUnchecked.displayName": "പരിശോധിക്കാത്തത്", "Calculations.Options.countUnchecked.label": "പരിശോധിക്കാത്തതിന്റെ എണ്ണം", "Calculations.Options.countUniqueValue.displayName": "അതുല്യമായ", "Calculations.Options.countUniqueValue.label": "അദ്വിതീയ മൂല്യങ്ങൾ എണ്ണുക", "Calculations.Options.countValue.displayName": "മൂല്യങ്ങൾ", "Calculations.Options.countValue.label": "മൂല്യം എണ്ണുക", "Calculations.Options.dateRange.displayName": "പരിധി", "Calculations.Options.dateRange.label": "പരിധി", "Calculations.Options.earliest.displayName": "നേരത്തെ", "Calculations.Options.earliest.label": "നേരത്തെ", "Calculations.Options.latest.displayName": "ഏറ്റവും പുതിയ", "Calculations.Options.latest.label": "ഏറ്റവും പുതിയ", "Calculations.Options.max.displayName": "പരമാവധി", "Calculations.Options.max.label": "പരമാവധി", "Calculations.Options.median.displayName": "മധ്യമം", "Calculations.Options.median.label": "മധ്യമം", "Calculations.Options.min.displayName": "കുറവ്", "Calculations.Options.min.label": "കുറവ്", "Calculations.Options.none.displayName": "കണക്കാക്കുക", "Calculations.Options.none.label": "ഒന്നുമില്ല", "Calculations.Options.percentChecked.displayName": "പരിശോധിച്ചു", "Calculations.Options.percentChecked.label": "ശതമാനം പരിശോധിച്ചു", "Calculations.Options.percentUnchecked.displayName": "പരിശോധിക്കാത്തത്", "Calculations.Options.percentUnchecked.label": "ശതമാനം പരിശോധിക്കാത്തത്", "Calculations.Options.range.displayName": "പരിധി", "Calculations.Options.range.label": "പരിധി", "Calculations.Options.sum.displayName": "തുക", "Calculations.Options.sum.label": "തുക", "CardActionsMenu.copiedLink": "പകർത്തി!", "CardActionsMenu.copyLink": "ലിങ്ക് പകർത്തുക", "CardActionsMenu.delete": "ഡിലീറ്റ് ചെയ്യുക", "CardActionsMenu.duplicate": "പകർപ്പ്", "CardBadges.title-checkboxes": "ചെക്ക്ബോക്സുകൾ", "CardBadges.title-comments": "അഭിപ്രായങ്ങൾ", "CardBadges.title-description": "ഈ കാർഡിന് ഒരു വിവരണമുണ്ട്", "CardDetail.Follow": "പിന്തുടരുക", "CardDetail.Following": "പിന്തുടരുന്നു", "CardDetail.add-content": "ഉള്ളടക്കം ചേർക്കുക", "CardDetail.add-icon": "ഐക്കൺ ചേർക്കുക", "CardDetail.add-property": "+ ഒരു വിശേഷണം ചേർക്കുക", "CardDetail.addCardText": "കാർഡിൽ വാക്യം ചേർക്കുക", "CardDetail.limited-body": "ആർക്കൈവ് ചെയ്‌ത കാർഡുകൾ കാണുന്നതിനും ഓരോ ബോർഡുകൾക്കും പരിധിയില്ലാത്ത കാഴ്‌ചകൾ നേടുന്നതിനും പരിധിയില്ലാത്ത കാർഡുകൾക്കും മറ്റും ഞങ്ങളുടെ പ്രൊഫഷണൽ അല്ലെങ്കിൽ എന്റർപ്രൈസ് പ്ലാനിലേക്ക് അപ്‌ഗ്രേഡ് ചെയ്യുക.", "CardDetail.limited-button": "അപ്ഗ്രേഡ്", "CardDetail.moveContent": "കാർഡ് ഉള്ളടക്കം നീക്കുക", "CardDetail.new-comment-placeholder": "ഒരു അഭിപ്രായം ചേർക്കുക...", "CardDetailProperty.confirm-delete-heading": "പ്രോപ്പർട്ടി ഇല്ലാതാക്കുന്നത് സ്ഥിരീകരിക്കുക", "CardDetailProperty.confirm-delete-subtext": "പ്രോപ്പർട്ടി ഇല്ലാതാക്കണമെന്ന് തീർച്ചയാണോ \"{propertyName}\"? ഇത് ഇല്ലാതാക്കുന്നത് ഈ ബോർഡിലെ എല്ലാ കാർഡുകളിൽ നിന്നും പ്രോപ്പർട്ടി ഇല്ലാതാക്കും.", "CardDetailProperty.confirm-property-name-change-subtext": "\"{propertyName}\" {customText} പ്രോപ്പർട്ടി മാറ്റണമെന്ന് തീർച്ചയാണോ? ഇത് ഈ ബോർഡിലെ {numOfCards} കാർഡുകളിലുടനീളമുള്ള മൂല്യങ്ങളെ(കളെ) ബാധിക്കുകയും ഡാറ്റ നഷ്‌ടത്തിന് കാരണമാവുകയും ചെയ്യും.", "CardDetailProperty.confirm-property-type-change": "പ്രോപ്പർട്ടി തരം മാറ്റം സ്ഥിരീകരിക്കുക!", "CardDetailProperty.delete-action-button": "നീക്കം ചെയ്യുക", "CardDetailProperty.property-change-action-button": "പ്രോപ്പർട്ടി മാറ്റുക", "CardDetailProperty.property-changed": "പ്രോപ്പർട്ടി വിജയകരമായി മാറ്റി!", "CardDetailProperty.property-deleted": "വിജയകരമായി {propertyName} ഇല്ലാതാക്കിയിരിക്കുന്നു!", "CardDetailProperty.property-name-change-subtext": "\"{oldPropType}\" മുതൽ \"{newPropType}\" വരെ ടൈപ്പ് ചെയ്യുക", "CardDialog.delete-confirmation-dialog-button-text": "ഇല്ലാതാക്കുക", "CardDialog.delete-confirmation-dialog-heading": "കാർഡ് ഇല്ലാതാക്കൽ സ്ഥിരീകരിക്കുക!", "CardDialog.editing-template": "നിങ്ങൾ ഒരു ടെംപ്ലേറ്റ് എഡിറ്റ് ചെയ്യുകയാണ്.", "CardDialog.nocard": "ഈ കാർഡ് നിലവിലില്ല അല്ലെങ്കിൽ ആക്സസ് ചെയ്യാനാകുന്നില്ല.", "Categories.CreateCategoryDialog.CancelText": "റദ്ദാക്കുക", "Categories.CreateCategoryDialog.CreateText": "സൃഷ്ടിക്കുക", "Categories.CreateCategoryDialog.Placeholder": "നിങ്ങളുടെ വിഭാഗത്തിന് പേര് നൽകുക", "Categories.CreateCategoryDialog.UpdateText": "അപ്ഡേറ്റ് ചെയ്യുക", "CenterPanel.Login": "ലോഗിൻ", "CenterPanel.Share": "പങ്കിടുക", "ColorOption.selectColor": "നിറം {color} തിരഞ്ഞെടുക്കുക", "Comment.delete": "ഇല്ലാതാക്കുക", "CommentsList.send": "അയക്കുക", "ConfirmationDialog.cancel-action": "റദ്ദാക്കുക", "ConfirmationDialog.confirm-action": "സ്ഥിരീകരിക്കുക", "ContentBlock.Delete": "ഇല്ലാതാക്കുക", "ContentBlock.DeleteAction": "ഇല്ലാതാക്കുക", "ContentBlock.addElement": "ചേർക്കുക {type}", "ContentBlock.checkbox": "ചെക്ക്ബോക്സ്", "ContentBlock.divider": "ഡിവൈഡർ", "ContentBlock.editCardCheckbox": "ടുഗേൾഡ് -ചെക്ക്ബോക്സ്", "ContentBlock.editCardCheckboxText": "കാർഡിലെ വാചകം തിരുത്തുക", "ContentBlock.editCardText": "കാർഡിലെ വാചകം തിരുത്തുക", "ContentBlock.editText": "വാചകം തിരുത്തുക...", "ContentBlock.image": "ചിത്രം", "ContentBlock.insertAbove": "മുകളിൽ തിരുകുക", "ContentBlock.moveDown": "താഴേക്ക് നീക്കുക", "ContentBlock.moveUp": "മുകളിലേക്കു നീക്കുക", "ContentBlock.text": "വാചകം", "DateRange.endDate": "അവസാന തീയതി", "DateRange.today": "ഇന്ന്", "DeleteBoardDialog.confirm-cancel": "നിര്‍ത്തലാക്കുക", "DeleteBoardDialog.confirm-delete": "നീക്കം ചെയ്യുക", "DeleteBoardDialog.confirm-info": "\"{boardTitle}\" എന്ന ബോർഡ് ഇല്ലാതാക്കണമെന്ന് തീർച്ചയാണോ? ഇത് ഇല്ലാതാക്കുന്നത് ബോർഡിലെ എല്ലാ കാർഡുകളും ഇല്ലാതാക്കും.", "DeleteBoardDialog.confirm-tite": "ബോർഡ് നീക്കം ചെയ്യുന്നത് സ്ഥിരീകരിക്കുക", "DeleteBoardDialog.confirm-tite-template": "ബോർഡ് ടെംപ്ലേറ്റ് ഇല്ലാതാക്കുന്നത് സ്ഥിരീകരിക്കുക", "Dialog.closeDialog": "ഡയലോഗ് അവസാനിപ്പിക്കുക", "EditableDayPicker.today": "ഇന്ന്", "Error.mobileweb": "മൊബൈൽ വെബ് പിന്തുണ നിലവിൽ ആദ്യകാല ബീറ്റയിലാണ്. എല്ലാ പ്രവർത്തനങ്ങളും ഉണ്ടായിരിക്കണമെന്നില്ല.", "Error.websocket-closed": "വെബ്‌സോക്കറ്റ് കണക്ഷൻ അടച്ചു, കണക്ഷൻ തടസ്സപ്പെട്ടു. ഇത് നിലനിൽക്കുകയാണെങ്കിൽ, നിങ്ങളുടെ സെർവർ അല്ലെങ്കിൽ വെബ് പ്രോക്സി കോൺഫിഗറേഷൻ പരിശോധിക്കുക.", "Filter.includes": "ഉൾപ്പെടുന്നു", "Filter.is-empty": "ശൂന്യമാണ്", "Filter.is-not-empty": "ശൂന്യമല്ല", "Filter.not-includes": "ഉൾപ്പെടുന്നില്ല", "FilterComponent.add-filter": "+ ഫിൽട്ടർ ചേർക്കുക", "FilterComponent.delete": "ഇല്ലാതാക്കുക", "FindBoardsDialog.NoResultsFor": "\"{searchQuery}\" എന്നതിന് ഫലങ്ങളൊന്നുമില്ല", "FindBoardsDialog.NoResultsSubtext": "അക്ഷരവിന്യാസം പരിശോധിക്കുക അല്ലെങ്കിൽ മറ്റൊരു തിരയൽ പരീക്ഷിക്കുക.", "FindBoardsDialog.SubTitle": "ഒരു ബോർഡ് കണ്ടെത്താൻ ടൈപ്പ് ചെയ്യുക. ബ്രൗസ് ചെയ്യാൻ UP/DOWN ഉപയോഗിക്കുക. തിരഞ്ഞെടുക്കാൻ ENTER, ഡിസ്മിസ് ചെയ്യാൻ ESC", "FindBoardsDialog.Title": "ബോർഡുകൾ കണ്ടെത്തുക", "GroupBy.hideEmptyGroups": "{count} ശൂന്യമായ ഗ്രൂപ്പുകൾ മറയ്ക്കുക", "GroupBy.showHiddenGroups": "മറഞ്ഞിരിക്കുന്ന {count} ഗ്രൂപ്പുകൾ കാണിക്കുക", "GroupBy.ungroup": "ഗ്രൂപ്പിൽ നിന്നും മാറ്റുക", "KanbanCard.untitled": "ശീർഷകമില്ലാത്തത്", "Mutator.new-card-from-template": "ടെംപ്ലേറ്റിൽ നിന്നുള്ള പുതിയ കാർഡ്", "Mutator.new-template-from-card": "കാർഡിൽ നിന്നുള്ള പുതിയ ടെംപ്ലേറ്റ്", "OnboardingTour.AddComments.Body": "നിങ്ങൾക്ക് പ്രശ്‌നങ്ങളിൽ അഭിപ്രായമിടാം, ഒപ്പം നിങ്ങളുടെ സഹ മാറ്റർമോസ് ഉപയോക്താക്കളെ അവരുടെ ശ്രദ്ധ ആകർഷിക്കാൻ അവരെ @പരാമർശിക്കുകയും ചെയ്യാം.", "OnboardingTour.AddComments.Title": "അഭിപ്രായങ്ങൾ ചേർക്കുക", "OnboardingTour.AddDescription.Body": "നിങ്ങളുടെ കാർഡിലേക്ക് ഒരു വിവരണം ചേർക്കുക, അതുവഴി കാർഡ് എന്താണെന്ന് നിങ്ങളുടെ ടീമംഗങ്ങൾക്ക് അറിയാം.", "OnboardingTour.AddDescription.Title": "വിവരണം ചേർക്കുക", "OnboardingTour.AddProperties.Body": "കാർഡുകളെ കൂടുതൽ ശക്തമാക്കുന്നതിന് അവയിൽ വിവിധ പ്രോപ്പർട്ടികൾ ചേർക്കുക!", "OnboardingTour.AddProperties.Title": "പ്രോപ്പർട്ടികൾ ചേർക്കുക", "OnboardingTour.AddView.Body": "വ്യത്യസ്‌ത ലേഔട്ടുകൾ ഉപയോഗിച്ച് നിങ്ങളുടെ ബോർഡ് ഓർഗനൈസുചെയ്യുന്നതിന് ഒരു പുതിയ കാഴ്‌ച സൃഷ്‌ടിക്കുന്നതിന് ഇവിടെ പോകുക.", "OnboardingTour.AddView.Title": "ഒരു പുതിയ കാഴ്ച ചേർക്കുക", "OnboardingTour.CopyLink.Body": "ലിങ്ക് പകർത്തി ഒരു ചാനലിലോ ഡയറക്ട് മെസേജിലോ ഗ്രൂപ്പ് മെസേജിലോ പകർത്തികൊണ്ട് നിങ്ങൾക്ക് ടീമംഗങ്ങളുമായി കാർഡുകൾ പങ്കിടാം.", "OnboardingTour.CopyLink.Title": "ലിങ്ക് പകർത്തുക", "OnboardingTour.OpenACard.Body": "നിങ്ങളുടെ ജോലി ഓർഗനൈസുചെയ്യാൻ ബോർഡുകൾക്ക് നിങ്ങളെ സഹായിക്കുന്ന ശക്തമായ വഴികൾ പര്യവേക്ഷണം ചെയ്യാൻ ഒരു കാർഡ് തുറക്കുക.", "OnboardingTour.OpenACard.Title": "ഒരു കാർഡ് തുറക്കുക", "OnboardingTour.ShareBoard.Body": "നിങ്ങളുടെ ബോർഡ് ടീമിനുള്ളിലും പങ്കിടാം അല്ലെങ്കിൽ നിങ്ങളുടെ സ്ഥാപനത്തിന് പുറത്തുള്ള ദൃശ്യപരതയ്ക്കായി അത് പരസ്യമായി പ്രസിദ്ധീകരിക്കാം.", "OnboardingTour.ShareBoard.Title": "ഷെയർ ബോർഡ്", "PropertyMenu.Delete": "ഇല്ലാതാക്കുക", "PropertyMenu.changeType": "പ്രോപ്പർട്ടി തരം മാറ്റുക", "PropertyMenu.selectType": "പ്രോപ്പർട്ടി തരം തിരഞ്ഞെടുക്കുക", "PropertyMenu.typeTitle": "തരം", "PropertyType.Checkbox": "ചെക്ക്ബോക്സ്", "PropertyType.CreatedBy": "ഉണ്ടാക്കിയത്", "PropertyType.CreatedTime": "സൃഷ്ടിച്ച സമയം", "PropertyType.Date": "തീയതി", "PropertyType.Email": "ഇമെയിൽ", "PropertyType.MultiSelect": "മൾട്ടി സെലക്ട്", "PropertyType.Number": "നമ്പർ", "PropertyType.Person": "വ്യക്തി", "PropertyType.Phone": "ഫോൺ", "PropertyType.Select": "തിരഞ്ഞെടുക്കുക", "PropertyType.Text": "വാചകം", "PropertyType.UpdatedBy": "അവസാനം അപ്ഡേറ്റ് ചെയ്തത്", "PropertyType.UpdatedTime": "അവസാനം പുതുക്കിയ സമയം", "PropertyValueElement.empty": "ശൂന്യം", "RegistrationLink.confirmRegenerateToken": "ഇത് മുമ്പ് പങ്കിട്ട ലിങ്കുകളെ അസാധുവാക്കും. തുടരുക?", "RegistrationLink.copiedLink": "പകർത്തി!", "RegistrationLink.copyLink": "ലിങ്ക് പകർത്തുക", "RegistrationLink.description": "മറ്റുള്ളവർക്ക് അക്കൗണ്ടുകൾ സൃഷ്ടിക്കാൻ ഈ ലിങ്ക് പങ്കിടുക:", "RegistrationLink.regenerateToken": "ടോക്കൺ പുനർനിർമ്മിക്കുക", "RegistrationLink.tokenRegenerated": "രജിസ്ട്രേഷൻ ലിങ്ക് പുനഃസൃഷ്ടിച്ചു", "ShareBoard.PublishDescription": "വെബിലെ എല്ലാവരുമായും \"വായന മാത്രം\" എന്ന ലിങ്ക് പ്രസിദ്ധീകരിക്കുകയും പങ്കിടുകയും ചെയ്യുക", "ShareBoard.PublishTitle": "വെബിൽ പ്രസിദ്ധീകരിക്കുക", "ShareBoard.ShareInternalDescription": "അനുമതിയുള്ള ഉപയോക്താക്കൾക്ക് ഈ ലിങ്ക് ഉപയോഗിക്കാനാകും", "ShareBoard.Title": "ഷെയർ ബോർഡ്", "ShareBoard.confirmRegenerateToken": "ഇത് മുമ്പ് പങ്കിട്ട ലിങ്കുകളെ അസാധുവാക്കും. തുടരുക?", "ShareBoard.copiedLink": "പകർത്തി!", "ShareBoard.copyLink": "ലിങ്ക് പകർത്തുക", "ShareBoard.regenerate": "ടോക്കൺ പുനർനിർമ്മിക്കുക", "ShareBoard.teamPermissionsText": "{teamName} ടീമിലെ എല്ലാവരും", "ShareBoard.tokenRegenrated": "ടോക്കൺ പുനർനിർമ്മിച്ചു", "ShareBoard.userPermissionsRemoveMemberText": "അംഗത്തെ നീക്കം ചെയ്യുക", "ShareBoard.userPermissionsYouText": "(നിങ്ങൾ)", "ShareTemplate.Title": "ടെംപ്ലേറ്റ് പങ്കിടുക", "Sidebar.about": "ഫോക്കൽബോർഡിനെക്കുറിച്ച്", "Sidebar.add-board": "+ ബോർഡ് ചേർക്കുക", "Sidebar.changePassword": "പാസ്സ്‌വേഡ്‌ മാറ്റുക", "Sidebar.delete-board": "ബോർഡ് നീക്കം ചെയ്യുക", "Sidebar.duplicate-board": "ഡ്യൂപ്ലിക്കേറ്റ് ബോർഡ്", "Sidebar.export-archive": "ആർക്കൈവ് എക്സ്പോർട്ട് ചെയ്യുക", "Sidebar.import": "ഇറക്കുമതി ചെയ്യുക", "Sidebar.import-archive": "ആർക്കൈവ് ഇമ്പോർട്ട് ചെയ്യുക", "Sidebar.invite-users": "ഉപയോക്താക്കളെ ക്ഷണിക്കുക", "Sidebar.logout": "ലോഗ്ഔട്ട്", "Sidebar.no-boards-in-category": "അകത്ത് ബോർഡുകളില്ല", "Sidebar.random-icons": "ക്രമരഹിതമായ ഐക്കണുകൾ", "Sidebar.set-language": "ഭാഷ സജ്ജമാക്കുക", "Sidebar.set-theme": "തീം സജ്ജമാക്കുക", "Sidebar.settings": "ക്രമീകരണങ്ങൾ", "Sidebar.template-from-board": "ബോർഡിൽ നിന്നുള്ള പുതിയ ടെംപ്ലേറ്റ്", "Sidebar.untitled-board": "(പേരില്ലാത്ത ബോർഡ്)", "SidebarCategories.BlocksMenu.Move": "ഇതിലേക്ക് നീക്കുക...", "SidebarCategories.CategoryMenu.CreateNew": "പുതിയ വിഭാഗം സൃഷ്ടിക്കുക", "SidebarCategories.CategoryMenu.Delete": "വിഭാഗം ഇല്ലാതാക്കുക", "SidebarCategories.CategoryMenu.DeleteModal.Body": "{categoryName} എന്നതിലെ ബോർഡുകൾ ബോർഡുകളുടെ വിഭാഗങ്ങളിലേക്ക് തിരികെ നീങ്ങും. നിങ്ങളെ ഒരു ബോർഡിൽ നിന്നും നീക്കം ചെയ്‌തിട്ടില്ല.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "ഈ കാറ്റഗറി ഇല്ലാതാക്കണോ?", "SidebarCategories.CategoryMenu.Update": "കാറ്റഗറിയുടെ പേര് മാറ്റുക", "TableComponent.add-icon": "ഐക്കൺ ചേർക്കുക", "TableComponent.name": "പേര്", "TableComponent.plus-new": "+ പുതിയത്", "TableHeaderMenu.delete": "ഇല്ലാതാക്കുക", "TableHeaderMenu.duplicate": "തനിപ്പകർപ്പ്", "TableHeaderMenu.hide": "മറയ്ക്കുക", "TableHeaderMenu.insert-left": "ഇടത്തേക്ക് തിരുകുക", "TableHeaderMenu.insert-right": "വലത്തേക്ക് തിരുകുക", "TableHeaderMenu.sort-ascending": "ആരോഹണക്രമത്തിൽ അടുക്കുക", "TableHeaderMenu.sort-descending": "അവരോഹണക്രമം അടുക്കുക", "TableRow.open": "തുറക്കുക", "TopBar.give-feedback": "അഭിപ്രായം അറിയിക്കുക", "URLProperty.copiedLink": "പകർത്തി!", "URLProperty.copy": "പകർത്തുക", "ValueSelector.noOptions": "ഓപ്ഷനുകളൊന്നുമില്ല. ആദ്യത്തേത് ചേർക്കാൻ ടൈപ്പുചെയ്യാൻ ആരംഭിക്കുക!", "ValueSelector.valueSelector": "മൂല്യ0 തിരഞ്ഞെടുക്കുക", "ValueSelectorLabel.openMenu": "മെനു തുറക്കുക", "View.AddView": "വ്യൂ ചേർക്കുക", "View.Board": "ബോർഡ്", "View.DeleteView": "വ്യൂ നീക്കം ചെയ്യുക", "View.DuplicateView": "വ്യൂവിന്റെ തനിപ്പകര്‍പ്പ്", "View.Gallery": "ചിത്രശാല", "View.NewBoardTitle": "ബോർഡ് വ്യൂ", "View.NewCalendarTitle": "കലണ്ടർ വ്യൂ", "View.NewGalleryTitle": "ചിത്രശാല വ്യൂ", "View.NewTableTitle": "പട്ടിക വ്യൂ", "View.Table": "പട്ടിക", "ViewHeader.add-template": "പുതിയ ടെംപ്ലേറ്റ്", "ViewHeader.delete-template": "നീക്കം ചെയ്യുക", "ViewHeader.display-by": "പ്രദർശിപ്പിക്കുന്നത്: {property}", "ViewHeader.edit-template": "തിരുത്തുക", "ViewHeader.empty-card": "ശൂന്യമായ കാർഡ്", "ViewHeader.export-board-archive": "ബോർഡ് ആർക്കൈവ് എക്സ്പോർട്ട് ചെയ്യുക", "ViewHeader.export-complete": "എക്സ്പോർട്ട് പൂർത്തിയായി!", "ViewHeader.export-csv": "CSV-ലേക്ക് എക്സ്പോർട്ട് ചെയ്യുക", "ViewHeader.export-failed": "എക്സ്പോർട്ട് പരാജയപ്പെട്ടു!", "ViewHeader.filter": "ഫിൽട്ടർ", "ViewHeader.group-by": "ഗ്രൂപ്പ്:{property}", "ViewHeader.new": "പുതിയത്", "ViewHeader.properties": "സവിശേഷതകള്‍", "ViewHeader.properties-menu": "പ്രോപ്പർട്ടീസ് മെനു", "ViewHeader.search-text": "കാർഡുകൾ തിരയുക", "ViewHeader.select-a-template": "ഒരു ടെംപ്ലേറ്റ് തിരഞ്ഞെടുക്കുക", "ViewHeader.set-default-template": "സ്ഥിരസ്ഥിതിയായി സജ്ജമാക്കാൻ", "ViewHeader.sort": "അടുക്കുക", "ViewHeader.untitled": "ശീർഷകമില്ലാത്തത്", "ViewHeader.view-header-menu": "തലക്കെട്ട് മെനു കാണുക", "ViewHeader.view-menu": "മെനു കാണുക", "ViewTitle.hide-description": "വിവരണം മറയ്ക്കുക", "ViewTitle.pick-icon": "ഐക്കൺ തിരഞ്ഞെടുക്കുക", "ViewTitle.random-icon": "ക്രമരഹിതം", "ViewTitle.remove-icon": "ഐക്കൺ നീക്കം ചെയ്യുക", "ViewTitle.show-description": "വിവരണം കാണിക്കുക", "ViewTitle.untitled-board": "ശീർഷകമില്ലാത്ത ബോർഡ്", "WelcomePage.Description": "പരിചിതമായ കാൻബൻ ബോർഡ് വ്യൂ ഉപയോഗിച്ച് ടീമുകളിലുടനീളമുള്ള ജോലി നിർവചിക്കാനും ഓർഗനൈസുചെയ്യാനും ട്രാക്കുചെയ്യാനും നിയന്ത്രിക്കാനും സഹായിക്കുന്ന ഒരു പ്രോജക്റ്റ് മാനേജ്‌മെന്റ് ടൂളാണ് ബോർഡുകൾ", "WelcomePage.Explore.Button": "ഒരു ടൂർ നടത്തുക", "WelcomePage.Heading": "ബോർഡുകളിലേക്ക് സ്വാഗതം", "WelcomePage.NoThanks.Text": "വേണ്ട നന്ദി, ഞാനത് സ്വയം കണ്ടുപിടിക്കാം", "Workspace.editing-board-template": "നിങ്ങൾ ഒരു ബോർഡ് ടെംപ്ലേറ്റ് എഡിറ്റ് ചെയ്യുകയാണ്.", "calendar.month": "മാസം", "calendar.today": "ഇന്ന്", "calendar.week": "ആഴ്ച", "createImageBlock.failed": "ഫയൽ അപ്‌ലോഡ് ചെയ്യാൻ കഴിയുന്നില്ല. ഫയൽ വലുപ്പ പരിധി എത്തി.", "default-properties.badges": "അഭിപ്രായങ്ങളും വിവരണവും", "default-properties.title": "തലക്കെട്ട്", "error.page.title": "ക്ഷമിക്കണം, എന്തോ കുഴപ്പം സംഭവിച്ചു", "generic.previous": "മുൻപിലേക്ക്", "imagePaste.upload-failed": "ചില ഫയലുകൾ അപ്‌ലോഡ് ചെയ്തിട്ടില്ല. ഫയൽ വലുപ്പ പരിധി എത്തി", "login.log-in-button": "ലോഗിൻ", "login.log-in-title": "ലോഗിൻ", "login.register-button": "അല്ലെങ്കിൽ നിങ്ങൾക്ക് അക്കൗണ്ട് ഇല്ലെങ്കിൽ ഒരു അക്കൗണ്ട് സൃഷ്ടിക്കുക", "register.login-button": "അല്ലെങ്കിൽ നിങ്ങൾക്ക് ഇതിനകം ഒരു അക്കൗണ്ട് ഉണ്ടെങ്കിൽ ലോഗിൻ ചെയ്യുക", "register.signup-title": "നിങ്ങളുടെ അക്കൗണ്ടിനായി സൈൻ അപ്പ് ചെയ്യുക", "share-board.publish": "പ്രസിദ്ധീകരിക്കുക", "share-board.share": "പങ്കിടുക", "shareBoard.lastAdmin": "ബോർഡുകളിൽ കുറഞ്ഞത് ഒരു അഡ്മിനിസ്ട്രേറ്റർ ഉണ്ടായിരിക്കണം", "tutorial_tip.finish_tour": "ചെയ്തു", "tutorial_tip.got_it": "മനസ്സിലായി", "tutorial_tip.ok": "അടുത്തത്", "tutorial_tip.out": "ഈ നുറുങ്ങുകളിൽ നിന്നും തിരഞ്ഞെടുക്കുക.", "tutorial_tip.seen": "ഇത് മുമ്പ് കണ്ടിട്ടുണ്ടോ?" } ================================================ FILE: webapp/i18n/nb_NO.json ================================================ { "AppBar.Tooltip": "Veksle lenkede tavler", "Attachment.Attachment-title": "Vedlegg", "AttachmentBlock.DeleteAction": "slett", "AttachmentBlock.addElement": "legg til {type}", "AttachmentBlock.delete": "Vedlegg slettet.", "AttachmentBlock.failed": "Denne filen kunne ikke lastes opp fordi størrelsesgrensen er nådd.", "AttachmentBlock.upload": "Vedlegg lastes opp.", "AttachmentBlock.uploadSuccess": "Vedlegg lastet opp.", "AttachmentElement.delete-confirmation-dialog-button-text": "Slett", "AttachmentElement.download": "Last ned", "AttachmentElement.upload-percentage": "Laster opp ...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Legg til gruppe", "BoardComponent.delete": "Slett", "BoardComponent.hidden-columns": "Skjulte kolonner", "BoardComponent.hide": "Skjul", "BoardComponent.new": "+ Ny", "BoardComponent.no-property": "Ingen {property}", "BoardComponent.no-property-title": "Elementer med tom {property} atributt legges her. Denne kolonnen kan ikke fjernes.", "BoardComponent.show": "Vis", "BoardMember.schemeAdmin": "Admin", "BoardMember.schemeCommenter": "Kommentator", "BoardMember.schemeEditor": "Redaktør", "BoardMember.schemeNone": "Ingen", "BoardMember.schemeViewer": "Viser", "BoardMember.unlinkChannel": "Fjern lenke", "BoardPage.newVersion": "En ny versjon av Boards er tilgjengelig, klikk her for å laste inn på nytt.", "BoardPage.syncFailed": "Tavle kan slettes eller adgangen trekkes tilbake.", "BoardTemplateSelector.add-template": "Lag ny mal", "BoardTemplateSelector.create-empty-board": "Opprett tom tavle", "BoardTemplateSelector.delete-template": "Slett", "BoardTemplateSelector.description": "Legg til en tavle til sidestolpen med hvilken mal du vil fra listen under, eller start med en helt tom tavle.", "BoardTemplateSelector.edit-template": "Rediger", "BoardTemplateSelector.plugin.no-content-description": "Legg til en tavle i sidestolpen med hvilken mal du vil, eller start med en tom tavle.", "BoardTemplateSelector.plugin.no-content-title": "Lag ny tavle", "BoardTemplateSelector.title": "Lag ny tavle", "BoardTemplateSelector.use-this-template": "Bruk denne malen", "BoardsSwitcher.Title": "Finn tavle", "BoardsUnfurl.Limited": "Flere detaljer er skjult fordi kortet er arkivert", "BoardsUnfurl.Remainder": "+{remainder} mer", "BoardsUnfurl.Updated": "Oppdatert {time}", "Calculations.Options.average.displayName": "Gjennomsnitt", "Calculations.Options.average.label": "Average", "Calculations.Options.count.displayName": "Antall", "Calculations.Options.count.label": "Antall", "Calculations.Options.countChecked.displayName": "Avkrysset", "Calculations.Options.countChecked.label": "Antall valgt", "Calculations.Options.countUnchecked.displayName": "Ikke avmerket", "Calculations.Options.countUnchecked.label": "Antall ikke valgt", "Calculations.Options.countUniqueValue.displayName": "Unik", "Calculations.Options.countUniqueValue.label": "Antall unike verdier", "Calculations.Options.countValue.displayName": "Verdier", "Calculations.Options.countValue.label": "Antall verdier", "Calculations.Options.dateRange.displayName": "Tidsrom", "Calculations.Options.dateRange.label": "Tidsrom", "Calculations.Options.earliest.displayName": "Tiligst", "Calculations.Options.earliest.label": "Tiligst", "Calculations.Options.latest.displayName": "Senest", "Calculations.Options.latest.label": "Senest", "Calculations.Options.max.displayName": "Maks", "Calculations.Options.max.label": "Maks", "Calculations.Options.median.displayName": "Median", "Calculations.Options.median.label": "Median", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Kalkulèr", "Calculations.Options.none.label": "Ingen", "Calculations.Options.percentChecked.displayName": "Valgt", "Calculations.Options.percentChecked.label": "Prosent valgt", "Calculations.Options.percentUnchecked.displayName": "Ikke valgt", "Calculations.Options.percentUnchecked.label": "Prosent ikke valgt", "Calculations.Options.range.displayName": "Tidsrom", "Calculations.Options.range.label": "Tidsrom", "Calculations.Options.sum.displayName": "Sum", "Calculations.Options.sum.label": "Sum", "CalendarCard.untitled": "Uten navn", "CardActionsMenu.copiedLink": "Kopiert!", "CardActionsMenu.copyLink": "Kopier lenke", "CardActionsMenu.delete": "Slett", "CardActionsMenu.duplicate": "Dupliser", "CardBadges.title-checkboxes": "Avkrysningsbokser", "CardBadges.title-comments": "Kommentarer", "CardBadges.title-description": "Dette kortet har en beskrivelsestekst", "CardDetail.Attach": "Legg ved", "CardDetail.Follow": "Følg", "CardDetail.Following": "Følger", "CardDetail.add-content": "Legg til innhold", "CardDetail.add-icon": "Legg til ikon", "CardDetail.add-property": "+ Legg til en verdi", "CardDetail.addCardText": "legg inn tekst i kortet", "CardDetail.limited-body": "Oppgrader til vår profesjonelle eller bedriftsplan.", "CardDetail.limited-button": "Oppgrader", "CardDetail.limited-title": "Dette kortet er skjult", "CardDetail.moveContent": "Flytt innholdet", "CardDetail.new-comment-placeholder": "Legg til kommentar ...", "CardDetailProperty.confirm-delete-heading": "Bekreft sletting av verdi", "CardDetailProperty.confirm-delete-subtext": "Er du sikker på at du vil slette verdien \"{propertyName}\"? Dette vil fjerne verdien fra alle kortene på denne tavlen.", "CardDetailProperty.confirm-property-name-change-subtext": "Er du sikker på at du vil endre verdien \"{propertyName}\" {customText}? Dette vil påvirke verdien på {numOfCards} kort på denne tavlen, og kan forårsake at du mister informasjon.", "CardDetailProperty.confirm-property-type-change": "Bekreft endring av verditype", "CardDetailProperty.delete-action-button": "Slett", "CardDetailProperty.property-change-action-button": "Endre verdi", "CardDetailProperty.property-changed": "Verdi endret!", "CardDetailProperty.property-deleted": "Fjernet {propertyName}!", "CardDetailProperty.property-name-change-subtext": "type fra \"{oldPropType}\" til \"{newPropType}\"", "CardDetial.limited-link": "Lær mer om våre planer.", "CardDialog.delete-confirmation-dialog-attachment": "Bekreft sletting av vedlegg", "CardDialog.delete-confirmation-dialog-button-text": "Slett", "CardDialog.delete-confirmation-dialog-heading": "Bekreft sletting av kort", "CardDialog.editing-template": "Du redigerer en mal.", "CardDialog.nocard": "Dette kortet eksisterer ikke eller du har ikke tilgang.", "Categories.CreateCategoryDialog.CancelText": "Avbryt", "Categories.CreateCategoryDialog.CreateText": "Opprett", "Categories.CreateCategoryDialog.Placeholder": "Navngi kategorien", "Categories.CreateCategoryDialog.UpdateText": "Oppdater", "CenterPanel.Login": "Logg inn", "CenterPanel.Share": "Del", "ChannelIntro.CreateBoard": "Opprett tavle", "ColorOption.selectColor": "Velg {color} farge", "Comment.delete": "Slett", "CommentsList.send": "Send", "ConfirmPerson.empty": "Tom", "ConfirmPerson.search": "Søk ...", "ConfirmationDialog.cancel-action": "Avbryt", "ConfirmationDialog.confirm-action": "Bekreft", "ContentBlock.Delete": "Slett", "ContentBlock.DeleteAction": "slett", "ContentBlock.addElement": "legg til {type}", "ContentBlock.checkbox": "avkrysningsboks", "ContentBlock.divider": "avdeler", "ContentBlock.editCardCheckbox": "krysset-boks", "ContentBlock.editCardCheckboxText": "rediger kort tekst", "ContentBlock.editCardText": "rediger kort tekst", "ContentBlock.editText": "Rediger tekst ...", "ContentBlock.image": "bilde", "ContentBlock.insertAbove": "Sett inn over", "ContentBlock.moveBlock": "flytt kort innhold", "ContentBlock.moveDown": "Flytt ned", "ContentBlock.moveUp": "Flytt opp", "ContentBlock.text": "tekst", "DateRange.clear": "Tøm", "DateRange.empty": "Tom", "DateRange.endDate": "Sluttdato", "DateRange.today": "I dag", "DeleteBoardDialog.confirm-cancel": "Avbryt", "DeleteBoardDialog.confirm-delete": "Slett", "DeleteBoardDialog.confirm-info": "Er du sikker på at du vil slette tavlen \"{boardTitle}\"? Dette vil slette alle kortene på tavlen.", "DeleteBoardDialog.confirm-info-template": "Er du sikker på at du vil slette tavlemalen \"{boardTitle}\"?", "DeleteBoardDialog.confirm-tite": "Bekreft sletting av tavle", "DeleteBoardDialog.confirm-tite-template": "Bekreft sletting av tavlemal", "Dialog.closeDialog": "Lukk", "EditableDayPicker.today": "I dag", "Error.mobileweb": "Støtte for bruk i nettleser på mobil er i tidlig beta. Alt vil ikke fungere.", "Error.websocket-closed": "Problemer med kobling til tjeneren. Sjekk konfigurasjonen hvis problemet vedvarer.", "Filter.contains": "inneholder", "Filter.ends-with": "ender med", "Filter.includes": "inkluderer", "Filter.is": "er", "Filter.is-empty": "er tom", "Filter.is-not-empty": "er ikke tom", "Filter.is-not-set": "er ikke satt", "Filter.is-set": "er satt", "Filter.not-contains": "inkluderer ikke", "Filter.not-ends-with": "ender ikke med", "Filter.not-includes": "inkluderer ikke", "Filter.not-starts-with": "starter ikke med", "Filter.starts-with": "starter med", "FilterByText.placeholder": "filtrer tekst", "FilterComponent.add-filter": "+ Nytt filter", "FilterComponent.delete": "Slett", "FilterValue.empty": "(tom)", "FindBoardsDialog.IntroText": "Søk etter tavle", "FindBoardsDialog.NoResultsFor": "Ingen resultat for \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Sjekk stavingen eller søk på noe annet.", "FindBoardsDialog.SubTitle": "Skriv for å finne en tavle. Bruk opp/ned for å navigere. Enter for å velge, eller Esc for å avbryte", "FindBoardsDialog.Title": "Finn tavle", "GroupBy.hideEmptyGroups": "Skjul {count} tomme grupper", "GroupBy.showHiddenGroups": "Vis {count} tomme grupper", "GroupBy.ungroup": "Fjern fra gruppe", "HideBoard.MenuOption": "Skjul tavlen", "KanbanCard.untitled": "Uten navn", "Mutator.new-board-from-template": "ny tavle fra mal", "Mutator.new-card-from-template": "nytt kort fra mal", "Mutator.new-template-from-card": "ny mal fra kort" } ================================================ FILE: webapp/i18n/nl.json ================================================ { "AppBar.Tooltip": "Gekoppelde borden weergeven", "Attachment.Attachment-title": "Bijlage", "AttachmentBlock.DeleteAction": "verwijderen", "AttachmentBlock.addElement": "voeg {type} toe", "AttachmentBlock.delete": "Bijlage verwijderd.", "AttachmentBlock.failed": "Dit bestand kon niet worden geüpload omdat de bestandslimiet wordt overschreden.", "AttachmentBlock.upload": "Bijlage aan het uploaden.", "AttachmentBlock.uploadSuccess": "Bijlage geüpload.", "AttachmentElement.delete-confirmation-dialog-button-text": "Verwijderen", "AttachmentElement.download": "Downloaden", "AttachmentElement.upload-percentage": "Uploaden...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Een groep toevoegen", "BoardComponent.delete": "Verwijderen", "BoardComponent.hidden-columns": "Verborgen kolommen", "BoardComponent.hide": "Verberg", "BoardComponent.new": "+ Nieuw", "BoardComponent.no-property": "Geen {property}", "BoardComponent.no-property-title": "Items met een lege {property} eigenschap komen hier te staan. Deze kolom kan niet worden verwijderd.", "BoardComponent.show": "Toon", "BoardMember.schemeAdmin": "Beheerder", "BoardMember.schemeCommenter": "Commentator", "BoardMember.schemeEditor": "Bewerker", "BoardMember.schemeNone": "Geen", "BoardMember.schemeViewer": "Toeschouwer", "BoardMember.unlinkChannel": "Losmaken", "BoardPage.newVersion": "Er is een nieuwe versie van Boards, klik hier om te herladen.", "BoardPage.syncFailed": "Het bord kan worden verwijderd of de toegang kan worden ingetrokken.", "BoardTemplateSelector.add-template": "Nieuwe sjabloon aanmaken", "BoardTemplateSelector.create-empty-board": "Maak een leeg bord", "BoardTemplateSelector.delete-template": "Verwijderen", "BoardTemplateSelector.description": "Voeg een bord aan de zijbalk door één van onderstaande sjabloon te gebruiken of start helemaal vanaf nul.", "BoardTemplateSelector.edit-template": "Bewerken", "BoardTemplateSelector.plugin.no-content-description": "Voeg een bord aan de zijbalk door één van onderstaande sjabloon te gebruiken of start helemaal vanaf nul.", "BoardTemplateSelector.plugin.no-content-title": "Een bord aanmaken", "BoardTemplateSelector.title": "Maak een board", "BoardTemplateSelector.use-this-template": "Gebruik dit sjabloon", "BoardsSwitcher.Title": "Boards vinden", "BoardsUnfurl.Limited": "Extra details zijn verborgen omdat de kaart gearchiveerd is", "BoardsUnfurl.Remainder": "+{remainder} meer", "BoardsUnfurl.Updated": "Bijgewerkt {time}", "Calculations.Options.average.displayName": "Gemiddeld", "Calculations.Options.average.label": "Gemiddeld", "Calculations.Options.count.displayName": "Aantal", "Calculations.Options.count.label": "Aantal", "Calculations.Options.countChecked.displayName": "Aangevinkt", "Calculations.Options.countChecked.label": "Aantal aangevinkt", "Calculations.Options.countUnchecked.displayName": "Niet aangevinkt", "Calculations.Options.countUnchecked.label": "Aantal niet aangevinkt", "Calculations.Options.countUniqueValue.displayName": "Uniek", "Calculations.Options.countUniqueValue.label": "Tel unieke waarden", "Calculations.Options.countValue.displayName": "Waarden", "Calculations.Options.countValue.label": "Aantal waarden", "Calculations.Options.dateRange.displayName": "Bereik", "Calculations.Options.dateRange.label": "Bereik", "Calculations.Options.earliest.displayName": "Vroegste", "Calculations.Options.earliest.label": "Vroegste", "Calculations.Options.latest.displayName": "Laatste", "Calculations.Options.latest.label": "Laatste", "Calculations.Options.max.displayName": "Maximum", "Calculations.Options.max.label": "Maximum", "Calculations.Options.median.displayName": "Mediaan", "Calculations.Options.median.label": "Mediaan", "Calculations.Options.min.displayName": "Minimum", "Calculations.Options.min.label": "Minimum", "Calculations.Options.none.displayName": "Bereken", "Calculations.Options.none.label": "Geen", "Calculations.Options.percentChecked.displayName": "Aangevinkt", "Calculations.Options.percentChecked.label": "Percentage aangevinkt", "Calculations.Options.percentUnchecked.displayName": "Niet aangevinkt", "Calculations.Options.percentUnchecked.label": "Percentage niet aangevinkt", "Calculations.Options.range.displayName": "Bereik", "Calculations.Options.range.label": "Bereik", "Calculations.Options.sum.displayName": "Som", "Calculations.Options.sum.label": "Som", "CalendarCard.untitled": "Titelloos", "CardActionsMenu.copiedLink": "Gekopieerd!", "CardActionsMenu.copyLink": "Kopieer link", "CardActionsMenu.delete": "Verwijderen", "CardActionsMenu.duplicate": "Dupliceren", "CardBadges.title-checkboxes": "Selectievakjes", "CardBadges.title-comments": "Opmerkingen", "CardBadges.title-description": "Deze kaart heeft een beschrijving", "CardDetail.Attach": "Toevoegen", "CardDetail.Follow": "Volgen", "CardDetail.Following": "Volgend", "CardDetail.add-content": "Inhoud toevoegen", "CardDetail.add-icon": "Pictogram toevoegen", "CardDetail.add-property": "+ Een eigenschap toevoegen", "CardDetail.addCardText": "kaarttekst toevoegen", "CardDetail.limited-body": "Upgrade naar ons Professional- of Enterprise-plan.", "CardDetail.limited-button": "Upgraden", "CardDetail.limited-title": "Deze kaart is verborgen", "CardDetail.moveContent": "Inhoud van de kaart verplaatsen", "CardDetail.new-comment-placeholder": "Voeg commentaar toe...", "CardDetailProperty.confirm-delete-heading": "Bevestig verwijderen eigenschap", "CardDetailProperty.confirm-delete-subtext": "Weet je zeker dat je de eigenschap \"{propertyName}\" wilt verwijderen? Dit verwijderen zal de eigenschap van alle kaarten in dit bord verwijderen.", "CardDetailProperty.confirm-property-name-change-subtext": "Weet je zeker dat je de eigenschap \"{propertyName}\" {customText} wilt wijzigen? Dit zal invloed hebben op de waarde(n) op de {numOfCards} kaart(en) in dit bord, en kan resulteren in data verlies.", "CardDetailProperty.confirm-property-type-change": "Bevestig wijziging type eigenschap", "CardDetailProperty.delete-action-button": "Verwijderen", "CardDetailProperty.property-change-action-button": "Wijzig eigenschap", "CardDetailProperty.property-changed": "Eigenschap succesvol gewijzigd!", "CardDetailProperty.property-deleted": "{propertyName} werd succesvol verwijderd!", "CardDetailProperty.property-name-change-subtext": "type van \"{oldPropType}\" naar \"{newPropType}\"", "CardDetial.limited-link": "Meer informatie over onze plannen.", "CardDialog.delete-confirmation-dialog-attachment": "Bevestig het verwijderen van de bijlage", "CardDialog.delete-confirmation-dialog-button-text": "Verwijderen", "CardDialog.delete-confirmation-dialog-heading": "Bevestig verwijderen kaart", "CardDialog.editing-template": "Je bent een sjabloon aan het bewerken.", "CardDialog.nocard": "Deze kaart bestaat niet of is ontoegankelijk.", "Categories.CreateCategoryDialog.CancelText": "Annuleren", "Categories.CreateCategoryDialog.CreateText": "Aanmaken", "Categories.CreateCategoryDialog.Placeholder": "Geef je categorie een naam", "Categories.CreateCategoryDialog.UpdateText": "Bijwerken", "CenterPanel.Login": "Aanmelden", "CenterPanel.Share": "Delen", "ChannelIntro.CreateBoard": "Een bord aanmaken", "ColorOption.selectColor": "Selecteer {color} Kleur", "Comment.delete": "Verwijderen", "CommentsList.send": "Verzenden", "ConfirmPerson.empty": "Leeg", "ConfirmPerson.search": "Zoeken...", "ConfirmationDialog.cancel-action": "Annuleren", "ConfirmationDialog.confirm-action": "Bevestigen", "ContentBlock.Delete": "Verwijderen", "ContentBlock.DeleteAction": "verwijderen", "ContentBlock.addElement": "voeg {type} toe", "ContentBlock.checkbox": "selectievakje", "ContentBlock.divider": "verdeler", "ContentBlock.editCardCheckbox": "Aangevinkt selectievakje", "ContentBlock.editCardCheckboxText": "kaarttekst bewerken", "ContentBlock.editCardText": "kaarttekst bewerken", "ContentBlock.editText": "Tekst bewerken...", "ContentBlock.image": "afbeelding", "ContentBlock.insertAbove": "Hierboven invoegen", "ContentBlock.moveBlock": "inhoud van de kaart verplaatsen", "ContentBlock.moveDown": "Naar beneden verplaatsen", "ContentBlock.moveUp": "Naar boven verplaatsen", "ContentBlock.text": "tekst", "DateRange.clear": "Wissen", "DateRange.empty": "Leeg", "DateRange.endDate": "Einddatum", "DateRange.today": "Vandaag", "DeleteBoardDialog.confirm-cancel": "Annuleren", "DeleteBoardDialog.confirm-delete": "Verwijderen", "DeleteBoardDialog.confirm-info": "Weet je zeker dat u het bord \"{boardTitle}\" wil verwijderen? Het verwijderen van het bord zal alle kaarten in het bord verwijderen.", "DeleteBoardDialog.confirm-info-template": "Weet je zeker dat je het boardsjabloon \"{boardTitle}\" wilt verwijderen?", "DeleteBoardDialog.confirm-tite": "Bevestig verwijderen board", "DeleteBoardDialog.confirm-tite-template": "Bevestig verwijderen Board-sjabloon", "Dialog.closeDialog": "Dialoogvenster sluiten", "EditableDayPicker.today": "Vandaag", "Error.mobileweb": "Mobiele webondersteuning is momenteel in vroege beta. Het is mogelijk dat niet alle functionaliteit aanwezig is.", "Error.websocket-closed": "Websocketverbinding gesloten, verbinding onderbroken. Als dit aanhoudt, controleer dan jouw server of web proxy configuratie.", "Filter.contains": "bevat", "Filter.ends-with": "eindigt met", "Filter.includes": "bevat", "Filter.is": "is", "Filter.is-empty": "is leeg", "Filter.is-not-empty": "is niet leeg", "Filter.is-not-set": "is niet ingesteld", "Filter.is-set": "is ingesteld", "Filter.not-contains": "bevat niet", "Filter.not-ends-with": "eindigt niet met", "Filter.not-includes": "bevat niet", "Filter.not-starts-with": "begint niet met", "Filter.starts-with": "begint met", "FilterByText.placeholder": "filtertekst", "FilterComponent.add-filter": "+ Filter toevoegen", "FilterComponent.delete": "Verwijderen", "FilterValue.empty": "(leeg)", "FindBoardsDialog.IntroText": "Zoeken naar borden", "FindBoardsDialog.NoResultsFor": "Geen resultaten voor \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Controleer de spelling of probeer een andere zoekopdracht.", "FindBoardsDialog.SubTitle": "Typ om een bord te vinden. Gebruik UP/DOWN om te bladeren. ENTER om te selecteren, ESC om te annuleren", "FindBoardsDialog.Title": "Boards vinden", "GroupBy.hideEmptyGroups": "Verberg {count} lege groepen", "GroupBy.showHiddenGroups": "Toon {count} verborgen groepen", "GroupBy.ungroup": "Groeperen stoppen", "HideBoard.MenuOption": "Bord verbergen", "KanbanCard.untitled": "Titelloos", "MentionSuggestion.is-not-board-member": "Geen deelnemer van het bord", "Mutator.new-board-from-template": "nieuw board van sjabloon", "Mutator.new-card-from-template": "nieuwe kaart van sjabloon", "Mutator.new-template-from-card": "nieuw sjabloon van kaart", "OnboardingTour.AddComments.Body": "Je kunt commentaar geven op onderwerpen, en zelfs je medeMattermostgebruikers @vermelden om hun aandacht te trekken.", "OnboardingTour.AddComments.Title": "Opmerkingen toevoegen", "OnboardingTour.AddDescription.Body": "Voeg een beschrijving toe aan je kaart, zodat je teamgenoten weten waar de kaart over gaat.", "OnboardingTour.AddDescription.Title": "Beschrijving toevoegen", "OnboardingTour.AddProperties.Body": "Voeg verschillende eigenschappen toe aan kaarten om ze effectiever te maken.", "OnboardingTour.AddProperties.Title": "Eigenschappen toevoegen", "OnboardingTour.AddView.Body": "Kom hier naartoe om een nieuwe weergave te maken om uw bord te organiseren met verschillende lay-outs.", "OnboardingTour.AddView.Title": "Een nieuwe weergave toevoegen", "OnboardingTour.CopyLink.Body": "Je kunt je kaarten delen met teamgenoten door de link te kopiëren en in een kanaal, direct bericht of groepsbericht te plakken.", "OnboardingTour.CopyLink.Title": "Link kopiëren", "OnboardingTour.OpenACard.Body": "Open een kaart om de krachtige manieren te ontdekken waarop Boards je kunnen helpen je werk te organiseren.", "OnboardingTour.OpenACard.Title": "Open een kaart", "OnboardingTour.ShareBoard.Body": "Je kan jouw bord intern delen, binnen jouw team, of het publiek publiceren voor zichtbaarheid buiten jouw organisatie.", "OnboardingTour.ShareBoard.Title": "Bord delen", "PersonProperty.board-members": "Deelnemers aan het bord", "PersonProperty.me": "Ik", "PersonProperty.non-board-members": "Niet-deelnemers aan het bord", "PropertyMenu.Delete": "Verwijderen", "PropertyMenu.changeType": "Type eigenschap wijzigen", "PropertyMenu.selectType": "Selecteer type eigenschap", "PropertyMenu.typeTitle": "Type", "PropertyType.Checkbox": "Selectievakje", "PropertyType.CreatedBy": "Gemaakt door", "PropertyType.CreatedTime": "Aangemaakt op", "PropertyType.Date": "Datum", "PropertyType.Email": "E-mail", "PropertyType.MultiPerson": "Meerdere personen", "PropertyType.MultiSelect": "Multiselect", "PropertyType.Number": "Nummer", "PropertyType.Person": "Persoon", "PropertyType.Phone": "Telefoon", "PropertyType.Select": "Selecteer", "PropertyType.Text": "Tekst", "PropertyType.Unknown": "Onbekend", "PropertyType.UpdatedBy": "Laatst aangepast door", "PropertyType.UpdatedTime": "Laatst bijgewerkte tijd", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Leeg", "RegistrationLink.confirmRegenerateToken": "Dit zal eerder gedeelde links ongeldig maken. Doorgaan?", "RegistrationLink.copiedLink": "Gekopieerd!", "RegistrationLink.copyLink": "Kopieer link", "RegistrationLink.description": "Deel deze link zodat anderen een account kunnen aanmaken:", "RegistrationLink.regenerateToken": "Token opnieuw genereren", "RegistrationLink.tokenRegenerated": "Registratielink heraangemaakt", "ShareBoard.PublishDescription": "Publiceer en deel een \"alleen-lezen\" link met iedereen op het web.", "ShareBoard.PublishTitle": "Publiceren op het web", "ShareBoard.ShareInternal": "Intern delen", "ShareBoard.ShareInternalDescription": "Gebruikers die toegangsrechten hebben, kunnen deze link gebruiken.", "ShareBoard.Title": "Bord delen", "ShareBoard.confirmRegenerateToken": "Dit zal eerder gedeelde links ongeldig maken. Doorgaan?", "ShareBoard.copiedLink": "Gekopieerd!", "ShareBoard.copyLink": "Link kopiëren", "ShareBoard.regenerate": "Token opnieuw genereren", "ShareBoard.searchPlaceholder": "Mensen en kanalen zoeken", "ShareBoard.teamPermissionsText": "Iedereen van team {teamName}", "ShareBoard.tokenRegenrated": "Token opnieuw gegenereerd", "ShareBoard.userPermissionsRemoveMemberText": "Lid verwijderen", "ShareBoard.userPermissionsYouText": "(jij)", "ShareTemplate.Title": "Sjabloon delen", "ShareTemplate.searchPlaceholder": "Mensen zoeken", "Sidebar.about": "Over Focalboard", "Sidebar.add-board": "+ Bord toevoegen", "Sidebar.changePassword": "Wachtwoord wijzigen", "Sidebar.delete-board": "Verwijder bord", "Sidebar.duplicate-board": "Board dupliceren", "Sidebar.export-archive": "Archief exporteren", "Sidebar.import": "Importeren", "Sidebar.import-archive": "Archief importeren", "Sidebar.invite-users": "Gebruikers uitnodigen", "Sidebar.logout": "Afmelden", "Sidebar.new-category.badge": "Nieuw", "Sidebar.new-category.drag-boards-cta": "Sleep borden naar hier...", "Sidebar.no-boards-in-category": "Geen boards hier", "Sidebar.product-tour": "Product-rondleiding", "Sidebar.random-icons": "Willekeurige iconen", "Sidebar.set-language": "Taal instellen", "Sidebar.set-theme": "Thema instellen", "Sidebar.settings": "Instellingen", "Sidebar.template-from-board": "Nieuw sjabloon van board", "Sidebar.untitled-board": "(Titelloze bord )", "Sidebar.untitled-view": "(Naamloze weergave)", "SidebarCategories.BlocksMenu.Move": "Verplaatsen naar...", "SidebarCategories.CategoryMenu.CreateNew": "Maak een nieuwe categorie", "SidebarCategories.CategoryMenu.Delete": "Categorie verwijderen", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Borden in {categoryName} zullen terug verhuizen naar de Boards categorieën. Je zal niet verwijderd worden uit enig board.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Deze categorie verwijderen?", "SidebarCategories.CategoryMenu.Update": "Categorie hernoemen", "SidebarTour.ManageCategories.Body": "Maak en beheer aangepaste categorieën. Categorieën zijn gebruikersspecifiek, dus het verplaatsen van een bord naar jouw categorie heeft geen invloed op andere leden die hetzelfde bord gebruiken.", "SidebarTour.ManageCategories.Title": "Categorieën beheren", "SidebarTour.SearchForBoards.Body": "Open de bordenswitcher (Cmd/Ctrl + K) om snel borden te zoeken en toe te voegen aan je zijbalk.", "SidebarTour.SearchForBoards.Title": "Borden zoeken", "SidebarTour.SidebarCategories.Body": "Al je borden zijn nu georganiseerd in je nieuwe zijbalk. Niet meer schakelen tussen werkruimten. Eenmalige zelfgemaakte categorieën gebaseerd op jouw vorige workspaces kunnen automatisch gemaakt zijn voor jou als onderdeel van jouw v7.2 upgrade. Deze kunnen worden verwijderd of aangepast aan jouw voorkeur.", "SidebarTour.SidebarCategories.Link": "Meer info", "SidebarTour.SidebarCategories.Title": "Zijbalk categorieën", "SiteStats.total_boards": "Totaal aantal borden", "SiteStats.total_cards": "Totaal aantal kaarten", "TableComponent.add-icon": "Pictogram toevoegen", "TableComponent.name": "Naam", "TableComponent.plus-new": "+ Nieuw", "TableHeaderMenu.delete": "Verwijderen", "TableHeaderMenu.duplicate": "Kopiëren", "TableHeaderMenu.hide": "Verberg", "TableHeaderMenu.insert-left": "Links invoegen", "TableHeaderMenu.insert-right": "Rechts invoegen", "TableHeaderMenu.sort-ascending": "Sorteer oplopend", "TableHeaderMenu.sort-descending": "Aflopend sorteren", "TableRow.DuplicateCard": "dupliceren kaart", "TableRow.MoreOption": "Meer acties", "TableRow.open": "Openen", "TopBar.give-feedback": "Geef feedback", "URLProperty.copiedLink": "Gekopieerd!", "URLProperty.copy": "Kopiëren", "URLProperty.edit": "Bewerken", "UndoRedoHotKeys.canRedo": "Herhaal", "UndoRedoHotKeys.canRedo-with-description": "Herhaal {description}", "UndoRedoHotKeys.canUndo": "Ongedaan maken", "UndoRedoHotKeys.canUndo-with-description": "Ongedaan maken van {description}", "UndoRedoHotKeys.cannotRedo": "Niets om te herhalen", "UndoRedoHotKeys.cannotUndo": "Niets om ongedaan te maken", "ValueSelector.noOptions": "Geen opties. Begin te typen om de eerste toe te voegen!", "ValueSelector.valueSelector": "Waardekiezer", "ValueSelectorLabel.openMenu": "Menu openen", "VersionMessage.help": "Bekijk eens wat nieuw is in deze versie.", "View.AddView": "Weergave toevoegen", "View.Board": "Bord", "View.DeleteView": "Weergave verwijderen", "View.DuplicateView": "Weergave kopiëren", "View.Gallery": "Galerij", "View.NewBoardTitle": "Bordweergave", "View.NewCalendarTitle": "Kalenderweergave", "View.NewGalleryTitle": "Galerie bekijken", "View.NewTableTitle": "Tabelweergave", "View.NewTemplateDefaultTitle": "Naamloos sjabloon", "View.NewTemplateTitle": "Naamloos", "View.Table": "Tabel", "ViewHeader.add-template": "Nieuw sjabloon", "ViewHeader.delete-template": "Verwijderen", "ViewHeader.display-by": "Weergegeven op: {property}", "ViewHeader.edit-template": "Bewerken", "ViewHeader.empty-card": "Lege kaart", "ViewHeader.export-board-archive": "Archief bord exporteren", "ViewHeader.export-complete": "Exporteren gelukt!", "ViewHeader.export-csv": "Exporteren naar CSV", "ViewHeader.export-failed": "Export mislukt!", "ViewHeader.filter": "Filter", "ViewHeader.group-by": "Groepeer op: {property}", "ViewHeader.new": "Nieuw", "ViewHeader.properties": "Eigenschappen", "ViewHeader.properties-menu": "Eigenschappen-menu", "ViewHeader.search-text": "Kaarten zoeken", "ViewHeader.select-a-template": "Kies een sjabloon", "ViewHeader.set-default-template": "Instellen als standaard", "ViewHeader.sort": "Sorteer", "ViewHeader.untitled": "Titelloos", "ViewHeader.view-header-menu": "Menu hoofding weergeven", "ViewHeader.view-menu": "Menuweergave", "ViewLimitDialog.Heading": "Limiet aantal views per board bereikt", "ViewLimitDialog.PrimaryButton.Title.Admin": "Upgraden", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Verwittig Admin", "ViewLimitDialog.Subtext.Admin": "Upgrade naar ons Professional- of Enterprise-plan.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Meer informatie over onze plannen.", "ViewLimitDialog.Subtext.RegularUser": "Verwittig jouw Admin om te upgraden naar ons Professioneel of Enterprise plan.", "ViewLimitDialog.UpgradeImg.AltText": "upgrade afbeelding", "ViewLimitDialog.notifyAdmin.Success": "Jouw beheerder is op de hoogte gebracht", "ViewTitle.hide-description": "beschrijving verbergen", "ViewTitle.pick-icon": "Pictogram kiezen", "ViewTitle.random-icon": "Willekeurig", "ViewTitle.remove-icon": "Verwijder pictogram", "ViewTitle.show-description": "beschrijving tonen", "ViewTitle.untitled-board": "Titelloos board", "WelcomePage.Description": "Boards is een projectmanagementtool die helpt bij het definiëren, organiseren, volgen en beheren van werk door teams heen, met behulp van een bekende Kanban-bordweergave.", "WelcomePage.Explore.Button": "Start een rondleiding", "WelcomePage.Heading": "Welkom bij Boards", "WelcomePage.NoThanks.Text": "Nee bedankt, ik zoek het zelf wel uit", "WelcomePage.StartUsingIt.Text": "Ga het gebruiken", "Workspace.editing-board-template": "Je bent een bordsjabloon aan het bewerken.", "badge.guest": "Gast", "boardSelector.confirm-link-board": "Koppel bord aan kanaal", "boardSelector.confirm-link-board-button": "Ja, koppel het bord", "boardSelector.confirm-link-board-subtext": "Wanneer je \"{boardName}\" aan het kanaal koppelt, kunnen alle leden van het kanaal (bestaande en nieuwe) het bewerken. Dit sluit leden die gast zijn uit. Je kan de koppeling van een bord naar een kanaal op elk moment ongedaan maken.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Wanneer je \"{boardName}\" aan het kanaal koppelt zullen alle leden van het kanaal (bestaande en nieuwe) het kunnen bewerken. Dit sluit leden die gast zijn uit. {lineBreak} Dit board is momenteel gekoppeld aan een ander kanaal. Het zal worden ontkoppeld als je ervoor kiest om het hier te koppelen.", "boardSelector.create-a-board": "Maak een bord", "boardSelector.link": "Link", "boardSelector.search-for-boards": "Zoeken naar borden", "boardSelector.title": "Link borden", "boardSelector.unlink": "Link ongedaan maken", "calendar.month": "Maand", "calendar.today": "VANDAAG", "calendar.week": "Week", "centerPanel.undefined": "Geen {propertyName}", "centerPanel.unknown-user": "Onbekende gebruiker", "cloudMessage.learn-more": "Meer info", "createImageBlock.failed": "Dit bestand kon niet worden geüpload omdat de bestandslimiet wordt overschreden.", "default-properties.badges": "Opmerkingen en beschrijving", "default-properties.title": "Titel", "error.back-to-home": "Terug naar startpagina", "error.back-to-team": "Terug naar team", "error.board-not-found": "Board niet gevonden.", "error.go-login": "Aanmelden", "error.invalid-read-only-board": "Je hebt geen toegang tot dit board. Meld je aan om toegang te krijgen tot Boards.", "error.not-logged-in": "Jouw sessie is misschien verlopen of je bent niet ingelogd. Meldt je opnieuw aan om toegang te krijgen tot Boards.", "error.page.title": "Sorry, er ging iets mis", "error.team-undefined": "Geen geldig team.", "error.unknown": "Er trad een fout op.", "generic.previous": "Vorige", "guest-no-board.subtitle": "Je hebt nog geen toegang tot een board in dit team, wacht tot iemand je toevoegt aan een board.", "guest-no-board.title": "Nog geen borden", "imagePaste.upload-failed": "Sommige bestanden zijn niet geüpload omdat de limiet voor de bestandsgrootte is bereikt.", "limitedCard.title": "Verborgen kaarten", "login.log-in-button": "Aanmelden", "login.log-in-title": "Aanmelden", "login.register-button": "of maak een account aan als je er nog geen hebt", "new_channel_modal.create_board.empty_board_description": "Maak een nieuw leeg bord", "new_channel_modal.create_board.empty_board_title": "Leeg bord", "new_channel_modal.create_board.select_template_placeholder": "Kies een sjabloon", "new_channel_modal.create_board.title": "Maak een bord voor dit kanaal", "notification-box-card-limit-reached.close-tooltip": "Snooze voor 10 dagen", "notification-box-card-limit-reached.contact-link": "breng je beheerder op de hoogte", "notification-box-card-limit-reached.link": "Upgrade naar een betaald plan", "notification-box-card-limit-reached.title": "{cards} kaarten verborgen van board", "notification-box-cards-hidden.title": "Deze actie heeft een andere kaart verborgen", "notification-box.card-limit-reached.not-admin.text": "Om toegang te krijgen tot gearchiveerde kaarten, neem contact op met {contactLink} om te upgraden naar een betaald plan.", "notification-box.card-limit-reached.text": "Limiet van aantal kaarten bereikt, om oudere kaarten te bekijken, {link}", "person.add-user-to-board": "{username} toevoegen aan bord", "person.add-user-to-board-confirm-button": "Toevoegen aan bord", "person.add-user-to-board-permissions": "Machtigingen", "person.add-user-to-board-question": "Wil je {username} toevoegen aan het bord?", "person.add-user-to-board-warning": "{username} is geen lid van het bord en zal er geen meldingen over ontvangen.", "register.login-button": "of meldt je aan als je al een account hebt", "register.signup-title": "Maak een nieuw account", "rhs-board-non-admin-msg": "Je bent geen beheerder van het bord", "rhs-boards.add": "Toevoegen", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "dit directe bericht", "rhs-boards.header.gm": "dit groepsbericht", "rhs-boards.last-update-at": "Laatste wijziging op: {datetime}", "rhs-boards.link-boards-to-channel": "Koppel borden aan {channelName}", "rhs-boards.linked-boards": "Gekoppelde borden", "rhs-boards.no-boards-linked-to-channel": "Er zijn nog geen borden gekoppeld aan {channelName}", "rhs-boards.no-boards-linked-to-channel-description": "Boards is een projectmanagementtool die helpt bij het definiëren, organiseren, volgen en beheren van werk door teams heen, met behulp van een bekende kanban-bordweergave.", "rhs-boards.unlink-board": "Bord loskoppelen", "rhs-boards.unlink-board1": "Bord loskoppelen", "rhs-channel-boards-header.title": "Boards", "share-board.publish": "Publiceren", "share-board.share": "Delen", "shareBoard.channels-select-group": "Kanalen", "shareBoard.confirm-change-team-role.body": "Iedereen op dit board met een lagere machtiging dan de \"{role}\" rol zal nu opwaarderen naar {role}. Weet je zeker dat je de minimale rol voor het board wilt veranderen?", "shareBoard.confirm-change-team-role.confirmBtnText": "Minimale rol van het bord wijzigen", "shareBoard.confirm-change-team-role.title": "Minimale rol van het bord wijzigen", "shareBoard.confirm-link-channel": "Bord koppelen aan kanaal", "shareBoard.confirm-link-channel-button": "Kanaal koppelen", "shareBoard.confirm-link-channel-button-with-other-channel": "Koppel en ontkoppel hier", "shareBoard.confirm-link-channel-subtext": "Wanneer je het bord aan het kanaal koppelt zullen alle leden van het kanaal (bestaande en nieuwe) het kunnen bewerken. Dit sluit leden die gast zijn uit.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Wanneer je een kanaal aan een bord koppelt zullen alle leden van het kanaal (bestaande en nieuwe) het kunnen bewerken.Dit sluit leden die gast zijn uit. {lineBreak} Dit board is momenteel gekoppeld aan een ander kanaal. Het zal worden ontkoppeld als je ervoor kiest om het hier te koppelen.", "shareBoard.confirm-unlink.body": "Wanneer een kanaal afkoppelt van een bord zullen alle leden van het kanaal (bestaande en nieuwe) geen toegang meer hebben tot ze apart toegang gegeven worden.", "shareBoard.confirm-unlink.confirmBtnText": "Kanaal ontkoppelen", "shareBoard.confirm-unlink.title": "Kanaal loskoppelen van bord", "shareBoard.lastAdmin": "Besturen moeten ten minste één beheerder hebben", "shareBoard.members-select-group": "Leden", "shareBoard.unknown-channel-display-name": "Onbekend kanaal", "tutorial_tip.finish_tour": "Klaar", "tutorial_tip.got_it": "Begrepen", "tutorial_tip.ok": "Volgende", "tutorial_tip.out": "Schakel deze tips uit.", "tutorial_tip.seen": "Heb je dit al gezien?" } ================================================ FILE: webapp/i18n/oc.json ================================================ { "BoardComponent.add-a-group": "+ Apondre un grop", "BoardComponent.delete": "Suprimir", "BoardComponent.hidden-columns": "Colomnas rescondudas", "BoardComponent.hide": "Rescondre", "BoardComponent.new": "+ Nòu", "BoardComponent.no-property": "Cap de {property}", "BoardComponent.no-property-title": "Los elements sens proprietats {property} seràn plaçats aquí. Se pòt pas suprimir aquesta colomna.", "BoardComponent.show": "Mostrar", "BoardPage.syncFailed": "Lo tablèu es benlèu suprimit o l’accès es revocat.", "BoardsUnfurl.Remainder": "+{remainder} de mai", "BoardsUnfurl.Updated": "Actualizat {time}", "CardDetail.add-content": "Apondre contengut", "CardDetail.add-icon": "Apondre una icòna", "CardDetail.add-property": "+ Apondre una proprietat", "CardDetail.addCardText": "apondre una zòna de tèxt", "CardDetail.moveContent": "desplaçar contengut de la carta", "CardDetail.new-comment-placeholder": "Apondre un comentari...", "CardDetailProperty.confirm-delete-subtext": "Volètz vertadièrament suprimir la proprietat « {propertyName} » ? La supression levarà la proprietat de totas las cartas d’aquesta tablèu.", "CardDetailProperty.property-deleted": "Supression de {propertyName} reüssida !", "CardDialog.editing-template": "Sètz a modificar un modèl.", "CardDialog.nocard": "Aquesta zòna existís pas o es pas accessibla.", "ColorOption.selectColor": "Seleccionar la color {color}", "Comment.delete": "Suprimir", "CommentsList.send": "Enviar", "ConfirmationDialog.cancel-action": "Anullar", "ContentBlock.Delete": "Suprimir", "ContentBlock.DeleteAction": "suprimir", "ContentBlock.addElement": "apondre {type}", "ContentBlock.checkbox": "cassa de marcar", "ContentBlock.divider": "separador", "ContentBlock.editCardCheckbox": "alternar-cassa", "ContentBlock.editCardCheckboxText": "modificar lo tèxt de la carta", "ContentBlock.editCardText": "modificar lo tèxt de la carta", "ContentBlock.editText": "Modificar lo tèxt...", "ContentBlock.image": "imatge", "ContentBlock.insertAbove": "Inserir al dessús", "ContentBlock.moveDown": "Desplaçar al dejós", "ContentBlock.moveUp": "Desplaçar al dessús", "ContentBlock.text": "tèxt", "Dialog.closeDialog": "Tampar la fenèstra de dialòg", "EditableDayPicker.today": "Uèi", "Error.websocket-closed": "Connexion al websocket tampada, connexion interrompuda. S’aquò ten de se produire, verificatz la configuracion del servidor o del servidor mandatari.", "Filter.includes": "inclutz", "Filter.is-empty": "es void", "Filter.is-not-empty": "es pas void", "Filter.not-includes": "inclutz pas", "FilterComponent.add-filter": "+ Apondre un filtre", "FilterComponent.delete": "Suprimir", "GroupBy.ungroup": "Desgropar", "KanbanCard.untitled": "Sens títol", "Mutator.new-card-from-template": "zòna novèla a partir d’un modèl", "Mutator.new-template-from-card": "modèl novèl a partir d’una zòna", "PropertyMenu.Delete": "Suprimir", "PropertyMenu.changeType": "Modificar lo tipe de proprietat", "PropertyMenu.selectType": "Seleccionar tipe de proprietat", "PropertyMenu.typeTitle": "Tipe", "PropertyType.Checkbox": "Casa de marcar", "PropertyType.CreatedBy": "Creat per", "PropertyType.CreatedTime": "Data de creacion", "PropertyType.Date": "Data", "PropertyType.Email": "Adreça e-mail", "PropertyType.MultiSelect": "Seleccion multipla", "PropertyType.Number": "Nombre", "PropertyType.Person": "Persona", "PropertyType.Phone": "Telefòn", "PropertyType.Select": "Lista", "PropertyType.Text": "Tèxt", "PropertyType.UpdatedBy": "Darrièra actualizacion per", "PropertyType.UpdatedTime": "Data de darrièra actualizacion", "PropertyValueElement.empty": "Void", "RegistrationLink.confirmRegenerateToken": "Aquò desactivarà los ligams de partiment existents. Contunhar ?", "RegistrationLink.copiedLink": "Copiat !", "RegistrationLink.copyLink": "Copiar lo ligam", "RegistrationLink.description": "Partejatz aqueste ligam per que d’autres pòscan crear un compte :", "RegistrationLink.regenerateToken": "Generar un geton novèl", "RegistrationLink.tokenRegenerated": "Un ligam novèl d’inscripcion es estat creat", "ShareBoard.confirmRegenerateToken": "Aquò desactivarà los ligams de partiment existents. Contunhar ?", "ShareBoard.copiedLink": "Copiat !", "ShareBoard.copyLink": "Copiar lo ligam", "ShareBoard.tokenRegenrated": "Geton regenerat", "Sidebar.about": "A prepaus de Focalboard", "Sidebar.add-board": "+ Apondre un tablèu", "Sidebar.changePassword": "Modificar lo senhal", "Sidebar.delete-board": "Suprimir lo tablèu", "Sidebar.export-archive": "Exportar un archiu", "Sidebar.import-archive": "Importar un archiu", "Sidebar.invite-users": "Convidar utilizaires", "Sidebar.logout": "Se desconnectar", "Sidebar.random-icons": "Icònas aleatòrias", "Sidebar.set-language": "Definir la lenga", "Sidebar.set-theme": "Causir lo tèma", "Sidebar.settings": "Paramètres", "Sidebar.untitled-board": "(Tablèu sens títol)", "TableComponent.add-icon": "Apondre una icòna", "TableComponent.name": "Nom", "TableComponent.plus-new": "+ Novèl", "TableHeaderMenu.delete": "Suprimir", "TableHeaderMenu.duplicate": "Duplicar", "TableHeaderMenu.hide": "Rescondre", "TableHeaderMenu.insert-left": "Inserir a esquèrra", "TableHeaderMenu.insert-right": "Inserir a drecha", "TableHeaderMenu.sort-ascending": "Tria ascendenta", "TableHeaderMenu.sort-descending": "Tria descendenta", "TableRow.open": "Dobrir", "TopBar.give-feedback": "Far un retorn", "ValueSelector.valueSelector": "Selector de valor", "ValueSelectorLabel.openMenu": "Dobrir lo menú", "View.AddView": "Apondre una vista", "View.Board": "Tablèu", "View.DeleteView": "Suprimir la vista", "View.DuplicateView": "Duplicar la vista", "View.Gallery": "Galariá", "View.NewBoardTitle": "Vista en tablèu", "View.NewGalleryTitle": "Vista galariá", "View.NewTableTitle": "Vista en taula", "View.Table": "Tablèu", "ViewHeader.add-template": "Modèl novèl", "ViewHeader.delete-template": "Suprimir", "ViewHeader.edit-template": "Modificar", "ViewHeader.empty-card": "Zòna voida", "ViewHeader.export-board-archive": "Exportar l’archiu del tablèu", "ViewHeader.export-complete": "Export acabat !", "ViewHeader.export-csv": "Exportar al format CSV", "ViewHeader.export-failed": "Export fracassat !", "ViewHeader.filter": "Filtre", "ViewHeader.group-by": "Agropar per : {property}", "ViewHeader.new": "Novèl", "ViewHeader.properties": "Proprietats", "ViewHeader.search-text": "Recercar de tèxt", "ViewHeader.select-a-template": "Seleccionar un modèl", "ViewHeader.set-default-template": "Definir per defaut", "ViewHeader.sort": "Triar", "ViewHeader.untitled": "Sens títol", "ViewTitle.hide-description": "rescondre la descripcion", "ViewTitle.pick-icon": "Causir una icòna", "ViewTitle.random-icon": "Aleatòria", "ViewTitle.remove-icon": "Suprimir l'icòna", "ViewTitle.show-description": "mostrar la descripcion", "ViewTitle.untitled-board": "Tablèu sens títol", "WelcomePage.Explore.Button": "Explorar", "WelcomePage.Heading": "La benvengudas als tablèus", "Workspace.editing-board-template": "Modificatz un modèl de tablèu.", "default-properties.title": "Títol", "login.log-in-button": "Connexion", "login.log-in-title": "Connexion", "login.register-button": "o creatz un compte se n’avètz pas un", "register.login-button": "o connectatz-vos s’avètz un compte", "register.signup-title": "Vos inscriure per aver un compte" } ================================================ FILE: webapp/i18n/pl.json ================================================ { "AdminBadge.SystemAdmin": "Administrator", "AdminBadge.TeamAdmin": "Administrator Zespołu", "AppBar.Tooltip": "Przełączanie Podlinkowanych Tablic", "Attachment.Attachment-title": "Załącznik", "AttachmentBlock.DeleteAction": "usuń", "AttachmentBlock.addElement": "dodaj {type}", "AttachmentBlock.delete": "Załącznik usunięty.", "AttachmentBlock.failed": "Ten plik nie mógł zostać przesłany, ponieważ został osiągnięty limit rozmiaru pliku.", "AttachmentBlock.upload": "Przesyłanie załączników.", "AttachmentBlock.uploadSuccess": "Załącznik przesłany.", "AttachmentElement.delete-confirmation-dialog-button-text": "Usuń", "AttachmentElement.download": "Pobierz", "AttachmentElement.upload-percentage": "Przesyłanie...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Dodaj grupę", "BoardComponent.delete": "Usuń", "BoardComponent.hidden-columns": "Ukryte kolumny", "BoardComponent.hide": "Ukryj", "BoardComponent.new": "+ Nowy", "BoardComponent.no-property": "Brak {property}", "BoardComponent.no-property-title": "Elementy z pustą właściwością {property} trafią tutaj. Tej kolumny nie można usunąć.", "BoardComponent.show": "Pokaż", "BoardMember.schemeAdmin": "Administrator", "BoardMember.schemeCommenter": "Komentujący", "BoardMember.schemeEditor": "Redaktor", "BoardMember.schemeNone": "Brak", "BoardMember.schemeViewer": "Obserwator", "BoardMember.unlinkChannel": "Odłącz", "BoardPage.newVersion": "Dostępna jest nowa wersja tablic. Naciśnij tutaj, aby przeładować.", "BoardPage.syncFailed": "Tablica mogła zostać usunięta lub dostęp do niej cofnięty.", "BoardTemplateSelector.add-template": "Utwórz nowy szablon", "BoardTemplateSelector.create-empty-board": "Utwórz pustą tablicę", "BoardTemplateSelector.delete-template": "Usuń", "BoardTemplateSelector.description": "Dodaj tablicę do paska bocznego używając dowolnego z poniższych szablonów lub zacznij od nowa.", "BoardTemplateSelector.edit-template": "Edytuj", "BoardTemplateSelector.plugin.no-content-description": "Dodaj tablicę do paska bocznego używając jednego z poniższych szablonów lub zacznij od nowa.", "BoardTemplateSelector.plugin.no-content-title": "Utwórz tablicę", "BoardTemplateSelector.title": "Utwórz tablicę", "BoardTemplateSelector.use-this-template": "Użyj tego szablonu", "BoardsSwitcher.Title": "Wyszukiwanie tablic", "BoardsUnfurl.Limited": "Dodatkowe szczegóły są ukryte ze względu na archiwizację karty", "BoardsUnfurl.Remainder": "+{remainder} więcej", "BoardsUnfurl.Updated": "Zaktualizowano {time}", "Calculations.Options.average.displayName": "Średnia", "Calculations.Options.average.label": "Średnia", "Calculations.Options.count.displayName": "Liczba", "Calculations.Options.count.label": "Liczba", "Calculations.Options.countChecked.displayName": "Zaznaczone", "Calculations.Options.countChecked.label": "Zaznaczono licznik", "Calculations.Options.countUnchecked.displayName": "Niezaznaczony", "Calculations.Options.countUnchecked.label": "Licznik niezaznaczony", "Calculations.Options.countUniqueValue.displayName": "Unikatowe", "Calculations.Options.countUniqueValue.label": "Policz wartości unikatowe", "Calculations.Options.countValue.displayName": "Wartości", "Calculations.Options.countValue.label": "Licznik wartości", "Calculations.Options.dateRange.displayName": "Zakres", "Calculations.Options.dateRange.label": "Zakres", "Calculations.Options.earliest.displayName": "Wcześniejszy", "Calculations.Options.earliest.label": "Wcześniejszy", "Calculations.Options.latest.displayName": "Ostatni", "Calculations.Options.latest.label": "Ostatni", "Calculations.Options.max.displayName": "Maks.", "Calculations.Options.max.label": "Maks.", "Calculations.Options.median.displayName": "Mediana", "Calculations.Options.median.label": "Mediana", "Calculations.Options.min.displayName": "Min.", "Calculations.Options.min.label": "Min.", "Calculations.Options.none.displayName": "Policz", "Calculations.Options.none.label": "Brak", "Calculations.Options.percentChecked.displayName": "Zaznaczony", "Calculations.Options.percentChecked.label": "Procent sprawdzonych", "Calculations.Options.percentUnchecked.displayName": "Niezaznaczony", "Calculations.Options.percentUnchecked.label": "Procent nie zaznaczonych", "Calculations.Options.range.displayName": "Zakres", "Calculations.Options.range.label": "Zakres", "Calculations.Options.sum.displayName": "Suma", "Calculations.Options.sum.label": "Suma", "CalendarCard.untitled": "Bez tytułu", "CardActionsMenu.copiedLink": "Skopiowano!", "CardActionsMenu.copyLink": "Kopiuj odnośnik", "CardActionsMenu.delete": "Usuń", "CardActionsMenu.duplicate": "Duplikuj", "CardBadges.title-checkboxes": "Pola wyboru", "CardBadges.title-comments": "Komentarze", "CardBadges.title-description": "Ta karta ma opis", "CardDetail.Attach": "Załącz", "CardDetail.Follow": "Obserwuj", "CardDetail.Following": "Obserwowane", "CardDetail.add-content": "Dodaj treść", "CardDetail.add-icon": "Dodaj ikonę", "CardDetail.add-property": "+ Dodaj właściwość", "CardDetail.addCardText": "dodaj tekst karty", "CardDetail.limited-body": "Uaktualnij do naszego planu Professional lub Enterprise.", "CardDetail.limited-button": "Zmień plan", "CardDetail.limited-title": "Ta karta jest ukryta", "CardDetail.moveContent": "Przenieś zawartość karty", "CardDetail.new-comment-placeholder": "Dodaj komentarz…", "CardDetailProperty.confirm-delete-heading": "Potwierdzanie usunięcia właściwości", "CardDetailProperty.confirm-delete-subtext": "Na pewno chcesz usunąć właściwość „{propertyName}”? Usunięcie tej właściwości spowoduje usunięcie jej z wszystkich kart na tej tablicy.", "CardDetailProperty.confirm-property-name-change-subtext": "Na pewno chcesz zmienić właściwość „{propertyName}” {customText}? Wpłynie to na wartości na {numOfCards} kartach na tej tablicy i może spowodować utratę danych.", "CardDetailProperty.confirm-property-type-change": "Potwierdzenie zmiany typu właściwości", "CardDetailProperty.delete-action-button": "Usuń", "CardDetailProperty.property-change-action-button": "Zmień właściwość", "CardDetailProperty.property-changed": "Zmieniono właściwość pomyślnie!", "CardDetailProperty.property-deleted": "Usunięto pomyślnie {propertyName}!", "CardDetailProperty.property-name-change-subtext": "typ z \"{oldPropType}\" do \"{newPropType}\"", "CardDetial.limited-link": "Dowiedz się więcej o naszych planach.", "CardDialog.delete-confirmation-dialog-attachment": "Potwierdź usunięcie załącznika", "CardDialog.delete-confirmation-dialog-button-text": "Usuń", "CardDialog.delete-confirmation-dialog-heading": "Potwierdź usunięcie karty", "CardDialog.editing-template": "Edytujesz szablon.", "CardDialog.nocard": "Ta karta nie istnieje lub jest niedostępna.", "Categories.CreateCategoryDialog.CancelText": "Anuluj", "Categories.CreateCategoryDialog.CreateText": "Utwórz", "Categories.CreateCategoryDialog.Placeholder": "Nazwij kategorię", "Categories.CreateCategoryDialog.UpdateText": "Zmień", "CenterPanel.Login": "Logowanie", "CenterPanel.Share": "Udostępnij", "ChannelIntro.CreateBoard": "Utwórz tablicę", "ColorOption.selectColor": "Wybierz Kolor {color}", "Comment.delete": "Usuń", "CommentsList.send": "Wyślij", "ConfirmPerson.empty": "Puste", "ConfirmPerson.search": "Szukaj...", "ConfirmationDialog.cancel-action": "Anuluj", "ConfirmationDialog.confirm-action": "Potwierdź", "ContentBlock.Delete": "Usuń", "ContentBlock.DeleteAction": "usuń", "ContentBlock.addElement": "dodaj {type}", "ContentBlock.checkbox": "pole wyboru", "ContentBlock.divider": "dzielnik", "ContentBlock.editCardCheckbox": "pole wyboru", "ContentBlock.editCardCheckboxText": "edytuj tekst karty", "ContentBlock.editCardText": "edytuj tekst karty", "ContentBlock.editText": "Edytuj tekst...", "ContentBlock.image": "obraz", "ContentBlock.insertAbove": "Wstaw powyżej", "ContentBlock.moveBlock": "przenieś zawartość karty", "ContentBlock.moveDown": "Przenieś w dół", "ContentBlock.moveUp": "Przenieś w górę", "ContentBlock.text": "tekst", "DateFilter.empty": "Puste", "DateRange.clear": "Wyczyść", "DateRange.empty": "Puste", "DateRange.endDate": "Data końcowa", "DateRange.today": "Dzisiaj", "DeleteBoardDialog.confirm-cancel": "Anuluj", "DeleteBoardDialog.confirm-delete": "Usuń", "DeleteBoardDialog.confirm-info": "Na pewno chcesz usunąć tablicę „{boardTitle}”? Usunięcie tej tablicy spowoduje usunięcie z niej wszystkich kart.", "DeleteBoardDialog.confirm-info-template": "Na pewno chcesz usunąć szablon tablicy „{boardTitle}”?", "DeleteBoardDialog.confirm-tite": "Potwierdzenie usunięcia tablicy", "DeleteBoardDialog.confirm-tite-template": "Potwierdzenie usunięcia szablonu tablicy", "Dialog.closeDialog": "Zamknij okno dialogowe", "EditableDayPicker.today": "Dzisiaj", "Error.mobileweb": "Strona internetowa dla urządzeń mobilnych jest obecnie we wczesnej fazie testów. Nie wszystkie funkcje mogą być dostępne.", "Error.websocket-closed": "Połączenie WebSocket zostało zamknięte – połączenie przerwane. Jeśli problem się powtarza, sprawdź konfigurację swojego serwera lub serwera pośredniczącego Web.", "Filter.contains": "zawiera", "Filter.ends-with": "kończy się na", "Filter.includes": "zawiera", "Filter.is": "jest", "Filter.is-after": "jest po", "Filter.is-before": "jest przed", "Filter.is-empty": "jest pusty", "Filter.is-not-empty": "nie jest pusty", "Filter.is-not-set": "nie jest ustawiony", "Filter.is-set": "jest ustawiony", "Filter.isafter": "jest po", "Filter.isbefore": "jest przed", "Filter.not-contains": "nie zawiera", "Filter.not-ends-with": "nie kończy się na", "Filter.not-includes": "nie zawiera", "Filter.not-starts-with": "nie zaczyna się od", "Filter.starts-with": "zaczyna się od", "FilterByText.placeholder": "tekst filtra", "FilterComponent.add-filter": "+ Dodaj filtr", "FilterComponent.delete": "Usuń", "FilterValue.empty": "(pusty)", "FindBoardsDialog.IntroText": "Wyszukiwanie tablic", "FindBoardsDialog.NoResultsFor": "Brak wyników dla „{searchQuery}”", "FindBoardsDialog.NoResultsSubtext": "Sprawdź pisownię lub spróbuj innego wyszukiwania.", "FindBoardsDialog.SubTitle": "Wpisz, aby znaleźć tablicę. Użyj GÓRA/DÓŁ, aby przeglądać. ENTER, aby wybrać, ESC, aby odrzucić", "FindBoardsDialog.Title": "Znajdź tablice", "GroupBy.hideEmptyGroups": "Ukryj {count} pustych grup", "GroupBy.showHiddenGroups": "Pokaż {count} ukrytych grup", "GroupBy.ungroup": "Rozgrupuj", "HideBoard.MenuOption": "Ukryj tablicę", "KanbanCard.untitled": "Bez tytułu", "MentionSuggestion.is-not-board-member": "(nie jest członkiem tablicy)", "Mutator.new-board-from-template": "nowa tablica z szablonu", "Mutator.new-card-from-template": "nowa karta z szablonu", "Mutator.new-template-from-card": "nowy szablon z karty", "OnboardingTour.AddComments.Body": "Możesz komentować zagadnienia, a nawet @wspominać innych użytkowników Mattermost, aby uzyskać ich uwagę.", "OnboardingTour.AddComments.Title": "Dodawanie komentarzy", "OnboardingTour.AddDescription.Body": "Dodaj opis do karty, aby członkowie zespołu wiedzieli, czego ona dotyczy .", "OnboardingTour.AddDescription.Title": "Dodaj opis", "OnboardingTour.AddProperties.Body": "Dodawaj różne właściwości do kart, aby zwiększyć ich moc.", "OnboardingTour.AddProperties.Title": "Dodawanie właściwości", "OnboardingTour.AddView.Body": "Tutaj utworzysz nowy widok, którym uporządkujesz tablicę za pomocą różnych układów.", "OnboardingTour.AddView.Title": "Dodawanie nowego widoku", "OnboardingTour.CopyLink.Body": "Karty można udostępniać członkom zespołu kopiując łącze i wklejając je w kanale, wiadomości prywatnej lub grupowej.", "OnboardingTour.CopyLink.Title": "Kopiowanie odnośnika", "OnboardingTour.OpenACard.Body": "Otwórz kartę i poznaj różne sposoby, dzięki którym zorganizujesz swoją pracę za pomocą tablic.", "OnboardingTour.OpenACard.Title": "Otwieranie karty", "OnboardingTour.ShareBoard.Body": "Możesz udostępniać swoją tablicę wewnętrznie, w ramach zespołu albo opublikować ją, aby była widoczna poza organizacją.", "OnboardingTour.ShareBoard.Title": "Udostępnianie tablicy", "PersonProperty.board-members": "Członkowie tablicy", "PersonProperty.me": "Ja", "PersonProperty.non-board-members": "Nie-członkowie tablicy", "PropertyMenu.Delete": "Usuń", "PropertyMenu.changeType": "Zmień typ właściwości", "PropertyMenu.selectType": "Wybierz typ właściwości", "PropertyMenu.typeTitle": "Typ", "PropertyType.Checkbox": "Pole wyboru", "PropertyType.CreatedBy": "Twórca", "PropertyType.CreatedTime": "Czas utworzenia", "PropertyType.Date": "Data", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "Wiele osób", "PropertyType.MultiSelect": "Pole wielokrotnego wyboru", "PropertyType.Number": "Liczba", "PropertyType.Person": "Osoba", "PropertyType.Phone": "Telefon", "PropertyType.Select": "Wybór", "PropertyType.Text": "Tekst", "PropertyType.Unknown": "Nieznany", "PropertyType.UpdatedBy": "Ostatni aktualizujący", "PropertyType.UpdatedTime": "Czas ostatniej aktualizacji", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Puste", "RegistrationLink.confirmRegenerateToken": "Unieważni to wcześniej udostępnione odnośniki. Kontynuować?", "RegistrationLink.copiedLink": "Skopiowano!", "RegistrationLink.copyLink": "Kopiuj odnośnik", "RegistrationLink.description": "Udostępnij ten odnośnik innym, aby mogli utworzyć konta:", "RegistrationLink.regenerateToken": "Wygeneruj ponownie poświadczenie", "RegistrationLink.tokenRegenerated": "Wygenerowano ponownie odnośnik rejestracyjny", "ShareBoard.PublishDescription": "Publikowanie i udostępnianie linku tylko-do-odczyt\" wszystkim w sieci.", "ShareBoard.PublishTitle": "Opublikuj w sieci", "ShareBoard.ShareInternal": "Udostępnij wewnętrznie", "ShareBoard.ShareInternalDescription": "Użytkownicy z odpowiednimi uprawnieniami będą mogli korzystać z tego łącza.", "ShareBoard.Title": "Udostępnij Tablicę", "ShareBoard.confirmRegenerateToken": "Spowoduje to unieważnienie wcześniej udostępnionych linków. Kontynuować?", "ShareBoard.copiedLink": "Skopiowane!", "ShareBoard.copyLink": "Kopiuj odnośnik", "ShareBoard.regenerate": "Wygeneruj ponownie token", "ShareBoard.searchPlaceholder": "Wyszukiwanie osób", "ShareBoard.teamPermissionsText": "Wszyscy w zespole {teamName}", "ShareBoard.tokenRegenrated": "Token wygenerowany", "ShareBoard.userPermissionsRemoveMemberText": "Usuń użytkownika", "ShareBoard.userPermissionsYouText": "(Ty)", "ShareTemplate.Title": "Udostępnij szablon", "ShareTemplate.searchPlaceholder": "Wyszukiwanie osób", "Sidebar.about": "O Focalboard", "Sidebar.add-board": "+ Dodaj tablicę", "Sidebar.changePassword": "Zmień hasło", "Sidebar.delete-board": "Usuń tablicę", "Sidebar.duplicate-board": "Duplikuj tablicę", "Sidebar.export-archive": "Eksportuj archiwum", "Sidebar.import": "Importuj", "Sidebar.import-archive": "Importuj archiwum", "Sidebar.invite-users": "Zaproś użytkowników", "Sidebar.logout": "Wyloguj się", "Sidebar.new-category.badge": "Nowy", "Sidebar.new-category.drag-boards-cta": "Przenieś tutaj tablice...", "Sidebar.no-boards-in-category": "Brak tablic wewnątrz", "Sidebar.product-tour": "Przegląd", "Sidebar.random-icons": "Losowe ikony", "Sidebar.set-language": "Ustaw język", "Sidebar.set-theme": "Ustaw motyw", "Sidebar.settings": "Ustawienia", "Sidebar.template-from-board": "Nowy szablon z tablicy", "Sidebar.untitled-board": "(Tablica bez tytułu)", "Sidebar.untitled-view": "(Widok bez Tytułu)", "SidebarCategories.BlocksMenu.Move": "Przenieś Do...", "SidebarCategories.CategoryMenu.CreateNew": "Utwórz Nową Kategorię", "SidebarCategories.CategoryMenu.Delete": "Usuń Kategorię", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Tablice w {categoryName} zostaną przeniesione z powrotem do kategorii Tablice. Nie zostaniesz usunięty z żadnej tablicy.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Usunąć tą kategorię?", "SidebarCategories.CategoryMenu.Update": "Zmień nazwę Kategorii", "SidebarTour.ManageCategories.Body": "Twórz i zarządzaj własnymi kategoriami. Kategorie są zależne od użytkownika, więc przeniesienie tablicy do twojej kategorii nie będzie miało wpływu na innych członków korzystających z tej samej tablicy.", "SidebarTour.ManageCategories.Title": "Zarządzaj kategoriami", "SidebarTour.SearchForBoards.Body": "Otwórz przełącznik tablic (Cmd/Ctrl + K), aby szybko wyszukać i dodać tablice do swojego paska bocznego.", "SidebarTour.SearchForBoards.Title": "Wyszukiwanie tablic", "SidebarTour.SidebarCategories.Body": "Wszystkie Twoje tablice są teraz uporządkowane w nowym pasku bocznym. Nie musisz już przełączać się między obszarami roboczymi. Jednorazowe niestandardowe kategorie oparte na Twoich poprzednich obszarach roboczych mogły zostać automatycznie utworzone dla Ciebie w ramach aktualizacji do wersji 7.2. Można je usunąć lub edytować według własnych preferencji.", "SidebarTour.SidebarCategories.Link": "Dowiedź się więcej", "SidebarTour.SidebarCategories.Title": "Kategorie paska bocznego", "SiteStats.total_boards": "Tablice ogółem", "SiteStats.total_cards": "Karty ogółem", "TableComponent.add-icon": "Dodaj Ikonę", "TableComponent.name": "Nazwa", "TableComponent.plus-new": "+ Nowy", "TableHeaderMenu.delete": "Usuń", "TableHeaderMenu.duplicate": "Duplikuj", "TableHeaderMenu.hide": "Ukryj", "TableHeaderMenu.insert-left": "Wstaw z lewej", "TableHeaderMenu.insert-right": "Wstaw z prawej", "TableHeaderMenu.sort-ascending": "Sortuj rosnąco", "TableHeaderMenu.sort-descending": "Sortuj malejąco", "TableRow.DuplicateCard": "duplikuj kartę", "TableRow.MoreOption": "Więcej działań", "TableRow.open": "Otwórz", "TopBar.give-feedback": "Przekaż informację zwrotną", "URLProperty.copiedLink": "Skopiowane!", "URLProperty.copy": "Kopia", "URLProperty.edit": "Edycja", "UndoRedoHotKeys.canRedo": "Powtórz", "UndoRedoHotKeys.canRedo-with-description": "Powtórz {description}", "UndoRedoHotKeys.canUndo": "Cofnij", "UndoRedoHotKeys.canUndo-with-description": "Cofnij {description}", "UndoRedoHotKeys.cannotRedo": "Nic do powtórzenia", "UndoRedoHotKeys.cannotUndo": "Nic do cofnięcia", "ValueSelector.noOptions": "Brak opcji. Zacznij wpisywać, aby dodać pierwszą z nich!", "ValueSelector.valueSelector": "Selektor wartości", "ValueSelectorLabel.openMenu": "Otwórz menu", "VersionMessage.help": "Sprawdź co nowego w tej wersji.", "VersionMessage.learn-more": "Dowiedź się więcej", "View.AddView": "Dodaj widok", "View.Board": "Tablica", "View.DeleteView": "Usuń widok", "View.DuplicateView": "Duplikuj widok", "View.Gallery": "Galeria", "View.NewBoardTitle": "Widok Tablicy", "View.NewCalendarTitle": "Widok Kalendarza", "View.NewGalleryTitle": "Widok galerii", "View.NewTableTitle": "Widok tabeli", "View.NewTemplateDefaultTitle": "Szablon bez tytułu", "View.NewTemplateTitle": "Bez tytułu", "View.Table": "Tabela", "ViewHeader.add-template": "Nowy szablon", "ViewHeader.delete-template": "Usuń", "ViewHeader.display-by": "Wyświetl według: {property}", "ViewHeader.edit-template": "Edytuj", "ViewHeader.empty-card": "Wyczyść kartę", "ViewHeader.export-board-archive": "Eksportuj archiwum tablicy", "ViewHeader.export-complete": "Eksport zakończony!", "ViewHeader.export-csv": "Eksportuj do CSV", "ViewHeader.export-failed": "Eksport nie powiódł się!", "ViewHeader.filter": "Filtr", "ViewHeader.group-by": "Grupuj wg: {property}", "ViewHeader.new": "Nowy", "ViewHeader.properties": "Właściwości", "ViewHeader.properties-menu": "Menu właściwości", "ViewHeader.search-text": "Przeszukaj karty", "ViewHeader.select-a-template": "Wybierz szablon", "ViewHeader.set-default-template": "Ustaw jako domyślne", "ViewHeader.sort": "Sortuj", "ViewHeader.untitled": "Bez tytułu", "ViewHeader.view-header-menu": "Wyświetl menu nagłówka", "ViewHeader.view-menu": "Wyświetl menu", "ViewLimitDialog.Heading": "Osiągnięty limit odsłon na tablicę", "ViewLimitDialog.PrimaryButton.Title.Admin": "Aktualizuj", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Powiadom Administratora", "ViewLimitDialog.Subtext.Admin": "Uaktualnij do naszego planu Professional lub Enterprise.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Dowiedz się więcej o naszych planach.", "ViewLimitDialog.Subtext.RegularUser": "Powiadom swojego Administratora, aby uaktualnić do naszego planu Professional lub Enterprise.", "ViewLimitDialog.UpgradeImg.AltText": "aktualizuj obraz", "ViewLimitDialog.notifyAdmin.Success": "Twój administrator został powiadomiony", "ViewTitle.hide-description": "ukryj opis", "ViewTitle.pick-icon": "Wybierz ikonę", "ViewTitle.random-icon": "Losowy", "ViewTitle.remove-icon": "Usuń ikonę", "ViewTitle.show-description": "pokaż opis", "ViewTitle.untitled-board": "Tablica bez tytułu", "WelcomePage.Description": "Tablice to narzędzie do zarządzania projektami, które pomaga definiować, organizować, śledzić i zarządzać pracą w zespołach wykorzystując widok znanych tablic Kanban.", "WelcomePage.Explore.Button": "Wybierz się na wycieczkę", "WelcomePage.Heading": "Witamy w Tablicach", "WelcomePage.NoThanks.Text": "Nie, dzięki, sam sobie z tym poradzę", "WelcomePage.StartUsingIt.Text": "Zacznij używać", "Workspace.editing-board-template": "Edytujesz szablon tablicy.", "badge.guest": "Gość", "boardPage.confirm-join-button": "Dołącz", "boardPage.confirm-join-text": "Zamierzasz dołączyć do prywatnej tablicy bez wyraźnego dodania przez administratora forum. Czy na pewno chcesz dołączyć do tego prywatnego forum?", "boardPage.confirm-join-title": "Dołącz do prywatnej tablicy", "boardSelector.confirm-link-board": "Połączenie tablicy z kanałem", "boardSelector.confirm-link-board-button": "Tak, podlinkuj tablicę", "boardSelector.confirm-link-board-subtext": "Kiedy połączysz \"{boardName}\" z kanałem, wszyscy członkowie kanału (istniejący i nowi) będą mogli go edytować. Nie dotyczy to członków, którzy są gośćmi. W każdej chwili możesz odłączyć tablicę od kanału.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Kiedy połączysz \"{boardName}\" z kanałem, wszyscy członkowie kanału (istniejący i nowi) będą mogli go edytować. Wyklucza to członków, którzy są gośćmi.{lineBreak} Ta tablica jest obecnie połączona z innym kanałem. Zostanie ona odłączona, jeśli zdecydujesz się połączyć ją tutaj.", "boardSelector.create-a-board": "Utwórz tablicę", "boardSelector.link": "Link", "boardSelector.search-for-boards": "Wyszukiwanie tablic", "boardSelector.title": "Linki tablic", "boardSelector.unlink": "Odłącz", "calendar.month": "Miesiąc", "calendar.today": "DZIŚ", "calendar.week": "Tydzień", "centerPanel.undefined": "Brak {propertyName}", "centerPanel.unknown-user": "Nieznany użytkownik", "cloudMessage.learn-more": "Dowiedź się więcej", "createImageBlock.failed": "Ten plik nie mógł zostać przesłany, ponieważ został osiągnięty limit rozmiaru pliku.", "default-properties.badges": "Uwagi i opis", "default-properties.title": "Tytuł", "error.back-to-home": "Powrót na stronę główną", "error.back-to-team": "Powrót do zespołu", "error.board-not-found": "Nie znaleziono tablicy.", "error.go-login": "Zaloguj się", "error.invalid-read-only-board": "Nie masz dostępu do tej tablicy. Zaloguj się, aby uzyskać dostęp do Tablic.", "error.not-logged-in": "Twoja sesja mogła wygasnąć lub nie jesteś zalogowany. Zaloguj się ponownie, aby uzyskać dostęp do Tablic.", "error.page.title": "Przepraszam, coś poszło nie tak", "error.team-undefined": "Nieprawidłowy zespół.", "error.unknown": "Wystąpił błąd.", "generic.previous": "Wstecz", "guest-no-board.subtitle": "Nie masz jeszcze dostępu do żadnej tablicy w tym zespole, poczekaj aż ktoś doda Cię do jakiejkolwiek tablicy.", "guest-no-board.title": "Nie ma jeszcze tablic", "imagePaste.upload-failed": "Niektóre pliki nie zostały przesłane, ponieważ został osiągnięty limit rozmiaru pliku.", "limitedCard.title": "Ukryte karty", "login.log-in-button": "Zaloguj się", "login.log-in-title": "Zaloguj się", "login.register-button": "lub załóż konto, jeśli jeszcze go nie masz", "new_channel_modal.create_board.empty_board_description": "Utwórz nową pustą tablicę", "new_channel_modal.create_board.empty_board_title": "Wyczyść tablicę", "new_channel_modal.create_board.select_template_placeholder": "Wybierz szablon", "new_channel_modal.create_board.title": "Utwórz tablicę dla tego kanału", "notification-box-card-limit-reached.close-tooltip": "Uśpij na 10 dni", "notification-box-card-limit-reached.contact-link": "powiadom swojego administratora", "notification-box-card-limit-reached.link": "Uaktualnienie do planu płatnego", "notification-box-card-limit-reached.title": "{cards} karty ukryte z tablicy", "notification-box-cards-hidden.title": "Ta akcja zakryła inną kartę", "notification-box.card-limit-reached.not-admin.text": "Aby uzyskać dostęp do zarchiwizowanych kart, możesz {contactLink} uaktualnić do płatnego planu.", "notification-box.card-limit-reached.text": "Osiągnięto limit kart, aby wyświetlić starsze karty, {link}", "person.add-user-to-board": "Dodaj {username} do tablicy", "person.add-user-to-board-confirm-button": "Dodaj do tablicy", "person.add-user-to-board-permissions": "Uprawnienia", "person.add-user-to-board-question": "Czy chcesz dodać do tablicy {username}?", "person.add-user-to-board-warning": "{username} nie jest członkiem tablicy i nie będzie otrzymywał żadnych powiadomień na ten temat.", "register.login-button": "lub zaloguj się, jeśli masz już konto", "register.signup-title": "Zarejestruj się na swoim koncie", "rhs-board-non-admin-msg": "Nie jesteś administratorem tablicy", "rhs-boards.add": "Dodaj", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "ta bezpośrednia wiadomość", "rhs-boards.header.gm": "ta wiadomość grupowa", "rhs-boards.last-update-at": "Ostatnia aktualizacja o: {datetime}", "rhs-boards.link-boards-to-channel": "Połączenie tablic z {channelName}", "rhs-boards.linked-boards": "Połączone tablice", "rhs-boards.no-boards-linked-to-channel": "Żadna z tablic nie jest jeszcze połączona z {channelName}", "rhs-boards.no-boards-linked-to-channel-description": "Tablice to narzędzie do zarządzania projektami, które pomagają definiować, organizować, śledzić i zarządzać pracą w zespołach, wykorzystując widok znanych tablic kanban.", "rhs-boards.unlink-board": "Odłączenie tablicy", "rhs-boards.unlink-board1": "Odłączenie tablicy", "rhs-channel-boards-header.title": "Tablice", "share-board.publish": "Opublikuj", "share-board.share": "Udostępnij", "shareBoard.channels-select-group": "Kanały", "shareBoard.confirm-change-team-role.body": "Każdy na tej tablicy z niższymi uprawnieniami niż rola \"{role}\" teraz zostanie awansowany do {role}. Czy na pewno chcesz zmienić minimalną rolę dla tablicy?", "shareBoard.confirm-change-team-role.confirmBtnText": "Zmiana minimalnej roli tablicy", "shareBoard.confirm-change-team-role.title": "Zmiana minimalnej roli tablicy", "shareBoard.confirm-link-channel": "Podlinku tablicę do kanału", "shareBoard.confirm-link-channel-button": "Połączenie kanału", "shareBoard.confirm-link-channel-button-with-other-channel": "Odłącz i podłącz tutaj", "shareBoard.confirm-link-channel-subtext": "Kiedy połączysz kanał z tablicą, wszyscy członkowie kanału (istniejący i nowi) będą mogli go edytować. Nie dotyczy to członków, którzy są gośćmi.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Kiedy połączysz kanał z tablicą, wszyscy członkowie kanału (istniejący i nowi) będą mogli go edytować. Nie dotyczy to członków, którzy są gośćmi.{lineBreak}Ta tablica jest obecnie połączona z innym kanałem. Zostanie ona usunięta, jeśli zdecydujesz się połączyć ją tutaj.", "shareBoard.confirm-unlink.body": "Kiedy odłączysz kanał od tablicy, wszyscy członkowie kanału (istniejący i nowi) stracą do niego dostęp, chyba że otrzymają osobne pozwolenie.", "shareBoard.confirm-unlink.confirmBtnText": "Tak, odłącz", "shareBoard.confirm-unlink.title": "Odłączenie kanału od tablicy", "shareBoard.lastAdmin": "Tablice muszą mieć co najmniej jednego Administratora", "shareBoard.members-select-group": "Członkowie", "shareBoard.unknown-channel-display-name": "Nieznany kanał", "tutorial_tip.finish_tour": "Gotowe", "tutorial_tip.got_it": "Jasne", "tutorial_tip.ok": "Dalej", "tutorial_tip.out": "Zrezygnuj z tych porad.", "tutorial_tip.seen": "Widziałeś to wcześniej?" } ================================================ FILE: webapp/i18n/pt.json ================================================ { "AdminBadge.SystemAdmin": "Administrador", "AppBar.Tooltip": "Alternar quadros vinculados", "Attachment.Attachment-title": "Anexo", "AttachmentBlock.DeleteAction": "Apagar", "AttachmentBlock.addElement": "Adicionar {type}", "AttachmentBlock.delete": "Anexo apagado.", "AttachmentBlock.failed": "Este arquivo não pôde ser carregado pois ultrapassou o tamanho limite.", "AttachmentBlock.upload": "Carregando anexo.", "AttachmentBlock.uploadSuccess": "Anexo carregado.", "AttachmentElement.delete-confirmation-dialog-button-text": "Apagar", "AttachmentElement.download": "Baixar", "BoardComponent.add-a-group": "+ Adicionar um grupo", "BoardComponent.delete": "Apagar", "BoardComponent.hidden-columns": "Colunas escondidas", "BoardComponent.hide": "Esconder", "BoardComponent.new": "+ Novo", "BoardComponent.no-property": "Não {property}", "BoardComponent.show": "Mostrar", "BoardMember.schemeAdmin": "Admin", "BoardMember.schemeCommenter": "Comentador", "BoardMember.schemeEditor": "Editor", "BoardMember.schemeNone": "Nenhum", "BoardMember.unlinkChannel": "Desvincular", "BoardPage.newVersion": "Está disponível uma nova versão do Boards, clique aqui para recarregar.", "BoardPage.syncFailed": "O Board pode ter sido apagado ou o acesso revogado.", "BoardTemplateSelector.add-template": "Criar novo modelo", "BoardTemplateSelector.create-empty-board": "Criar um board vazio", "BoardTemplateSelector.delete-template": "Apagar", "BoardTemplateSelector.edit-template": "Editar", "BoardTemplateSelector.plugin.no-content-title": "Criar um board", "BoardTemplateSelector.title": "Criar um board", "BoardTemplateSelector.use-this-template": "Usar este modelo", "BoardsSwitcher.Title": "Encontrar boards", "Calculations.Options.average.displayName": "Média", "Calculations.Options.average.label": "Média", "Calculations.Options.countUniqueValue.displayName": "Único", "Calculations.Options.countValue.displayName": "Valores", "Calculations.Options.dateRange.displayName": "Intervalo", "Calculations.Options.dateRange.label": "Intervalo", "Calculations.Options.latest.label": "Mais recente", "Calculations.Options.max.displayName": "Máx", "Calculations.Options.max.label": "Máximo", "Calculations.Options.min.displayName": "Mínimo", "Calculations.Options.min.label": "Mínimo", "Calculations.Options.none.label": "Nenhum", "Calculations.Options.range.displayName": "Intervalo", "Calculations.Options.range.label": "Intervalo", "Calculations.Options.sum.displayName": "Soma", "Calculations.Options.sum.label": "Soma", "CalendarCard.untitled": "Sem título", "CardActionsMenu.copiedLink": "Copiado!", "CardActionsMenu.copyLink": "Copiar link", "CardActionsMenu.delete": "Apagar", "CardActionsMenu.duplicate": "Duplicar", "CardBadges.title-comments": "Comentários", "CardDetail.Attach": "Anexar", "CardDetail.Follow": "Seguir", "CardDetail.Following": "Seguindo", "CardDetail.add-content": "Adicionar conteúdo", "CardDetail.add-icon": "Adicionar ícone", "CardDetail.add-property": "+ Adicionar uma propriedade", "CardDetail.limited-button": "Atualizar", "CardDetail.new-comment-placeholder": "Adicionar um comentário...", "CardDetailProperty.delete-action-button": "Apagar", "CardDialog.delete-confirmation-dialog-button-text": "Apagar", "Categories.CreateCategoryDialog.CancelText": "Cancelar", "Categories.CreateCategoryDialog.CreateText": "Criar", "Categories.CreateCategoryDialog.UpdateText": "Atualizar", "CenterPanel.Login": "Entrar", "CenterPanel.Share": "Compartilhar", "Comment.delete": "Apagar", "CommentsList.send": "Enviar", "ConfirmPerson.empty": "Vazio", "ConfirmPerson.search": "Procurar...", "ConfirmationDialog.cancel-action": "Cancelar", "ConfirmationDialog.confirm-action": "Confirmar", "ContentBlock.Delete": "Apagar", "ContentBlock.DeleteAction": "apagar", "ContentBlock.addElement": "adicionar {type}", "ContentBlock.editText": "Editar texto...", "ContentBlock.image": "imagem", "ContentBlock.moveDown": "Mover pra baixo", "ContentBlock.moveUp": "Mover pra cima", "ContentBlock.text": "texto", "DateFilter.empty": "Vazio", "DateRange.clear": "Limpar", "DateRange.empty": "Vazio", "DateRange.today": "Hoje", "DeleteBoardDialog.confirm-cancel": "Cancelar", "DeleteBoardDialog.confirm-delete": "Apagar", "EditableDayPicker.today": "Hoje", "shareBoard.members-select-group": "Membros", "shareBoard.unknown-channel-display-name": "Canal desconhecido", "tutorial_tip.ok": "Próximo" } ================================================ FILE: webapp/i18n/pt_BR.json ================================================ { "AdminBadge.SystemAdmin": "Administrador", "AdminBadge.TeamAdmin": "Administrador de equipe", "AppBar.Tooltip": "Ativar Boards Vinculados", "Attachment.Attachment-title": "Anexo", "AttachmentBlock.DeleteAction": "excluir", "AttachmentBlock.addElement": "adicionar {type}", "AttachmentBlock.delete": "Anexo apagado.", "AttachmentBlock.failed": "Este arquivo não pôde ser carregado pois ultrapassou o tamanho limite.", "AttachmentBlock.upload": "Carregando anexo.", "AttachmentBlock.uploadSuccess": "Anexo enviado.", "AttachmentElement.delete-confirmation-dialog-button-text": "Excluir", "AttachmentElement.download": "Baixar", "AttachmentElement.upload-percentage": "Enviando...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Adicione um grupo", "BoardComponent.delete": "Excluir", "BoardComponent.hidden-columns": "Colunas ocultas", "BoardComponent.hide": "Ocultar", "BoardComponent.new": "Novo", "BoardComponent.no-property": "Sem {property}", "BoardComponent.no-property-title": "Itens com um valor {property} vazio aparecerão aqui. Esta coluna não pode ser excluída.", "BoardComponent.show": "Exibir", "BoardMember.schemeAdmin": "Administrador", "BoardMember.schemeCommenter": "Comentarista", "BoardMember.schemeEditor": "Editor", "BoardMember.schemeNone": "Nenhum", "BoardMember.schemeViewer": "Visualizador", "BoardMember.unlinkChannel": "Desvincular", "BoardPage.newVersion": "Uma nova versão do Boards está disponível, clique aqui para recarregar.", "BoardPage.syncFailed": "O Board pode ter sido excluído ou o acesso revogado.", "BoardTemplateSelector.add-template": "Criar novo modelo", "BoardTemplateSelector.create-empty-board": "Criar um board vazio", "BoardTemplateSelector.delete-template": "Excluir", "BoardTemplateSelector.description": "Adicione um quadro à barra lateral usando qualquer um dos modelos definidos abaixo ou comece do zero.", "BoardTemplateSelector.edit-template": "Editar", "BoardTemplateSelector.plugin.no-content-description": "Adicione um board à barra lateral usando um dos templates disponíveis abaixo ou comece do zero.", "BoardTemplateSelector.plugin.no-content-title": "Criar um board", "BoardTemplateSelector.title": "Criar um board", "BoardTemplateSelector.use-this-template": "Use este template", "BoardsSwitcher.Title": "Encontrar boards", "BoardsUnfurl.Limited": "Detalhes adicionais estão ocultos devido ao cartão ter sido arquivado", "BoardsUnfurl.Remainder": "+{remainder} mais", "BoardsUnfurl.Updated": "Atualizado {time}", "Calculations.Options.average.displayName": "Média", "Calculations.Options.average.label": "Média", "Calculations.Options.count.displayName": "Total", "Calculations.Options.count.label": "Total", "Calculations.Options.countChecked.displayName": "Confirmado", "Calculations.Options.countChecked.label": "Total de itens confirmados", "Calculations.Options.countUnchecked.displayName": "Não confirmado", "Calculations.Options.countUnchecked.label": "Total de itens não confirmados", "Calculations.Options.countUniqueValue.displayName": "Único", "Calculations.Options.countUniqueValue.label": "Total de valores únicos", "Calculations.Options.countValue.displayName": "Valores", "Calculations.Options.countValue.label": "Valor total", "Calculations.Options.dateRange.displayName": "Período", "Calculations.Options.dateRange.label": "Alcance", "Calculations.Options.earliest.displayName": "Mais antigo", "Calculations.Options.earliest.label": "Mais antigo", "Calculations.Options.latest.displayName": "Mais recente", "Calculations.Options.latest.label": "Mais recente", "Calculations.Options.max.displayName": "Máx", "Calculations.Options.max.label": "Máx", "Calculations.Options.median.displayName": "Mediana", "Calculations.Options.median.label": "Mediana", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Calcular", "Calculations.Options.none.label": "Nenhum", "Calculations.Options.percentChecked.displayName": "Verificado", "Calculations.Options.percentChecked.label": "Porcentagem verificada", "Calculations.Options.percentUnchecked.displayName": "Não verificado", "Calculations.Options.percentUnchecked.label": "Porcentagem não verificada", "Calculations.Options.range.displayName": "Alcance", "Calculations.Options.range.label": "Período", "Calculations.Options.sum.displayName": "Soma", "Calculations.Options.sum.label": "Soma", "CalendarCard.untitled": "Sem título", "CardActionsMenu.copiedLink": "Copiado!", "CardActionsMenu.copyLink": "Copiar link", "CardActionsMenu.delete": "Excluir", "CardActionsMenu.duplicate": "Duplicar", "CardBadges.title-checkboxes": "Caixa de seleção", "CardBadges.title-comments": "Comentários", "CardBadges.title-description": "Este cartão tem uma descrição", "CardDetail.Attach": "Anexar", "CardDetail.Follow": "Seguir", "CardDetail.Following": "Seguindo", "CardDetail.add-content": "Adicionar conteúdo", "CardDetail.add-icon": "Adicionar ícone", "CardDetail.add-property": "+ Adicionar propriedade", "CardDetail.addCardText": "adicionar texto ao card", "CardDetail.limited-body": "Atualize para nosso plano Professional ou Enterprise.", "CardDetail.limited-button": "Upgrade", "CardDetail.limited-title": "Este cartão está oculto", "CardDetail.moveContent": "Mover conteúdo do cartão", "CardDetail.new-comment-placeholder": "Adicionar um comentário...", "CardDetailProperty.confirm-delete-heading": "Confirmar exclusão da propriedade", "CardDetailProperty.confirm-delete-subtext": "Tem certeza que quer excluir a propriedade \"{propertyName}\"? Deletando-a excluirá a propriedade de todos os cards nessa board.", "CardDetailProperty.confirm-property-name-change-subtext": "Tem certeza que deseja alterar propriedade \"{propertyName}\" {customText}? Isto afetará valor(es) em {numOfCards} cartão(ões) neste quadro, podendo resultar em perda de dados.", "CardDetailProperty.confirm-property-type-change": "Confirmar alteração de tipo de propriedade", "CardDetailProperty.delete-action-button": "Excluir", "CardDetailProperty.property-change-action-button": "Alterar propriedade", "CardDetailProperty.property-changed": "Propriedade alterada com sucesso!", "CardDetailProperty.property-deleted": "{propertyName} excluído com êxito!", "CardDetailProperty.property-name-change-subtext": "digite de \"{oldPropType}\" para \"{newPropType}\"", "CardDetial.limited-link": "Saiba mais sobre nossos planos.", "CardDialog.delete-confirmation-dialog-attachment": "Confirmar exclusão de anexo", "CardDialog.delete-confirmation-dialog-button-text": "Excluir", "CardDialog.delete-confirmation-dialog-heading": "Confirmar exclusão do cartão", "CardDialog.editing-template": "Você está editando um template.", "CardDialog.nocard": "Esse cartão não existe ou não está acessível.", "Categories.CreateCategoryDialog.CancelText": "Cancelar", "Categories.CreateCategoryDialog.CreateText": "Criar", "Categories.CreateCategoryDialog.Placeholder": "Nomeie sua categoria", "Categories.CreateCategoryDialog.UpdateText": "Atualizar", "CenterPanel.Login": "Login", "CenterPanel.Share": "Compartilhar", "ChannelIntro.CreateBoard": "Criar um board", "ColorOption.selectColor": "Selecione {color} Cor", "Comment.delete": "Excluir", "CommentsList.send": "Enviar", "ConfirmPerson.empty": "Vazio", "ConfirmPerson.search": "Buscar...", "ConfirmationDialog.cancel-action": "Cancelar", "ConfirmationDialog.confirm-action": "Confirmar", "ContentBlock.Delete": "Excluir", "ContentBlock.DeleteAction": "Excluir", "ContentBlock.addElement": "adicionar {type}", "ContentBlock.checkbox": "caixa de seleção", "ContentBlock.divider": "Divisor", "ContentBlock.editCardCheckbox": "Caixa de seleção marcada", "ContentBlock.editCardCheckboxText": "editar texto do cartão", "ContentBlock.editCardText": "editar texto do cartão", "ContentBlock.editText": "Editar texto...", "ContentBlock.image": "imagem", "ContentBlock.insertAbove": "Inserir acima", "ContentBlock.moveBlock": "mover conteúdo do cartão", "ContentBlock.moveDown": "Mover para baixo", "ContentBlock.moveUp": "Mover para cima", "ContentBlock.text": "texto", "DateFilter.empty": "Vazio", "DateRange.clear": "Limpar", "DateRange.empty": "Vazio", "DateRange.endDate": "data de término", "DateRange.today": "Hoje", "DeleteBoardDialog.confirm-cancel": "Cancelar", "DeleteBoardDialog.confirm-delete": "Excluir", "DeleteBoardDialog.confirm-info": "Tem certeza que quer excluir o quadro \"{boardTitle}\"? Excluí-lo irá apagar todos os cartões no quadro.", "DeleteBoardDialog.confirm-info-template": "Tem certeza que deseja excluir o board template “{boardTitle}”?", "DeleteBoardDialog.confirm-tite": "Confirmar exclusão do board", "DeleteBoardDialog.confirm-tite-template": "Confirmar exclusão do template de board", "Dialog.closeDialog": "Fechar diálogo", "EditableDayPicker.today": "Hoje", "Error.mobileweb": "Suporte Web móvel está em estágio beta. Algumas funcionalidades podem estar presentes.", "Error.websocket-closed": "Conexão Websocket fechada, conexão interrompida. Se persistir, verifique a configuração do seu servidor ou proxy de web.", "Filter.contains": "contém", "Filter.ends-with": "termina com", "Filter.includes": "Inclui", "Filter.is": "é", "Filter.is-after": "está depois", "Filter.is-before": "está antes", "Filter.is-empty": "está vazio", "Filter.is-not-empty": "Não está vazio", "Filter.is-not-set": "não está definido", "Filter.is-set": "está definido", "Filter.isafter": "está depois", "Filter.isbefore": "está antes", "Filter.not-contains": "não contém", "Filter.not-ends-with": "não termina com", "Filter.not-includes": "Não inclui", "Filter.not-starts-with": "não começa com", "Filter.starts-with": "começa com", "FilterByText.placeholder": "filtrar texto", "FilterComponent.add-filter": "+ Adicionar filtro", "FilterComponent.delete": "Excluir", "FilterValue.empty": "(vazio)", "FindBoardsDialog.IntroText": "Procurar por quadros", "FindBoardsDialog.NoResultsFor": "Sem resultado para \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Verifique a digitação ou tente outra busca.", "FindBoardsDialog.SubTitle": "Digite para localizar um board. Use CIMA/BAIXO para explorar ENTER para selecionar, ESC para dispensar", "FindBoardsDialog.Title": "Encontrar quadros", "GroupBy.hideEmptyGroups": "Ocultar {count} grupos vazios", "GroupBy.showHiddenGroups": "Mostrar {count} grupos ocultos", "GroupBy.ungroup": "Desagrupar", "HideBoard.MenuOption": "Ocultar quadro", "KanbanCard.untitled": "Sem nome", "MentionSuggestion.is-not-board-member": "(não membro do board)", "Mutator.new-board-from-template": "novo board do template", "Mutator.new-card-from-template": "novo cartão à partir de um template", "Mutator.new-template-from-card": "novo template à partir de um cartão", "OnboardingTour.AddComments.Body": "Você pode comentar questões, e até mesmo @mencionar seus usuários companheiros de Mattermost para conseguir suas atenções.", "OnboardingTour.AddComments.Title": "Adicionar comentários", "OnboardingTour.AddDescription.Body": "Adicione uma descrição para que seus companheiros de time saibam sobre o que é o cartão.", "OnboardingTour.AddDescription.Title": "Adicionar descrição", "OnboardingTour.AddProperties.Body": "Adicione várias propriedades aos cartões para torná-los mais poderosos.", "OnboardingTour.AddProperties.Title": "Adicionar propriedades", "OnboardingTour.AddView.Body": "Crie uma nova view aquei para organizar seu board usando diferentes layouts.", "OnboardingTour.AddView.Title": "Adicionar nova visualização", "OnboardingTour.CopyLink.Body": "Você pode compartilhar seus cartões com companheiros de times copiando e colando o link em um canal, mensagem direta, ou mensagem de grupo.", "OnboardingTour.CopyLink.Title": "Copiar link", "OnboardingTour.OpenACard.Body": "Abra um cartão para explorar formas poderosas que os Boards podem ajudar a organizar seu trabalho.", "OnboardingTour.OpenACard.Title": "Abrir um cartão", "OnboardingTour.ShareBoard.Body": "Você pode compartilhar seu board internament, com seu time, ou public para permitir visibilidade fora da sua organização.", "OnboardingTour.ShareBoard.Title": "Compartilhar quadro", "PersonProperty.board-members": "Membros do Board", "PersonProperty.me": "Eu", "PersonProperty.non-board-members": "Não membros do board", "PropertyMenu.Delete": "Excluir", "PropertyMenu.changeType": "Alterar tipo da propriedade", "PropertyMenu.selectType": "Selecione o tipo de propriedade", "PropertyMenu.typeTitle": "Tipo", "PropertyType.Checkbox": "Caixa de seleção", "PropertyType.CreatedBy": "Criado por", "PropertyType.CreatedTime": "Horário de criação", "PropertyType.Date": "Data", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "Múltiplas pessoas", "PropertyType.MultiSelect": "Seleção múltipla", "PropertyType.Number": "Número", "PropertyType.Person": "Pessoa", "PropertyType.Phone": "Telefone", "PropertyType.Select": "Selcionar", "PropertyType.Text": "Texto", "PropertyType.Unknown": "Desconhecido", "PropertyType.UpdatedBy": "Atualizado pela última vez por", "PropertyType.UpdatedTime": "Atualizado pela última vez em", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Vazio", "RegistrationLink.confirmRegenerateToken": "Isso vai invalidar os links compartilhados anteriormente. Continuar?", "RegistrationLink.copiedLink": "Copiado!", "RegistrationLink.copyLink": "Copiar link", "RegistrationLink.description": "Compartilhe esse link para que outras pessoas criarem contas:", "RegistrationLink.regenerateToken": "Gerar o token novamente", "RegistrationLink.tokenRegenerated": "Link para registro gerado novamente", "ShareBoard.PublishDescription": "Publique e compartilhe um link de somente leitura com todos na web.", "ShareBoard.PublishTitle": "Publicar para a web", "ShareBoard.ShareInternal": "Compartilhar internamente", "ShareBoard.ShareInternalDescription": "Usuários que terão permissão para utilizar este link.", "ShareBoard.Title": "Compartilhar Quadro", "ShareBoard.confirmRegenerateToken": "Isso vai invalidar links compartilhados anteriormente. Continuar?", "ShareBoard.copiedLink": "Copiado!", "ShareBoard.copyLink": "Copiar link", "ShareBoard.regenerate": "Gerar token novamente", "ShareBoard.searchPlaceholder": "Procurar por pessoas e canais", "ShareBoard.teamPermissionsText": "Todos no time {teamName}", "ShareBoard.tokenRegenrated": "Token gerado novamente", "ShareBoard.userPermissionsRemoveMemberText": "Remover membro", "ShareBoard.userPermissionsYouText": "(Você)", "ShareTemplate.Title": "Compartilhar template", "ShareTemplate.searchPlaceholder": "Busca por pessoas", "Sidebar.about": "Sobre o Focalboard", "Sidebar.add-board": "+ Adicionar quadro", "Sidebar.changePassword": "Mudar senha", "Sidebar.delete-board": "Excluir quadro", "Sidebar.duplicate-board": "Duplicar quadro", "Sidebar.export-archive": "Exportar arquivo", "Sidebar.import": "Importar", "Sidebar.import-archive": "Importar arquivo", "Sidebar.invite-users": "Convidar usuários", "Sidebar.logout": "Sair", "Sidebar.new-category.badge": "Novo", "Sidebar.new-category.drag-boards-cta": "Solte quadros aqui...", "Sidebar.no-boards-in-category": "Nenhum board", "Sidebar.product-tour": "Tour pelo produto", "Sidebar.random-icons": "Ícones aleatórios", "Sidebar.set-language": "Definir linguagem", "Sidebar.set-theme": "Definir tema", "Sidebar.settings": "Configurações", "Sidebar.template-from-board": "Novo template vindo do board", "Sidebar.untitled-board": "(Quadro sem nome)", "Sidebar.untitled-view": "(View sem título)", "SidebarCategories.BlocksMenu.Move": "Mover Para...", "SidebarCategories.CategoryMenu.CreateNew": "Criar Nova Categoria", "SidebarCategories.CategoryMenu.Delete": "Excluir Categoria", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Boards em {categoryName} serão movidos de volta para categoria de Boards. Você não será removido de nenhum board.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Excluir esta categoria?", "SidebarCategories.CategoryMenu.Update": "Renomear Categoria", "SidebarTour.ManageCategories.Body": "Criar e gerenciar categorias personalizadas. Categorias são específicas para usuário, então mover um board para sua categoria não impactará outros membros usando o mesmo board.", "SidebarTour.ManageCategories.Title": "Gerenciar categorias", "SidebarTour.SearchForBoards.Body": "Abrir o alternador de board (Cmd/Ctrl + K) para buscar rapidamente e adicionar boards a sua barra lateral.", "SidebarTour.SearchForBoards.Title": "Buscar por boards", "SidebarTour.SidebarCategories.Body": "Todos seus boards agora são organizados sob sua nova barra lateral. Não é mais necessárioa alternar entre espaços de trabalho. Categorias personalizadas em suas estações prévias de trabalho foram automaticamente criadas para você como parte do seu upgrade para v7.2. Estas podem ser removidas ou editadas de acordo com a sua preferência.", "SidebarTour.SidebarCategories.Link": "Saiba mais", "SidebarTour.SidebarCategories.Title": "Categorias de barra lateral", "SiteStats.total_boards": "Total de boards", "SiteStats.total_cards": "Total de cartões", "TableComponent.add-icon": "Adicionar Ícone", "TableComponent.name": "Nome", "TableComponent.plus-new": "+ Novo", "TableHeaderMenu.delete": "Excluir", "TableHeaderMenu.duplicate": "Duplicar", "TableHeaderMenu.hide": "Esconder", "TableHeaderMenu.insert-left": "Inserir à esquerda", "TableHeaderMenu.insert-right": "Inserir à direita", "TableHeaderMenu.sort-ascending": "Ordem ascendente", "TableHeaderMenu.sort-descending": "Ordem descendente", "TableRow.DuplicateCard": "duplicar cartão", "TableRow.MoreOption": "Mais ações", "TableRow.open": "Abrir", "TopBar.give-feedback": "Dar feedback", "URLProperty.copiedLink": "Copiado!", "URLProperty.copy": "Copiar", "URLProperty.edit": "Editar", "UndoRedoHotKeys.canRedo": "Refazer", "UndoRedoHotKeys.canRedo-with-description": "Refazer {description}", "UndoRedoHotKeys.canUndo": "Desfazer", "UndoRedoHotKeys.canUndo-with-description": "Desfazer {description}", "UndoRedoHotKeys.cannotRedo": "Nada para Refazer", "UndoRedoHotKeys.cannotUndo": "Nada para Desfazer", "ValueSelector.noOptions": "Sem opções. Comece adicionando a primeira!", "ValueSelector.valueSelector": "Selecionador de valor", "ValueSelectorLabel.openMenu": "Abrir menu", "VersionMessage.help": "Verifique o que é novo nesta versão.", "VersionMessage.learn-more": "Saiba mais", "View.AddView": "Adicionar visualização", "View.Board": "Quadro", "View.DeleteView": "Excluir visualização", "View.DuplicateView": "Duplicar visualização", "View.Gallery": "Galeria", "View.NewBoardTitle": "Visualização de Quadro", "View.NewCalendarTitle": "Visualização de calendário", "View.NewGalleryTitle": "Visualização de Galeria", "View.NewTableTitle": "Visualização de Tabela", "View.NewTemplateDefaultTitle": "Template sem título", "View.NewTemplateTitle": "Sem título", "View.Table": "Tabela", "ViewHeader.add-template": "+ Novo modelo", "ViewHeader.delete-template": "Excluir", "ViewHeader.display-by": "Exibir por: {property}", "ViewHeader.edit-template": "Editar", "ViewHeader.empty-card": "Cartão vazio", "ViewHeader.export-board-archive": "Exportar arquivo do painel", "ViewHeader.export-complete": "Exportação completa!", "ViewHeader.export-csv": "Exportar para CSV", "ViewHeader.export-failed": "Falha ao exportar!", "ViewHeader.filter": "Filtrar", "ViewHeader.group-by": "Agrupar por: {property}", "ViewHeader.new": "Novo", "ViewHeader.properties": "Propriedades", "ViewHeader.properties-menu": "Menu de propriedades", "ViewHeader.search-text": "Pesquisar cartões", "ViewHeader.select-a-template": "Selecionar um modelo", "ViewHeader.set-default-template": "Definir como padrão", "ViewHeader.sort": "Ordenar", "ViewHeader.untitled": "Sem nome", "ViewHeader.view-header-menu": "Visualizar menu de cabeçalho", "ViewHeader.view-menu": "Visualizar menu", "ViewLimitDialog.Heading": "Limite de views por board alcaçado", "ViewLimitDialog.PrimaryButton.Title.Admin": "Upgrade", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Notificar Admin", "ViewLimitDialog.Subtext.Admin": "Atualize para nosso plano Profissional ou Enterprise.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Saiba mais sobre nossos planos.", "ViewLimitDialog.Subtext.RegularUser": "Notifique seu administrador para atualizar para nosso plano Professional ou Enterprise.", "ViewLimitDialog.UpgradeImg.AltText": "Atualizar imagem", "ViewLimitDialog.notifyAdmin.Success": "Seu administrador foi notificado", "ViewTitle.hide-description": "esconder descrição", "ViewTitle.pick-icon": "Escolher ícone", "ViewTitle.random-icon": "Aleatório", "ViewTitle.remove-icon": "Remover ícone", "ViewTitle.show-description": "mostrar descrição", "ViewTitle.untitled-board": "Quadro sem título", "WelcomePage.Description": "Boards é uma ferramenta de gerenciamento de projeto que ajuda a definir, organizar, rastrear, e gerenciar traabalho de vários times utilizando uma estrturtura familiar de quadro de Kanban.", "WelcomePage.Explore.Button": "Faça um tour", "WelcomePage.Heading": "Bem vindo ao Boards", "WelcomePage.NoThanks.Text": "Não obrigado, eu descubrirei sozinho", "WelcomePage.StartUsingIt.Text": "Começar a usar", "Workspace.editing-board-template": "Você está editando um modelo de quadro.", "badge.guest": "Convidado", "boardPage.confirm-join-button": "Ingressar", "boardPage.confirm-join-text": "Você está prestes a ingressar em um board privado sem ter sido explicitamente adicionado pelo administrador do quadro. Você tem certeza de que deseja ingressar neste board privado?", "boardPage.confirm-join-title": "Ingressar board privado", "boardSelector.confirm-link-board": "Linkar board para canal", "boardSelector.confirm-link-board-button": "Sim, linkar board", "boardSelector.confirm-link-board-subtext": "Quando você vincula \"{boardName}\" a um canal, todos os membros daquele canal (existentes e novos) poderão editá-lo. Isto excluirá os membros que são convidados. Você pode desvincular um board de um canal a qualquer hora.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Quando você vincula \"{boardName}\" a um canal, todos membros do canal (existentes e novas) poderão editar. Isto excluirá os membros que forem convidados. {lineBreak} Este board está atualmente vinculado a outro canal. Será desvinculado se você optar por vincular aqui.", "boardSelector.create-a-board": "Criar um board", "boardSelector.link": "Link", "boardSelector.search-for-boards": "Procurar por boards", "boardSelector.title": "Vincular boards", "boardSelector.unlink": "Desvincular", "calendar.month": "Mês", "calendar.today": "HOJE", "calendar.week": "Semana", "centerPanel.undefined": "Sem {propertyName}", "centerPanel.unknown-user": "Usuário desconhecido", "cloudMessage.learn-more": "Saiba mais", "createImageBlock.failed": "Não foi possível enviar o arquivo, pois o tamanho ultrapassou o limite permitido.", "default-properties.badges": "Comentários e descrição", "default-properties.title": "Título", "error.back-to-home": "Volta para o início", "error.back-to-team": "Volta para o time", "error.board-not-found": "Quadro não encontrado.", "error.go-login": "Log in", "error.invalid-read-only-board": "Você não possui acesso a este board. Faça log in para acessar Boards.", "error.not-logged-in": "Sua sessão pode ter expirado ou você não está logado. Faça Log in para obter acesso ao Boards.", "error.page.title": "Desculpe, algo deu errado", "error.team-undefined": "Não é um time válido.", "error.unknown": "Um erro ocorreu.", "generic.previous": "Anterior", "guest-no-board.subtitle": "Você não tem acesso a nenhum board neste time ainda, por favor aguarde até alguém adicionar você a algum board.", "guest-no-board.title": "Nenhum board ainda", "imagePaste.upload-failed": "Alguns arquivos não foram enviados, pois o tamanho ultrapassou o limite permitido.", "limitedCard.title": "Cartões ocultos", "login.log-in-button": "Entrar", "login.log-in-title": "Entrar", "login.register-button": "ou criar uma conta se você ainda não tiver uma", "new_channel_modal.create_board.empty_board_description": "Criar um novo quadro vazio", "new_channel_modal.create_board.empty_board_title": "Quadro vazio", "new_channel_modal.create_board.select_template_placeholder": "Selecionar um modelo", "new_channel_modal.create_board.title": "Criar um quadro para este canal", "notification-box-card-limit-reached.close-tooltip": "Soneca por 10 dias", "notification-box-card-limit-reached.contact-link": "notificar seu admin", "notification-box-card-limit-reached.link": "Atualizar para um plano pago", "notification-box-card-limit-reached.title": "{cards} cartões ocultos do board", "notification-box-cards-hidden.title": "Esta ação ocultou outro cartão", "notification-box.card-limit-reached.not-admin.text": "Para acessar cartões arquivados, você pode {contactLink} para atualizar para um plano pago.", "notification-box.card-limit-reached.text": "Limite de cartão alcançado, para visualizar cartões mais antigos, {link}", "person.add-user-to-board": "Adicionar {username} ao board", "person.add-user-to-board-confirm-button": "Adicionar ao board", "person.add-user-to-board-permissions": "Permissões", "person.add-user-to-board-question": "Você quer adicionar {username} ao board?", "person.add-user-to-board-warning": "{username} não é um membro do quadro e não receberá nenhuma notificação sobre ele.", "register.login-button": "ou entre se você já tem uma conta", "register.signup-title": "Registrar uma conta", "rhs-board-non-admin-msg": "Você não é um adminstrador do quadro", "rhs-boards.add": "Adicionar", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "esta mensagem direta", "rhs-boards.header.gm": "esta mensagem de grupo", "rhs-boards.last-update-at": "Última atualização em: {datetime}", "rhs-boards.link-boards-to-channel": "Vincular boards para {channelName}", "rhs-boards.linked-boards": "Boards vinculados", "rhs-boards.no-boards-linked-to-channel": "Nenhum board está vinculado a {channelName} ainda", "rhs-boards.no-boards-linked-to-channel-description": "Boards é uma ferramenta de gerenciamento de projeto que ajuda a definir, organizar, rastrear e gerenciar o trabalho entre times, usando uma visualização de quadro estilo Kaban familiar.", "rhs-boards.unlink-board": "Desvincular board", "rhs-boards.unlink-board1": "Desvincular board", "rhs-channel-boards-header.title": "Boards", "share-board.publish": "Publicar", "share-board.share": "Compartilhar", "shareBoard.channels-select-group": "Canais", "shareBoard.confirm-change-team-role.body": "Todos neste quadro com permissão mais baixa que papel \"{role}\" serão serão promovidos para {role}. Tem certeza que deseja alterar o papel mínimo para esse board?", "shareBoard.confirm-change-team-role.confirmBtnText": "Alterar papel mínimo do board", "shareBoard.confirm-change-team-role.title": "Alterar papel mínimo do board", "shareBoard.confirm-link-channel": "Vincular ao canal", "shareBoard.confirm-link-channel-button": "Vincular canal", "shareBoard.confirm-link-channel-button-with-other-channel": "Desvincular e vincular aqui", "shareBoard.confirm-link-channel-subtext": "Quando você vincula um canal a um quadro, todos os membros do canal (existentes e novos) poderão edita-lo. Isto excluirá os membros que forem convidados.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Quando você vincula um canal a um board, todos os membros de um canal (existente e novos) poderão edita-lo. Isto excluirá os membros que forem convidados {lineBreak}. Este board está vinculado a outro canal. Será desvinculado se você optar por vincula-lo aqui.", "shareBoard.confirm-unlink.body": "QUando você desvincula um canal de um board, todos os membros do canal (existentes e novos) irão perder acesso ao menos que tenham recebido permissão separadamente.", "shareBoard.confirm-unlink.confirmBtnText": "Desvincular canal", "shareBoard.confirm-unlink.title": "Desvincular canal do board", "shareBoard.lastAdmin": "Boards devem ter pelo menos um Administrador", "shareBoard.members-select-group": "Membros", "shareBoard.unknown-channel-display-name": "Canal desconhecido", "tutorial_tip.finish_tour": "Feito", "tutorial_tip.got_it": "Entendi", "tutorial_tip.ok": "Próximo", "tutorial_tip.out": "Desativar estas dicas.", "tutorial_tip.seen": "Já viu isto antes?" } ================================================ FILE: webapp/i18n/ru.json ================================================ { "AppBar.Tooltip": "Переключить связанные доски", "Attachment.Attachment-title": "Вложение", "AttachmentBlock.DeleteAction": "Удалить", "AttachmentBlock.addElement": "добавить {type}", "AttachmentBlock.delete": "Вложение удалено.", "AttachmentBlock.failed": "Не удалось загрузить файл, так как превышена квота на размер файла.", "AttachmentBlock.upload": "Загрузка вложения.", "AttachmentBlock.uploadSuccess": "Вложение загружено.", "AttachmentElement.delete-confirmation-dialog-button-text": "Удалить", "AttachmentElement.download": "Скачать", "AttachmentElement.upload-percentage": "Загрузка...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Добавить группу", "BoardComponent.delete": "Удалить", "BoardComponent.hidden-columns": "Скрытые столбцы", "BoardComponent.hide": "Скрыть", "BoardComponent.new": "+ Создать", "BoardComponent.no-property": "{property} пусто", "BoardComponent.no-property-title": "Здесь будут элементы с пустым свойством {property}. Этот столбец не может быть удален.", "BoardComponent.show": "Показать", "BoardMember.schemeAdmin": "Администратор", "BoardMember.schemeCommenter": "Комментатор", "BoardMember.schemeEditor": "Редактор", "BoardMember.schemeNone": "Никто", "BoardMember.schemeViewer": "Наблюдатель", "BoardMember.unlinkChannel": "Отключить", "BoardPage.newVersion": "Доступна новая версия Доски. Нажмите здесь, чтобы перезагрузить.", "BoardPage.syncFailed": "Доска может быть удалена или доступ аннулирован.", "BoardTemplateSelector.add-template": "Новый шаблон", "BoardTemplateSelector.create-empty-board": "Создать пустую доску", "BoardTemplateSelector.delete-template": "Удалить", "BoardTemplateSelector.description": "Добавьте доску на боковую панель, используя любой из шаблонов, описанных ниже, или начните с нуля.", "BoardTemplateSelector.edit-template": "Изменить", "BoardTemplateSelector.plugin.no-content-description": "Добавьте доску на боковую панель, используя любой из указанных ниже шаблонов, или начните с нуля.", "BoardTemplateSelector.plugin.no-content-title": "Создать доску", "BoardTemplateSelector.title": "Создать доску", "BoardTemplateSelector.use-this-template": "Использовать этот шаблон", "BoardsSwitcher.Title": "Найти доски", "BoardsUnfurl.Limited": "Информация скрыта, потому что карточка находится в архиве", "BoardsUnfurl.Remainder": "+{remainder} ещё", "BoardsUnfurl.Updated": "Обновлено {time}", "Calculations.Options.average.displayName": "Среднее", "Calculations.Options.average.label": "Среднее", "Calculations.Options.count.displayName": "Итого", "Calculations.Options.count.label": "Итого", "Calculations.Options.countChecked.displayName": "Проверено", "Calculations.Options.countChecked.label": "Итог проверен", "Calculations.Options.countUnchecked.displayName": "Не проверен", "Calculations.Options.countUnchecked.label": "Итог не проверен", "Calculations.Options.countUniqueValue.displayName": "Уникальный", "Calculations.Options.countUniqueValue.label": "Итого уникальных значений", "Calculations.Options.countValue.displayName": "Значения", "Calculations.Options.countValue.label": "Итоговое значение", "Calculations.Options.dateRange.displayName": "Диапазон", "Calculations.Options.dateRange.label": "Диапазон", "Calculations.Options.earliest.displayName": "Ранний", "Calculations.Options.earliest.label": "Ранний", "Calculations.Options.latest.displayName": "Последний", "Calculations.Options.latest.label": "Последний", "Calculations.Options.max.displayName": "Максимальный", "Calculations.Options.max.label": "Максимальный", "Calculations.Options.median.displayName": "Медиана", "Calculations.Options.median.label": "Медиана", "Calculations.Options.min.displayName": "Минимальный", "Calculations.Options.min.label": "Минимальный", "Calculations.Options.none.displayName": "Вычислить", "Calculations.Options.none.label": "Ничто", "Calculations.Options.percentChecked.displayName": "Проверено", "Calculations.Options.percentChecked.label": "Процент проверенных", "Calculations.Options.percentUnchecked.displayName": "Непроверенный", "Calculations.Options.percentUnchecked.label": "Процент непроверенных", "Calculations.Options.range.displayName": "Диапазон", "Calculations.Options.range.label": "Диапазон", "Calculations.Options.sum.displayName": "Сумма", "Calculations.Options.sum.label": "Сумма", "CalendarCard.untitled": "Без названия", "CardActionsMenu.copiedLink": "Скопировано!", "CardActionsMenu.copyLink": "Копировать ссылку", "CardActionsMenu.delete": "Удалить", "CardActionsMenu.duplicate": "Дублировать", "CardBadges.title-checkboxes": "Флажки", "CardBadges.title-comments": "Комментарии", "CardBadges.title-description": "Эта карточка имеет описание", "CardDetail.Attach": "Прикреплять", "CardDetail.Follow": "Отслеживать", "CardDetail.Following": "Отслеживание", "CardDetail.add-content": "Добавить контент", "CardDetail.add-icon": "Добавить иконку", "CardDetail.add-property": "+ Добавить свойство", "CardDetail.addCardText": "добавить текст карточки", "CardDetail.limited-body": "Перейдите на наш тарифный план Professional или Enterprise, чтобы просматривать архивные карточки, иметь неограниченное количество просмотров для каждой доски, неограниченное количество карточек и многое другое.", "CardDetail.limited-button": "Обновление", "CardDetail.limited-title": "Эта карточка скрыта", "CardDetail.moveContent": "переместить содержимое карты", "CardDetail.new-comment-placeholder": "Добавить комментарий...", "CardDetailProperty.confirm-delete-heading": "Подтвердить удаление свойства", "CardDetailProperty.confirm-delete-subtext": "Вы действительно хотите удалить свойство \"{propertyName}\"? При его удалении свойство будет удалено со всех карточек на этой доске.", "CardDetailProperty.confirm-property-name-change-subtext": "Вы действительно хотите изменить свойство \"{propertyName}\" {customText}? Это повлияет на значение(-я) на {numOfCards} карточке(-ах) на этой доске и может привести к потере данных.", "CardDetailProperty.confirm-property-type-change": "Подтвердите изменение типа свойства", "CardDetailProperty.delete-action-button": "Удалить", "CardDetailProperty.property-change-action-button": "Изменить свойство", "CardDetailProperty.property-changed": "Свойство изменено успешно!", "CardDetailProperty.property-deleted": "{propertyName} успешно удалено!", "CardDetailProperty.property-name-change-subtext": "тип из \"{oldPropType}\" в \"{newPropType}\"", "CardDetial.limited-link": "Узнайте больше о наших планах.", "CardDialog.delete-confirmation-dialog-attachment": "Подтвердите удаление вложения", "CardDialog.delete-confirmation-dialog-button-text": "Удалить", "CardDialog.delete-confirmation-dialog-heading": "Подтвердите удаление карточки", "CardDialog.editing-template": "Вы редактируете шаблон.", "CardDialog.nocard": "Эта карточка не существует или недоступна.", "Categories.CreateCategoryDialog.CancelText": "Отмена", "Categories.CreateCategoryDialog.CreateText": "Создать", "Categories.CreateCategoryDialog.Placeholder": "Назовите свою категорию", "Categories.CreateCategoryDialog.UpdateText": "Обновить", "CenterPanel.Login": "Логин", "CenterPanel.Share": "Поделиться", "ChannelIntro.CreateBoard": "Создать доску", "ColorOption.selectColor": "Выберите цвет {color}", "Comment.delete": "Удалить", "CommentsList.send": "Отправить", "ConfirmPerson.empty": "Пусто", "ConfirmPerson.search": "Поиск...", "ConfirmationDialog.cancel-action": "Отмена", "ConfirmationDialog.confirm-action": "Подтвердить", "ContentBlock.Delete": "Удалить", "ContentBlock.DeleteAction": "удалить", "ContentBlock.addElement": "добавить {type}", "ContentBlock.checkbox": "флажок", "ContentBlock.divider": "разделитель", "ContentBlock.editCardCheckbox": "помеченный флажок", "ContentBlock.editCardCheckboxText": "редактировать текст карточки", "ContentBlock.editCardText": "редактировать текст карточки", "ContentBlock.editText": "Изменить текст...", "ContentBlock.image": "изображение", "ContentBlock.insertAbove": "Вставить выше", "ContentBlock.moveBlock": "переместить содержимое карточки", "ContentBlock.moveDown": "Опустить", "ContentBlock.moveUp": "Поднять", "ContentBlock.text": "текст", "DateRange.clear": "Очистить", "DateRange.empty": "Пусто", "DateRange.endDate": "Дата окончания", "DateRange.today": "Сегодня", "DeleteBoardDialog.confirm-cancel": "Отмена", "DeleteBoardDialog.confirm-delete": "Удалить", "DeleteBoardDialog.confirm-info": "Вы уверены, что хотите удалить доску \"{boardTitle}\"? Ее удаление приведет к удалению всех карточек на доске.", "DeleteBoardDialog.confirm-info-template": "Вы уверены, что хотите удалить шаблон доски “{boardTitle}”?", "DeleteBoardDialog.confirm-tite": "Подтвердить удаление доски", "DeleteBoardDialog.confirm-tite-template": "Подтвердите удаление шаблона Доски", "Dialog.closeDialog": "Закрыть диалог", "EditableDayPicker.today": "Сегодня", "Error.mobileweb": "Мобильная веб-поддержка в настоящее время находится на ранней стадии бета-тестирования. Могут присутствовать не все функции.", "Error.websocket-closed": "Соединение через веб-сокет закрыто, соединение прервано. Если это не устраняется, проверьте конфигурацию сервера или веб-прокси.", "Filter.contains": "содержит", "Filter.ends-with": "заканчивается", "Filter.includes": "содержит", "Filter.is": "является", "Filter.is-empty": "пусто", "Filter.is-not-empty": "не пусто", "Filter.is-not-set": "не установлен", "Filter.is-set": "установлен", "Filter.not-contains": "не содержит", "Filter.not-ends-with": "не заканчивается", "Filter.not-includes": "не содержит", "Filter.not-starts-with": "не начинается с", "Filter.starts-with": "начинается с", "FilterByText.placeholder": "фильтровать текст", "FilterComponent.add-filter": "+ Добавить фильтр", "FilterComponent.delete": "Удалить", "FilterValue.empty": "(пусто)", "FindBoardsDialog.IntroText": "Поиск досок", "FindBoardsDialog.NoResultsFor": "Нет результатов для \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Проверьте правильность написания или попробуйте другой запрос.", "FindBoardsDialog.SubTitle": "Введите запрос, чтобы найти доску. Используйте ВВЕРХ/ВНИЗ для просмотра. ENTER для выбора, ESC для закрытия", "FindBoardsDialog.Title": "Найти доски", "GroupBy.hideEmptyGroups": "Скрыть {count} пустых групп", "GroupBy.showHiddenGroups": "Показать {count} скрытых групп", "GroupBy.ungroup": "Разгруппировать", "HideBoard.MenuOption": "Скрыть доску", "KanbanCard.untitled": "Без названия", "MentionSuggestion.is-not-board-member": "(не член правления)", "Mutator.new-board-from-template": "новая доска из шаблона", "Mutator.new-card-from-template": "новая карточка из шаблона", "Mutator.new-template-from-card": "новый шаблон из карточки", "OnboardingTour.AddComments.Body": "Вы можете комментировать проблемы и даже @упоминать своих коллег-пользователей Mattermost, чтобы привлечь их внимание.", "OnboardingTour.AddComments.Title": "Добавить комментарии", "OnboardingTour.AddDescription.Body": "Добавьте описание к своей карточке, чтобы Ваши коллеги по команде знали, о чем эта карточка.", "OnboardingTour.AddDescription.Title": "Добавить описание", "OnboardingTour.AddProperties.Body": "Добавляйте различные свойства карточкам, чтобы сделать их более значительными.", "OnboardingTour.AddProperties.Title": "Добавить свойства", "OnboardingTour.AddView.Body": "Перейдите сюда, чтобы создать новый вид для организации доски с использованием различных макетов.", "OnboardingTour.AddView.Title": "Добавить новый вид", "OnboardingTour.CopyLink.Body": "Вы можете поделиться своими карточками с коллегами по команде, скопировав ссылку и вставив ее в канал, личное сообщение или групповое сообщение.", "OnboardingTour.CopyLink.Title": "Копировать ссылку", "OnboardingTour.OpenACard.Body": "Откройте карточку, чтобы изучить мощные способы, с помощью которых Доски могут помочь Вам организовать Вашу работу.", "OnboardingTour.OpenACard.Title": "Открыть карточку", "OnboardingTour.ShareBoard.Body": "Вы можете поделиться своей доской внутри своей команды или опубликовать ее для общего доступа за пределами Вашей организации.", "OnboardingTour.ShareBoard.Title": "Поделиться доской", "PersonProperty.board-members": "Совет директоров", "PersonProperty.non-board-members": "Не члены правления", "PropertyMenu.Delete": "Удалить", "PropertyMenu.changeType": "Изменить тип свойства", "PropertyMenu.selectType": "Выберите тип свойства", "PropertyMenu.typeTitle": "Тип", "PropertyType.Checkbox": "Флажок", "PropertyType.CreatedBy": "Создано пользователем", "PropertyType.CreatedTime": "Время создания", "PropertyType.Date": "Дата", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "Несколько человек", "PropertyType.MultiSelect": "Многократный выбор", "PropertyType.Number": "Номер", "PropertyType.Person": "Персона", "PropertyType.Phone": "Телефон", "PropertyType.Select": "Выбрать", "PropertyType.Text": "Текст", "PropertyType.Unknown": "Неизвестный", "PropertyType.UpdatedBy": "Обновлено пользователем", "PropertyType.UpdatedTime": "Время обновления", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Пустой", "RegistrationLink.confirmRegenerateToken": "Это сделает недействительными ссылки, которые ранее были общими. Продолжить?", "RegistrationLink.copiedLink": "Скопировано!", "RegistrationLink.copyLink": "Скопировать ссылку", "RegistrationLink.description": "Поделитесь этой ссылкой с другими для создания аккаунтов:", "RegistrationLink.regenerateToken": "Пересоздать токен", "RegistrationLink.tokenRegenerated": "Регистрационная ссылка пересоздана", "ShareBoard.PublishDescription": "Публикуйте и делитесь ссылкой \"только для чтения\" со всеми пользователями сети.", "ShareBoard.PublishTitle": "Опубликовать в Интернете", "ShareBoard.ShareInternal": "Поделиться внутри организации", "ShareBoard.ShareInternalDescription": "Пользователи, у которых есть разрешения, смогут использовать эту ссылку.", "ShareBoard.Title": "Поделится Доской", "ShareBoard.confirmRegenerateToken": "Это сделает недействительными ссылки, которые ранее были общими. Продолжить?", "ShareBoard.copiedLink": "Скопировано!", "ShareBoard.copyLink": "Скопировать ссылку", "ShareBoard.regenerate": "Восстановить токен", "ShareBoard.searchPlaceholder": "Поиск людей", "ShareBoard.teamPermissionsText": "Все в команде {teamName}", "ShareBoard.tokenRegenrated": "Токен пересоздан", "ShareBoard.userPermissionsRemoveMemberText": "Удалить участника", "ShareBoard.userPermissionsYouText": "(Вы)", "ShareTemplate.Title": "Поделиться Шаблоном", "ShareTemplate.searchPlaceholder": "Поиск людей", "Sidebar.about": "О Focalboard", "Sidebar.add-board": "+ Добавить доску", "Sidebar.changePassword": "Изменить пароль", "Sidebar.delete-board": "Удалить доску", "Sidebar.duplicate-board": "Дублировать доску", "Sidebar.export-archive": "Экспорт архива", "Sidebar.import": "Импорт", "Sidebar.import-archive": "Импорт архива", "Sidebar.invite-users": "Пригласить пользователей", "Sidebar.logout": "Выйти", "Sidebar.new-category.badge": "Новый", "Sidebar.new-category.drag-boards-cta": "Перетащите сюда доски...", "Sidebar.no-boards-in-category": "Без досок внутри", "Sidebar.product-tour": "Экскурсия по продукту", "Sidebar.random-icons": "Случайные иконки", "Sidebar.set-language": "Язык", "Sidebar.set-theme": "Тема", "Sidebar.settings": "Настройки", "Sidebar.template-from-board": "Новый шаблон из доски", "Sidebar.untitled-board": "(Доска без названия)", "Sidebar.untitled-view": "(Безымянный вид)", "SidebarCategories.BlocksMenu.Move": "Перейти к...", "SidebarCategories.CategoryMenu.CreateNew": "Создать новую категорию", "SidebarCategories.CategoryMenu.Delete": "Удалить категорию", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Доски в {categoryName} вернутся к категориям \"Доски\". Вы не удалены ни с одной доски.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Удалить эту категорию?", "SidebarCategories.CategoryMenu.Update": "Переименовать категорию", "SidebarTour.ManageCategories.Body": "Создавайте и управляйте пользовательскими категориями. Категории зависят от пользователя, поэтому перемещение доски в вашу категорию не повлияет на других участников, использующих ту же доску.", "SidebarTour.ManageCategories.Title": "Управление категориями", "SidebarTour.SearchForBoards.Body": "Откройте переключатель досок (Cmd/Ctrl + K), чтобы быстро найти и добавить доски на боковую панель.", "SidebarTour.SearchForBoards.Title": "Поиск досок", "SidebarTour.SidebarCategories.Body": "Все ваши доски теперь организованы под новой боковой панелью. Больше не нужно переключаться между рабочими пространствами. Одноразовые настраиваемые категории, основанные на ваших предыдущих рабочих областях, могли быть автоматически созданы для вас в рамках обновления до версии 7.2. Их можно удалить или отредактировать по своему усмотрению.", "SidebarTour.SidebarCategories.Link": "Учить больше", "SidebarTour.SidebarCategories.Title": "Категории боковой панели", "SiteStats.total_boards": "Всего досок", "SiteStats.total_cards": "Всего карт", "TableComponent.add-icon": "Добавить иконку", "TableComponent.name": "Название", "TableComponent.plus-new": "+ Создать", "TableHeaderMenu.delete": "Удалить", "TableHeaderMenu.duplicate": "Создать дубликат", "TableHeaderMenu.hide": "Скрыть", "TableHeaderMenu.insert-left": "Вставить слева", "TableHeaderMenu.insert-right": "Вставить справа", "TableHeaderMenu.sort-ascending": "Сортировать по возрастанию", "TableHeaderMenu.sort-descending": "Сортировать по убыванию", "TableRow.DuplicateCard": "дублировать карточку", "TableRow.MoreOption": "Больше действий", "TableRow.open": "Открыть", "TopBar.give-feedback": "Дать обратную связь", "URLProperty.copiedLink": "Скопировано!", "URLProperty.copy": "Копировать", "URLProperty.edit": "Изменить", "UndoRedoHotKeys.canRedo": "Повторить", "UndoRedoHotKeys.canRedo-with-description": "Повторить {description}", "UndoRedoHotKeys.canUndo": "Отменить", "UndoRedoHotKeys.canUndo-with-description": "Отменить {description}", "UndoRedoHotKeys.cannotRedo": "Нечего переделывать", "UndoRedoHotKeys.cannotUndo": "Нечего отменить", "ValueSelector.noOptions": "Нет вариантов. Начните печатать, чтобы добавить первый!", "ValueSelector.valueSelector": "Выбор значения", "ValueSelectorLabel.openMenu": "Открыть меню", "VersionMessage.help": "Узнайте, что нового в этой версии.", "View.AddView": "Добавить вид", "View.Board": "Доска", "View.DeleteView": "Удалить вид", "View.DuplicateView": "Создать дубликат вида", "View.Gallery": "Галерея", "View.NewBoardTitle": "Вид доски", "View.NewCalendarTitle": "Просмотр календаря", "View.NewGalleryTitle": "Представление \"галерея\"", "View.NewTableTitle": "Вид таблицы", "View.NewTemplateDefaultTitle": "Шаблон без названия", "View.NewTemplateTitle": "Шаблон", "View.Table": "Таблица", "ViewHeader.add-template": "Новый шаблон", "ViewHeader.delete-template": "Удалить", "ViewHeader.display-by": "Показать по: {property}", "ViewHeader.edit-template": "Изменить", "ViewHeader.empty-card": "Очистить карточку", "ViewHeader.export-board-archive": "Экспорт архива доски", "ViewHeader.export-complete": "Экспорт завершен!", "ViewHeader.export-csv": "Экспорт в CSV", "ViewHeader.export-failed": "Ошибка экспорта!", "ViewHeader.filter": "Фильтр", "ViewHeader.group-by": "Сгруппировать по: {property}", "ViewHeader.new": "Создать", "ViewHeader.properties": "Свойства", "ViewHeader.properties-menu": "Меню свойств", "ViewHeader.search-text": "Карточки поиска", "ViewHeader.select-a-template": "Выбрать шаблон", "ViewHeader.set-default-template": "Установить по умолчанию", "ViewHeader.sort": "Сортировать", "ViewHeader.untitled": "Без названия", "ViewHeader.view-header-menu": "Посмотреть меню заголовка", "ViewHeader.view-menu": "Посмотреть меню", "ViewLimitDialog.Heading": "Достигнут лимит просмотров на доске", "ViewLimitDialog.PrimaryButton.Title.Admin": "Обновление", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Сообщить администратору", "ViewLimitDialog.Subtext.Admin": "Перейдите на наш план Professional или Enterprise, чтобы иметь неограниченное количество просмотров на досках, неограниченное количество карточек и многое другое.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Узнайте больше о наших тарифах.", "ViewLimitDialog.Subtext.RegularUser": "Сообщите своему администратору, чтобы перейти на наш план Professional или Enterprise, чтобы иметь неограниченные количество просмотров на доске, карточек и многое другое.", "ViewLimitDialog.UpgradeImg.AltText": "обновить изображение", "ViewLimitDialog.notifyAdmin.Success": "Ваш администратор был уведомлен", "ViewTitle.hide-description": "скрыть описание", "ViewTitle.pick-icon": "Выбрать иконку", "ViewTitle.random-icon": "Случайным образом", "ViewTitle.remove-icon": "Убрать иконку", "ViewTitle.show-description": "Показать описание", "ViewTitle.untitled-board": "Доска без названия", "WelcomePage.Description": "Доски — это инструмент управления проектами, который помогает определять, организовывать, отслеживать и управлять работой между командами, используя знакомое представление доски Kanban.", "WelcomePage.Explore.Button": "Исследовать", "WelcomePage.Heading": "Добро пожаловать на Доски", "WelcomePage.NoThanks.Text": "Нет спасибо, сам разберусь", "WelcomePage.StartUsingIt.Text": "Начать пользоваться", "Workspace.editing-board-template": "Вы редактируете шаблон доски.", "badge.guest": "Гость", "boardSelector.confirm-link-board": "Привязать доску к каналу", "boardSelector.confirm-link-board-button": "Да, ссылка доски", "boardSelector.confirm-link-board-subtext": "Связывание доски \"{boardName}\" с каналом даст всем участникам канала доступ на редактирование доски. Вы можете в любое время отвязать доску о канала.", "boardSelector.confirm-link-board-subtext-with-other-channel": "Привязка \"{boardName}\" с каналом приведет к возможности её редактирования всеми участниками канала (существующими и новыми). Кроме гостей канала.{lineBreak} Эта доска сейчас связана с другим каналом. Он будет отключен, если вы решите изменить привязку.", "boardSelector.create-a-board": "Создать доску", "boardSelector.link": "Ссылка", "boardSelector.search-for-boards": "Поиск досок", "boardSelector.title": "Ссылки на доски", "boardSelector.unlink": "Отключить", "calendar.month": "Месяц", "calendar.today": "СЕГОДНЯ", "calendar.week": "Неделя", "centerPanel.undefined": "Отсутствует {propertyName}", "centerPanel.unknown-user": "Неизвестный пользователь", "cloudMessage.learn-more": "Учить больше", "createImageBlock.failed": "Не удалось загрузить файл. Достигнут предел размера файла.", "default-properties.badges": "Комментарии и описание", "default-properties.title": "Заголовок", "error.back-to-home": "Вернуться на Главную", "error.back-to-team": "Вернуться в команду", "error.board-not-found": "Доска не найдена.", "error.go-login": "Логин", "error.invalid-read-only-board": "У Вас нет доступа к этой доске. Войдите, чтобы получить доступ к Доскам.", "error.not-logged-in": "Возможно, срок действия Вашего сеанса истек или Вы не вошли в систему. Войдите еще раз, чтобы получить доступ к Доскам.", "error.page.title": "Извините, что-то пошло не так", "error.team-undefined": "Не корректная команда.", "error.unknown": "Произошла ошибка.", "generic.previous": "Предыдущий", "imagePaste.upload-failed": "Некоторые файлы не загружены из-за превышения квоты на размер файла.", "limitedCard.title": "Карточки скрыты", "login.log-in-button": "Вход в систему", "login.log-in-title": "Вход в систему", "login.register-button": "или создать аккаунт, если у Вас его нет", "new_channel_modal.create_board.empty_board_description": "Создать новую пустую доску", "new_channel_modal.create_board.empty_board_title": "Пустая доска", "new_channel_modal.create_board.select_template_placeholder": "Выбрать шаблон", "new_channel_modal.create_board.title": "Создать доску для этого канала", "notification-box-card-limit-reached.close-tooltip": "Отложить на 10 дней", "notification-box-card-limit-reached.contact-link": "уведомить Вашего администратора", "notification-box-card-limit-reached.link": "Перейти на платный тариф", "notification-box-card-limit-reached.title": "{cards} карты, скрытые на доске", "notification-box-cards-hidden.title": "Это действие скрыло другую карточку", "notification-box.card-limit-reached.not-admin.text": "Чтобы получить доступ к архивным карточкам, Вы можете {contactLink} перейти на платный тариф.", "notification-box.card-limit-reached.text": "Достигнут лимит карточки, чтобы просмотреть старые карточки, {link}", "person.add-user-to-board": "Добавить {username} на доску", "person.add-user-to-board-confirm-button": "Добавить доску", "person.add-user-to-board-permissions": "Разрешения", "person.add-user-to-board-question": "Вы хотите добавить {username} на доску?", "register.login-button": "или войти в систему, если у вас уже есть аккаунт", "register.signup-title": "Зарегистрируйте свой аккаунт", "rhs-board-non-admin-msg": "Вы не являетесь администратором этой доски", "rhs-boards.add": "Добавить", "rhs-boards.last-update-at": "Последнее обновление: {datetime}", "rhs-boards.link-boards-to-channel": "Связать доски с {channelName}", "rhs-boards.linked-boards": "Связанные доски", "rhs-boards.no-boards-linked-to-channel": "К каналу {channelName} пока не подключены доски", "rhs-boards.no-boards-linked-to-channel-description": "Доски — это инструмент управления проектами, который помогает определять, организовывать, отслеживать и управлять работой между командами, используя знакомое представление доски Канбан.", "rhs-boards.unlink-board": "Отвязать доску", "rhs-channel-boards-header.title": "Доски", "share-board.publish": "Опубликовать", "share-board.share": "Поделиться", "shareBoard.channels-select-group": "Каналы", "shareBoard.lastAdmin": "Доски должны иметь хотя бы одного администратора", "shareBoard.members-select-group": "Участники", "tutorial_tip.finish_tour": "Готово", "tutorial_tip.got_it": "Понятно", "tutorial_tip.ok": "Следующий", "tutorial_tip.out": "Отказаться от этих советов.", "tutorial_tip.seen": "Видели это раньше?" } ================================================ FILE: webapp/i18n/sk.json ================================================ { "Attachment.Attachment-title": "Príloha", "AttachmentBlock.DeleteAction": "odstrániť", "AttachmentBlock.delete": "Príloha odstránená.", "AttachmentBlock.failed": "Tento súbor nebol nahratý, pretože presiahol veľkostný limit.", "AttachmentBlock.upload": "Príloha sa nahráva.", "AttachmentBlock.uploadSuccess": "Príloha bola nahratá.", "AttachmentElement.delete-confirmation-dialog-button-text": "Odstrániť", "AttachmentElement.download": "Stiahnuť", "AttachmentElement.upload-percentage": "Nahrávam... ({uploadPercent}%)", "BoardComponent.add-a-group": "+ Pridaj skupinu", "BoardComponent.delete": "Odstrániť", "BoardComponent.hidden-columns": "Skryté stĺpce", "BoardComponent.hide": "Skryť", "BoardComponent.new": "+ Nový", "BoardComponent.no-property": "žiadna {property}", "BoardComponent.no-property-title": "Položky s prázdnou {property} pôjdu tu. Tento stĺpec nemožno vymazať.", "BoardComponent.show": "Ukáž", "BoardMember.schemeAdmin": "Administrátor", "BoardMember.schemeCommenter": "Komentátor", "BoardMember.schemeEditor": "Editor", "BoardMember.schemeNone": "Žiadny", "BoardMember.schemeViewer": "Sledovateľ", "BoardMember.unlinkChannel": "Odpojiť", "BoardPage.newVersion": "Nová verzia je dostupná, kliknite tu pre znovu načítanie.", "BoardPage.syncFailed": "Nástenka môže byť vymazaná alebo prístup odobraný.", "BoardTemplateSelector.add-template": "Vytvoriť novú šablónu", "BoardTemplateSelector.create-empty-board": "Vytvoriť prázdnu nástenku", "BoardTemplateSelector.delete-template": "Odstrániť", "BoardTemplateSelector.description": "Pridajte nástenku do bočného panelu pomocou ktorýchkoľvek šablón definovaných dole alebo začnite od začiatku.", "BoardTemplateSelector.edit-template": "Upraviť", "BoardTemplateSelector.plugin.no-content-description": "Pridajte nástenku do bočného panelu pomocou ktorýchkoľvek šablón dole alebo začnite od začiatku.", "BoardTemplateSelector.plugin.no-content-title": "Vytvoriť nástenku", "BoardTemplateSelector.title": "Vytvoriť nástenku", "BoardTemplateSelector.use-this-template": "Použiť túto šablónu", "BoardsSwitcher.Title": "Hľadať nástenky", "BoardsUnfurl.Limited": "Ďalšie detaily sú skryté, pretože je karta archivovaná", "BoardsUnfurl.Remainder": "+{remainder} viac", "BoardsUnfurl.Updated": "Upravené {time}", "Calculations.Options.average.displayName": "Priemer", "Calculations.Options.average.label": "Priemer", "Calculations.Options.count.displayName": "Počet", "Calculations.Options.count.label": "Počet", "Calculations.Options.countChecked.displayName": "Označené", "Calculations.Options.countChecked.label": "Počítať označené", "Calculations.Options.countUnchecked.displayName": "Neoznačené", "Calculations.Options.countUnchecked.label": "Počítať neoznačené", "Calculations.Options.countUniqueValue.displayName": "Unikátne", "Calculations.Options.countUniqueValue.label": "Počítať unikátne hodnoty", "Calculations.Options.countValue.displayName": "Hodnoty", "Calculations.Options.countValue.label": "Počítať hodnotu", "Calculations.Options.dateRange.displayName": "Rozsah", "Calculations.Options.dateRange.label": "Rozsah", "Calculations.Options.earliest.displayName": "Prvý", "Calculations.Options.earliest.label": "Prvý", "Calculations.Options.latest.displayName": "Posledný", "Calculations.Options.latest.label": "Posledný", "Calculations.Options.max.displayName": "Max", "Calculations.Options.max.label": "Max", "Calculations.Options.median.displayName": "Medián", "Calculations.Options.median.label": "Medián", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Vypočítať", "Calculations.Options.none.label": "Nič", "Calculations.Options.percentChecked.displayName": "Označené", "Calculations.Options.percentChecked.label": "Percent skontrolovaných", "Calculations.Options.percentUnchecked.displayName": "Neskontrolované", "Calculations.Options.percentUnchecked.label": "Percent neskontrolovaných", "Calculations.Options.range.displayName": "Rozsah", "Calculations.Options.range.label": "Rozsah", "Calculations.Options.sum.displayName": "Súčet", "Calculations.Options.sum.label": "Súčet", "CalendarCard.untitled": "Bez názvu", "CardActionsMenu.copiedLink": "Skopírované!", "CardActionsMenu.copyLink": "Skopírovať odkaz", "CardActionsMenu.delete": "Odstrániť", "CardActionsMenu.duplicate": "Duplikovať", "CardBadges.title-checkboxes": "Začiarkávacie políčka", "CardBadges.title-comments": "Komentáre", "CardBadges.title-description": "Táto karta má popis", "CardDetail.Attach": "Priložiť", "CardDetail.Follow": "Sledovať", "CardDetail.Following": "Sledujúci", "CardDetail.add-content": "Pridať obsah", "CardDetail.add-icon": "Pridať ikonu", "CardDetail.add-property": "+ Pridať vlastnosť", "CardDetail.addCardText": "pridať text karty", "CardDetail.limited-body": "Vylepšiť na náš Professional alebo Enterprise plán.", "CardDetail.limited-button": "Zmeniť plán", "CardDetail.limited-title": "Táto karta je skrytá", "CardDetail.moveContent": "Presunúť obsah karty", "CardDetail.new-comment-placeholder": "Pridať komentár...", "CardDetailProperty.confirm-delete-heading": "Potvrdiť vymazanie vlastnosti", "CardDetailProperty.confirm-delete-subtext": "Skutočne chcete vymazať hodnotu \"{propertyName}\"? Bude odstránená zo všetkých kariet na tejto tabuli.", "CardDetailProperty.confirm-property-name-change-subtext": "Skutočne chcete zmeniť hodnotu \"{propertyName}\" {customText}? Ovplyvní to {numOfCards} kariet na tabuli a môže viesť k strate dát.", "CardDetailProperty.confirm-property-type-change": "Potvrdiť zmenu typu vlastnosti", "CardDetailProperty.delete-action-button": "Odstrániť", "CardDetailProperty.property-change-action-button": "Zmeniť vlastnosť", "CardDetailProperty.property-changed": "Zmena vlastnosti úspešná!", "CardDetailProperty.property-deleted": "Odstránenie {propertyName} úspešné!", "CardDetailProperty.property-name-change-subtext": "typ z \"{oldPropType}\" na \"{newPropType}\"", "CardDetial.limited-link": "Dozvedieť sa viac o našich plánoch.", "CardDialog.delete-confirmation-dialog-attachment": "Potvrdiť odstránenie prílohy", "CardDialog.delete-confirmation-dialog-button-text": "Odstrániť", "CardDialog.delete-confirmation-dialog-heading": "Potvrdiť odstránenie karty", "CardDialog.editing-template": "Upravujete šablónu.", "CardDialog.nocard": "Táto karta neexistuje alebo nie je prístupná.", "Categories.CreateCategoryDialog.CancelText": "Zrušiť", "Categories.CreateCategoryDialog.CreateText": "Vytvoriť", "Categories.CreateCategoryDialog.Placeholder": "Nazvite Vašu kategóriu", "Categories.CreateCategoryDialog.UpdateText": "Zmeniť", "CenterPanel.Login": "Prihlásiť sa", "CenterPanel.Share": "Zdieľať", "ChannelIntro.CreateBoard": "Vytvoriť nástenku", "ColorOption.selectColor": "Vyberte {color} farbu", "Comment.delete": "Odstrániť", "CommentsList.send": "Odoslať", "ConfirmPerson.empty": "Prázdne", "ConfirmPerson.search": "Vyhľadať...", "ConfirmationDialog.cancel-action": "Zrušiť", "ConfirmationDialog.confirm-action": "Potvrdiť", "ContentBlock.Delete": "Odstrániť", "ContentBlock.DeleteAction": "odstrániť", "ContentBlock.addElement": "pridať {type}", "ContentBlock.checkbox": "začiarkávacie pole", "ContentBlock.divider": "oddeľovač", "ContentBlock.editCardCheckbox": "Začiarknuté pole", "ContentBlock.editCardCheckboxText": "upraviť text karty", "ContentBlock.editCardText": "upraviť text karty", "ContentBlock.editText": "Upraviť text...", "ContentBlock.image": "obrázok", "ContentBlock.insertAbove": "Vložiť nad", "ContentBlock.moveBlock": "presunúť obsah karty", "ContentBlock.moveDown": "Presunúť dole", "ContentBlock.moveUp": "Presunúť hore", "ContentBlock.text": "text", "DateRange.clear": "Vyčistiť", "DateRange.empty": "Prázdny", "DateRange.endDate": "Koncový dátum", "DateRange.today": "Dnes", "DeleteBoardDialog.confirm-cancel": "Zrušiť", "DeleteBoardDialog.confirm-delete": "Odstrániť", "DeleteBoardDialog.confirm-info": "Naozaj chcete odstrániť nástenku “{boardTitle}”? Odstránením vymažete všetky karty na tabuli.", "DeleteBoardDialog.confirm-info-template": "Naozaj chcete odstrániť nástenkovú šablónu \"{boardTitle}\"?", "DeleteBoardDialog.confirm-tite": "Potvrďte odstránenie nástenky", "DeleteBoardDialog.confirm-tite-template": "Potvrdiť odstránenie šablóny nástenky", "Dialog.closeDialog": "Zatvoriť dialógové okno", "EditableDayPicker.today": "Dnes", "Error.mobileweb": "Podpora pre mobilné prehliadače je v skorej bete. Niektoré funkcionality môžu chýbať.", "Error.websocket-closed": "Websocket pripojenie zlyhalo - bolo prerušené. Pokiaľ problém pretrváva, skontrolujte konfiguráciu servera.", "Filter.contains": "obsahuje", "Filter.ends-with": "končí s", "Filter.includes": "zahŕňa", "Filter.is": "je", "Filter.is-empty": "je prázdny", "Filter.is-not-empty": "nie je prázdny", "Filter.is-not-set": "nie je nastavený", "Filter.is-set": "je nastavený", "Filter.not-contains": "neobsahuje", "Filter.not-ends-with": "nekončí s", "Filter.not-includes": "nezahŕňa", "Filter.not-starts-with": "nezačína s", "Filter.starts-with": "začína s", "FilterByText.placeholder": "text filtra", "FilterComponent.add-filter": "+ Pridaj filter", "FilterComponent.delete": "Odstrániť", "FilterValue.empty": "(prázdny)", "FindBoardsDialog.IntroText": "Vyhľadať nástenky", "FindBoardsDialog.NoResultsFor": "Žiadne výsledky pre \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Skontrolujte pravopis alebo vyskúšajte iný pojem.", "FindBoardsDialog.SubTitle": "Nájdite nástenku písaním. Použite HORE/DOLE na prehliadanie, ENTER na vybratie a ESC na zrušenie", "FindBoardsDialog.Title": "Nájsť nástenky", "GroupBy.hideEmptyGroups": "Skryť {count} prázdnych skupín", "GroupBy.showHiddenGroups": "Zobraziť {count} prázdnych skupín", "GroupBy.ungroup": "Zrušiť zoskupenie", "HideBoard.MenuOption": "Skryť nástenku", "KanbanCard.untitled": "Bez názvu", "MentionSuggestion.is-not-board-member": "(nie je členom nástenky)", "Mutator.new-board-from-template": "nová nástenka zo šablóny", "Mutator.new-card-from-template": "nová karta zo šablóny", "Mutator.new-template-from-card": "nová šablóna z karty", "PropertyMenu.Delete": "Odstrániť", "PropertyMenu.changeType": "Zmeniť vlastnosť", "PropertyMenu.selectType": "Vybrať vlastnosť", "PropertyMenu.typeTitle": "Typ", "PropertyType.Checkbox": "Checkbox", "PropertyType.CreatedBy": "Vytvoril", "PropertyType.CreatedTime": "Vytvorené", "PropertyType.Date": "Dátum", "PropertyType.Email": "Email", "PropertyType.MultiSelect": "Viacnásobný výber", "PropertyType.Number": "číslo", "PropertyType.Person": "Osoba", "PropertyType.Phone": "Telefón", "PropertyType.Select": "Vyber", "PropertyType.Text": "Text", "PropertyType.UpdatedBy": "Naposledy upravil", "PropertyType.UpdatedTime": "Posledná úprava", "PropertyValueElement.empty": "Prázdny", "RegistrationLink.confirmRegenerateToken": "Toto zruší platnosť predtým zdieľaných odkazov. Pokračovať?", "RegistrationLink.copiedLink": "Skopírované!", "RegistrationLink.copyLink": "Skopírovať odkaz", "RegistrationLink.description": "Zdieľajte tento odkaz pre vytvorenie účtu:", "RegistrationLink.regenerateToken": "Obnoviť token", "RegistrationLink.tokenRegenerated": "Registračný odkaz obnovený", "ShareBoard.confirmRegenerateToken": "Toto zruší platnosť predtým zdieľaných odkazov. Pokračovať?", "ShareBoard.copiedLink": "Skopírované!", "ShareBoard.copyLink": "Skopírovať odkaz", "ShareBoard.tokenRegenrated": "Token obnovený", "Sidebar.about": "O Focalboard", "Sidebar.add-board": "+ Pridať nástenku", "Sidebar.changePassword": "Zmeniť heslo", "Sidebar.delete-board": "Odstrániť nástenku", "Sidebar.export-archive": "Export archívu", "Sidebar.import-archive": "Import archívu", "Sidebar.invite-users": "Pozvať užívateľa", "Sidebar.logout": "Odhlásiť sa", "Sidebar.random-icons": "Náhodné ikony", "Sidebar.set-language": "Nastaviť jazyk", "Sidebar.set-theme": "Nastaviť tému", "Sidebar.settings": "nastavenia", "Sidebar.untitled-board": "(nástenka bez názvu)", "TableComponent.add-icon": "Pridať ikonu", "TableComponent.name": "názov", "TableComponent.plus-new": "+ Nový", "TableHeaderMenu.delete": "Odstrániť", "TableHeaderMenu.duplicate": "Duplikuj", "TableHeaderMenu.hide": "Skryť", "TableHeaderMenu.insert-left": "Vložiť vľavo", "TableHeaderMenu.insert-right": "Vložiť vpravo", "TableHeaderMenu.sort-ascending": "vzostupne", "TableHeaderMenu.sort-descending": "zostupne", "TableRow.open": "Otvoriť", "TopBar.give-feedback": "Spätná väzba", "ValueSelector.noOptions": "Žiadne možnosti. Pridajte prvú!", "ValueSelector.valueSelector": "Výber hodnoty", "ValueSelectorLabel.openMenu": "Otvor menu", "View.AddView": "Pridaj pohľad", "View.Board": "nástenka", "View.DeleteView": "Odstrániť pohľad", "View.DuplicateView": "Duplikuj pohľad", "View.Gallery": "Galéria", "View.NewBoardTitle": "Náhľad nástenky", "View.NewCalendarTitle": "Kalendár", "View.NewGalleryTitle": "Galéria", "View.NewTableTitle": "Tabuľka", "View.Table": "Tabuľka", "ViewHeader.add-template": "Nový template", "ViewHeader.delete-template": "Odstrániť", "ViewHeader.display-by": "Zobraziť podľa: {property}", "ViewHeader.edit-template": "Upraviť", "ViewHeader.empty-card": "Prázdna karta", "ViewHeader.export-board-archive": "Export archívu nástenky", "ViewHeader.export-complete": "Export hotový!", "ViewHeader.export-csv": "Export do CSV", "ViewHeader.export-failed": "Export zlyhal!", "ViewHeader.filter": "Filter", "ViewHeader.group-by": "Zoskupiť podľa: {property}", "ViewHeader.new": "Nový", "ViewHeader.properties": "Vlastnosti", "ViewHeader.search-text": "Hľadať text", "ViewHeader.select-a-template": "vyber template", "ViewHeader.set-default-template": "Nastaviť ako predvolenú", "ViewHeader.sort": "Triediť", "ViewHeader.untitled": "Bez názvu", "ViewTitle.hide-description": "Skryť popis", "ViewTitle.pick-icon": "Vybrať ikonu", "ViewTitle.random-icon": "Náhodne", "ViewTitle.remove-icon": "Odstrániť ikonu", "ViewTitle.show-description": "zobraziť popis", "ViewTitle.untitled-board": "nástenka bez názvu", "WelcomePage.Description": "Nástenky sú nástroj na riadenie projektov, ktorý pomáha definovať, organizovať, sledovať a riadiť prácu medzi tímami pomocou zobrazenia kanban", "WelcomePage.Explore.Button": "Preskúmať", "WelcomePage.Heading": "Vitajte", "Workspace.editing-board-template": "Upravujete template nástenky.", "calendar.month": "Mesiac", "calendar.today": "Dnes", "calendar.week": "Týždeň", "default-properties.title": "Názov", "login.log-in-button": "Prihlásiť", "login.log-in-title": "Prihlásiť", "login.register-button": "alebo vytvoriť účet, ak žiadny nemáte", "register.login-button": "alebo sa prihláste ak už máte účet", "register.signup-title": "Zaregistrujte si účet" } ================================================ FILE: webapp/i18n/sl.json ================================================ { "BoardComponent.add-a-group": "+ Dodaj skupino", "BoardComponent.delete": "Izbriši", "BoardComponent.hidden-columns": "Skriti stolpci", "BoardComponent.hide": "Skrij", "BoardComponent.new": "+ Novo", "BoardComponent.no-property": "Ni {property}", "BoardComponent.no-property-title": "Elementi s prazno lastnostjo {property} bodo šli sem. Tega stolpca ni mogoče odstraniti.", "BoardComponent.show": "Pokaži", "BoardPage.newVersion": "Na voljo je nova različica Boards, kliknite tukaj za ponovno nalaganje.", "BoardPage.syncFailed": "Plošča se lahko izbriše ali dostop prekliče." } ================================================ FILE: webapp/i18n/sv.json ================================================ { "AppBar.Tooltip": "Växla länkade boards", "Attachment.Attachment-title": "Bilaga", "AttachmentBlock.DeleteAction": "radera", "AttachmentBlock.addElement": "lägg till {type}", "AttachmentBlock.delete": "Bilagan har tagits bort.", "AttachmentBlock.failed": "Flen kunde inte laddas upp eftersom gränsen för filstorlek har nåtts.", "AttachmentBlock.upload": "Bilagor laddas upp.", "AttachmentBlock.uploadSuccess": "Bilagan är uppladdad.", "AttachmentElement.delete-confirmation-dialog-button-text": "Radera", "AttachmentElement.download": "Ladda ner", "AttachmentElement.upload-percentage": "Laddar upp...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Lägg till grupp", "BoardComponent.delete": "Radera", "BoardComponent.hidden-columns": "Dolda kolumner", "BoardComponent.hide": "Dölj", "BoardComponent.new": "+ Ny", "BoardComponent.no-property": "Ingen {property}", "BoardComponent.no-property-title": "Objekt med en tom {property} egenskap grupperas här. Denna kolumn kan inte tas bort.", "BoardComponent.show": "Visa", "BoardMember.schemeAdmin": "Administratör", "BoardMember.schemeCommenter": "Kommentator", "BoardMember.schemeEditor": "Redaktör", "BoardMember.schemeNone": "Inget", "BoardMember.schemeViewer": "Granskare", "BoardMember.unlinkChannel": "Koppla ifrån", "BoardPage.newVersion": "En ny version av Boards finns tillgänglig. Klicka här för att uppdatera.", "BoardPage.syncFailed": "Denna Board kan ha blivit raderad eller så har din behörighet tagits bort.", "BoardTemplateSelector.add-template": "Skapa ny mall", "BoardTemplateSelector.create-empty-board": "Skapa en tom board", "BoardTemplateSelector.delete-template": "Ta bort", "BoardTemplateSelector.description": "Lägg till ett Board till sidomenyn genom att välja någon av mallarna nedan eller börja med en tom.", "BoardTemplateSelector.edit-template": "Ändra", "BoardTemplateSelector.plugin.no-content-description": "Lägg till en Board till sidofältet genom att använda en av mallarna nedan eller starta med en tom.", "BoardTemplateSelector.plugin.no-content-title": "Skapa en Board", "BoardTemplateSelector.title": "Skapa en board", "BoardTemplateSelector.use-this-template": "Använd den här mallen", "BoardsSwitcher.Title": "Hitta board", "BoardsUnfurl.Limited": "Ytterligare uppgifter är dolda eftersom kortet är arkiverat", "BoardsUnfurl.Remainder": "+{remainder} mer", "BoardsUnfurl.Updated": "Uppdaterad {time}", "Calculations.Options.average.displayName": "Genomsnitt", "Calculations.Options.average.label": "Genomsnitt", "Calculations.Options.count.displayName": "Räkna", "Calculations.Options.count.label": "Räkna", "Calculations.Options.countChecked.displayName": "Vald", "Calculations.Options.countChecked.label": "Räkna valda", "Calculations.Options.countUnchecked.displayName": "Ej vald", "Calculations.Options.countUnchecked.label": "Räkna ej valda", "Calculations.Options.countUniqueValue.displayName": "Unika", "Calculations.Options.countUniqueValue.label": "Räkna unika värden", "Calculations.Options.countValue.displayName": "Värden", "Calculations.Options.countValue.label": "Räkna värden", "Calculations.Options.dateRange.displayName": "Intervall", "Calculations.Options.dateRange.label": "Intervall", "Calculations.Options.earliest.displayName": "Tidigast", "Calculations.Options.earliest.label": "Tidigast", "Calculations.Options.latest.displayName": "Senast", "Calculations.Options.latest.label": "Senast", "Calculations.Options.max.displayName": "Max", "Calculations.Options.max.label": "Max", "Calculations.Options.median.displayName": "Median", "Calculations.Options.median.label": "Median", "Calculations.Options.min.displayName": "Min", "Calculations.Options.min.label": "Min", "Calculations.Options.none.displayName": "Beräkna", "Calculations.Options.none.label": "Ingen", "Calculations.Options.percentChecked.displayName": "Avbockad", "Calculations.Options.percentChecked.label": "Procent avbockade", "Calculations.Options.percentUnchecked.displayName": "Ej avbockad", "Calculations.Options.percentUnchecked.label": "Procent ej avbockade", "Calculations.Options.range.displayName": "Intervall", "Calculations.Options.range.label": "Intervall", "Calculations.Options.sum.displayName": "Summa", "Calculations.Options.sum.label": "Summa", "CalendarCard.untitled": "Saknar titel", "CardActionsMenu.copiedLink": "Kopierad!", "CardActionsMenu.copyLink": "Kopiera länk", "CardActionsMenu.delete": "Radera", "CardActionsMenu.duplicate": "Duplicera", "CardBadges.title-checkboxes": "Kryssrutor", "CardBadges.title-comments": "Kommentarer", "CardBadges.title-description": "Detta kort har en beskrivning", "CardDetail.Attach": "Bifoga", "CardDetail.Follow": "Följ", "CardDetail.Following": "Följer", "CardDetail.add-content": "Lägg till innehåll", "CardDetail.add-icon": "Lägg till ikon", "CardDetail.add-property": "+ Lägg till egenskap", "CardDetail.addCardText": "lägg till korttext", "CardDetail.limited-body": "Uppgradera till Professional- eller Enterprise-abonnemang.", "CardDetail.limited-button": "Uppgradera", "CardDetail.limited-title": "Detta kort är dolt", "CardDetail.moveContent": "Flytta kortinnehåll", "CardDetail.new-comment-placeholder": "Lägg till kommentar...", "CardDetailProperty.confirm-delete-heading": "Bekräfta ta bort egenskap", "CardDetailProperty.confirm-delete-subtext": "Är du säker på att du vill ta bort egenskapen \"{propertyName}\"? Om du raderar den kommer egenskapen tas bort från alla kort på tavlan.", "CardDetailProperty.confirm-property-name-change-subtext": "Är du säker du vill ändra egenskapen \"{propertyName}\" {customText}? Detta kommer påverka alla värden på {numOfCards} kort på den här boarden och kan innebära att du förlorar information.", "CardDetailProperty.confirm-property-type-change": "Bekräfta ändring av egenskapstyp", "CardDetailProperty.delete-action-button": "Ta bort", "CardDetailProperty.property-change-action-button": "Ändra egenskap", "CardDetailProperty.property-changed": "Ändrade egenskap!", "CardDetailProperty.property-deleted": "{propertyName} har raderats!", "CardDetailProperty.property-name-change-subtext": "typ från \"{oldPropType}\" till \"{newPropType}\"", "CardDetial.limited-link": "Läs mer om våra abonnemang.", "CardDialog.delete-confirmation-dialog-attachment": "Bekräfta att bilagor ska raderas", "CardDialog.delete-confirmation-dialog-button-text": "Radera", "CardDialog.delete-confirmation-dialog-heading": "Bekräfta att kortet ska raderas", "CardDialog.editing-template": "Du redigerar en mall.", "CardDialog.nocard": "Detta kort existerar inte eller är oåtkomligt.", "Categories.CreateCategoryDialog.CancelText": "Avbryt", "Categories.CreateCategoryDialog.CreateText": "Skapa", "Categories.CreateCategoryDialog.Placeholder": "Namnge din kategori", "Categories.CreateCategoryDialog.UpdateText": "Uppdatera", "CenterPanel.Login": "Logga in", "CenterPanel.Share": "Dela", "ChannelIntro.CreateBoard": "Skapa en board", "ColorOption.selectColor": "Välj {color} färg", "Comment.delete": "Radera", "CommentsList.send": "Skicka", "ConfirmPerson.empty": "Tom", "ConfirmPerson.search": "Sök...", "ConfirmationDialog.cancel-action": "Avbryt", "ConfirmationDialog.confirm-action": "Godkänn", "ContentBlock.Delete": "Radera", "ContentBlock.DeleteAction": "radera", "ContentBlock.addElement": "lägg till {type}", "ContentBlock.checkbox": "kryssrutan", "ContentBlock.divider": "avdelare", "ContentBlock.editCardCheckbox": "markerad krysskruta", "ContentBlock.editCardCheckboxText": "redigera korttext", "ContentBlock.editCardText": "redigera korttext", "ContentBlock.editText": "Redigera text...", "ContentBlock.image": "bild", "ContentBlock.insertAbove": "Lägg till ovanför", "ContentBlock.moveBlock": "flytta kortets innehåll", "ContentBlock.moveDown": "Flytta ned", "ContentBlock.moveUp": "Flytta upp", "ContentBlock.text": "text", "DateRange.clear": "Rensa", "DateRange.empty": "Tom", "DateRange.endDate": "Slutdatum", "DateRange.today": "Idag", "DeleteBoardDialog.confirm-cancel": "Avbryt", "DeleteBoardDialog.confirm-delete": "Radera", "DeleteBoardDialog.confirm-info": "Är du säker på att du vill ta bort board “{boardTitle}”? När du tar bort den kommer du radera alla kort på board.", "DeleteBoardDialog.confirm-info-template": "Är du säker på att du vill ta bort board-mallen \"{boardTitle}\"?", "DeleteBoardDialog.confirm-tite": "Bekräfta att ta bort board", "DeleteBoardDialog.confirm-tite-template": "Bekräfta att board-mallen ska raderas", "Dialog.closeDialog": "Stäng dialog", "EditableDayPicker.today": "Idag", "Error.mobileweb": "Webbåtkomst via mobilen är i tidig betaversion. All funktionalitet kanske inte är tillgänglig.", "Error.websocket-closed": "Websocketanslutningen stängdes då anslutningen avbröts. Om detta fortgår, kontrollera din server eller webproxykonfigurationen.", "Filter.contains": "innehåller", "Filter.ends-with": "slutar med", "Filter.includes": "inkluderar", "Filter.is": "är", "Filter.is-empty": "är tomt", "Filter.is-not-empty": "är inte tomt", "Filter.is-not-set": "är inte inställd", "Filter.is-set": "är inställd", "Filter.not-contains": "innehåller inte", "Filter.not-ends-with": "slutar inte med", "Filter.not-includes": "inkluderar inte", "Filter.not-starts-with": "börjar inte med", "Filter.starts-with": "börjar med", "FilterByText.placeholder": "filtrera text", "FilterComponent.add-filter": "+ Lägg till filter", "FilterComponent.delete": "Radera", "FilterValue.empty": "(tom)", "FindBoardsDialog.IntroText": "Sök efter boards", "FindBoardsDialog.NoResultsFor": "Inga sökresultat för \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Kontrollera stavningen eller sök igen.", "FindBoardsDialog.SubTitle": "Skriv för att hitta en board. Använd UPP/NER för att bläddra. RETUR för att välja, ESC för att avbryta", "FindBoardsDialog.Title": "Hitta board", "GroupBy.hideEmptyGroups": "Dölj {count} tomma grupper", "GroupBy.showHiddenGroups": "Visa {count} dolda grupper", "GroupBy.ungroup": "Dela upp grupp", "HideBoard.MenuOption": "Dölj board", "KanbanCard.untitled": "Saknar titel", "MentionSuggestion.is-not-board-member": "(inte board-medlem)", "Mutator.new-board-from-template": "ny board från en mall", "Mutator.new-card-from-template": "nytt kort från mall", "Mutator.new-template-from-card": "ny mall från kort", "OnboardingTour.AddComments.Body": "Du kan kommentera ämnen och till och med @omnämna andra Mattermost-användare för att få deras uppmärksamhet.", "OnboardingTour.AddComments.Title": "Lägg till kommentarer", "OnboardingTour.AddDescription.Body": "Lägg till en beskrivning till kortet så ditt team vet vad kortet handlar om.", "OnboardingTour.AddDescription.Title": "Lägg till beskrivning", "OnboardingTour.AddProperties.Body": "Lägg till egenskaper till korten för att göra dem mer informativa.", "OnboardingTour.AddProperties.Title": "Lägg till egenskaper", "OnboardingTour.AddView.Body": "Gå hit för att skapa en ny vy för att organisera ditt Board med hjälp av olika layouter.", "OnboardingTour.AddView.Title": "Lägg till en ny vy", "OnboardingTour.CopyLink.Body": "Du kan dela dina kort med ditt team genom att kopiera länken och klistra in den i en kanal, ett direktmeddelande eller ett gruppmeddelande.", "OnboardingTour.CopyLink.Title": "Kopiera länken", "OnboardingTour.OpenACard.Body": "Öppna ett kort för att utforska produktiva sätt som Boards kan hjälpa dig att organisera ditt arbete.", "OnboardingTour.OpenACard.Title": "Öppna ett kort", "OnboardingTour.ShareBoard.Body": "Du kan dela ditt Board internt, inom ditt team, eller publicera den publikt för att visa det utanför din organisation.", "OnboardingTour.ShareBoard.Title": "Dela Board", "PersonProperty.board-members": "Board-medlemmar", "PersonProperty.me": "Jag", "PersonProperty.non-board-members": "Inte board-medlemmar", "PropertyMenu.Delete": "Radera", "PropertyMenu.changeType": "Ändra egenskapstyp", "PropertyMenu.selectType": "Välj typ av egenskap", "PropertyMenu.typeTitle": "Typ", "PropertyType.Checkbox": "Checkruta", "PropertyType.CreatedBy": "Skapad av", "PropertyType.CreatedTime": "Skapad tid", "PropertyType.Date": "Datum", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "Flera personer", "PropertyType.MultiSelect": "Flervalsalternativ", "PropertyType.Number": "Tal", "PropertyType.Person": "Person", "PropertyType.Phone": "Telefon", "PropertyType.Select": "Alternativ", "PropertyType.Text": "Text", "PropertyType.Unknown": "Okänd", "PropertyType.UpdatedBy": "Senast ändrad av", "PropertyType.UpdatedTime": "Senast uppdaterad", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Tom", "RegistrationLink.confirmRegenerateToken": "Det här kommer att göra tidigare delade länkar ogiltiga. Vill du fortsätta?", "RegistrationLink.copiedLink": "Kopierad!", "RegistrationLink.copyLink": "Kopiera länk", "RegistrationLink.description": "Dela denna länk med andra för att skapa konton:", "RegistrationLink.regenerateToken": "Återskapa åtkomstnyckel", "RegistrationLink.tokenRegenerated": "Registreringslänk återskapad", "ShareBoard.PublishDescription": "Publicera och dela en skrivskyddad länk med alla på webben.", "ShareBoard.PublishTitle": "Publicera på webben", "ShareBoard.ShareInternal": "Dela internt", "ShareBoard.ShareInternalDescription": "Användare som har behörighet kan använda denna länk.", "ShareBoard.Title": "Dela Board", "ShareBoard.confirmRegenerateToken": "Det här kommer att göra tidigare delade länkar ogiltiga. Vill du fortsätta?", "ShareBoard.copiedLink": "Kopierad!", "ShareBoard.copyLink": "Kopiera länk", "ShareBoard.regenerate": "Generera nytt Token", "ShareBoard.searchPlaceholder": "Sök efter personer och kanaler", "ShareBoard.teamPermissionsText": "Alla i teamet {teamName}", "ShareBoard.tokenRegenrated": "Åtkomstnyckel återskapad", "ShareBoard.userPermissionsRemoveMemberText": "Ta bort användare", "ShareBoard.userPermissionsYouText": "(du)", "ShareTemplate.Title": "Dela mallen", "ShareTemplate.searchPlaceholder": "Sök efter personer", "Sidebar.about": "Om Focalboard", "Sidebar.add-board": "+ Lägg till tavla", "Sidebar.changePassword": "Ändra lösenord", "Sidebar.delete-board": "Radera tavla", "Sidebar.duplicate-board": "Duplicera Board", "Sidebar.export-archive": "Exportera arkiv", "Sidebar.import": "Importera", "Sidebar.import-archive": "Importera arkiv", "Sidebar.invite-users": "Bjud in användare", "Sidebar.logout": "Logga ut", "Sidebar.new-category.badge": "Ny", "Sidebar.new-category.drag-boards-cta": "Släpp Boards här...", "Sidebar.no-boards-in-category": "Inga Boards", "Sidebar.product-tour": "Produktvisning", "Sidebar.random-icons": "Slumpmässiga ikoner", "Sidebar.set-language": "Välj språk", "Sidebar.set-theme": "Välj tema", "Sidebar.settings": "Inställningar", "Sidebar.template-from-board": "Ny mall från Board", "Sidebar.untitled-board": "(Tavla saknar titel)", "Sidebar.untitled-view": "(vy utan titel)", "SidebarCategories.BlocksMenu.Move": "Flytta till...", "SidebarCategories.CategoryMenu.CreateNew": "Skapa ny kategori", "SidebarCategories.CategoryMenu.Delete": "Ta bort kategori", "SidebarCategories.CategoryMenu.DeleteModal.Body": "Boards i {categoryName} flyttas tillbaka till kategorierna Boards. Du har inte tagits bort från några Boards.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Radera kategorin?", "SidebarCategories.CategoryMenu.Update": "Byt namn på kategori", "SidebarTour.ManageCategories.Body": "Skapa och hantera egna kategorier. Kategorier är användarspecifika, så om du flyttar en Board till din kategori påverkas inte andra medlemmar som använder samma Board.", "SidebarTour.ManageCategories.Title": "Hantera kategorier", "SidebarTour.SearchForBoards.Body": "Öppna Board-växlaren (Cmd/Ctrl + K) för att snabbt söka och lägga till boards i sidofältet.", "SidebarTour.SearchForBoards.Title": "Sök efter boards", "SidebarTour.SidebarCategories.Body": "Alla dina boards är nu organiserade i ditt nya sidofält. Du behöver inte längre växla mellan olika arbetsområden. Anpassade kategorier baserade på dina tidigare arbetsytor kan ha skapats automatiskt för dig som en del av din uppgradering av v7.2. Dessa kan tas bort eller redigeras enligt dina önskemål.", "SidebarTour.SidebarCategories.Link": "Mer information", "SidebarTour.SidebarCategories.Title": "Kategorier i sidoomenyn", "SiteStats.total_boards": "Totalt antal tavlor", "SiteStats.total_cards": "Totalt antal kort", "TableComponent.add-icon": "Lägg till ikon", "TableComponent.name": "Namn", "TableComponent.plus-new": "+ Ny", "TableHeaderMenu.delete": "Radera", "TableHeaderMenu.duplicate": "Duplicera", "TableHeaderMenu.hide": "Dölj", "TableHeaderMenu.insert-left": "Infoga till vänster", "TableHeaderMenu.insert-right": "Infoga till höger", "TableHeaderMenu.sort-ascending": "Sortera stigande", "TableHeaderMenu.sort-descending": "Sortera fallande", "TableRow.DuplicateCard": "duplicera kort", "TableRow.MoreOption": "Fler åtgärder", "TableRow.open": "Öppna", "TopBar.give-feedback": "Ge återkoppling", "URLProperty.copiedLink": "Kopierad!", "URLProperty.copy": "Kopiera", "URLProperty.edit": "Ändra", "UndoRedoHotKeys.canRedo": "Gör om", "UndoRedoHotKeys.canRedo-with-description": "Gör om {description}", "UndoRedoHotKeys.canUndo": "Ångra", "UndoRedoHotKeys.canUndo-with-description": "Ångra {description}", "UndoRedoHotKeys.cannotRedo": "Inget att göra om igen", "UndoRedoHotKeys.cannotUndo": "Inget att ångra", "ValueSelector.noOptions": "Inga alternativ. Börja skriva för att lägga till den första!", "ValueSelector.valueSelector": "Värdeväljare", "ValueSelectorLabel.openMenu": "Öppna meny", "VersionMessage.help": "Kolla in vad som är nytt i den här versionen.", "View.AddView": "Lägg till vy", "View.Board": "Tavla", "View.DeleteView": "Radera vy", "View.DuplicateView": "Duplicera vy", "View.Gallery": "Galleri", "View.NewBoardTitle": "Tavelvy", "View.NewCalendarTitle": "Kalendervy", "View.NewGalleryTitle": "Galleri vy", "View.NewTableTitle": "Tabellvy", "View.NewTemplateDefaultTitle": "Namnlös mall", "View.NewTemplateTitle": "Namnlös", "View.Table": "Tabell", "ViewHeader.add-template": "Ny mall", "ViewHeader.delete-template": "Radera", "ViewHeader.display-by": "Visa efter: {property}", "ViewHeader.edit-template": "Redigera", "ViewHeader.empty-card": "Blankt kort", "ViewHeader.export-board-archive": "Exportera tavelarkivet", "ViewHeader.export-complete": "Export slutförd!", "ViewHeader.export-csv": "Exportera till CSV", "ViewHeader.export-failed": "Export misslyckades!", "ViewHeader.filter": "Filter", "ViewHeader.group-by": "Gruppera på: {property}", "ViewHeader.new": "Ny", "ViewHeader.properties": "Egenskaper", "ViewHeader.properties-menu": "Menyn Egenskaper", "ViewHeader.search-text": "Sök efter kort", "ViewHeader.select-a-template": "Välj en mall", "ViewHeader.set-default-template": "Sätt som förvald", "ViewHeader.sort": "Sortera", "ViewHeader.untitled": "Saknar titel", "ViewHeader.view-header-menu": "Visa huvudmenyn", "ViewHeader.view-menu": "Visa menyn", "ViewLimitDialog.Heading": "Gränsen för antalet visningar per board har uppnåtts", "ViewLimitDialog.PrimaryButton.Title.Admin": "Uppgradera", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Meddela administratör", "ViewLimitDialog.Subtext.Admin": "Uppgradera till Professional- eller Enterprise-abonnemang.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Läs mer om våra abonnemang.", "ViewLimitDialog.Subtext.RegularUser": "Meddela din administratör att uppgradera till Professional- eller Enterprise-abonnemang.", "ViewLimitDialog.UpgradeImg.AltText": "bild som föreställer en uppgradering", "ViewLimitDialog.notifyAdmin.Success": "Din systemadministratör har blivit notifierad", "ViewTitle.hide-description": "dölj beskrivning", "ViewTitle.pick-icon": "Välj ikon", "ViewTitle.random-icon": "Slumpmässig", "ViewTitle.remove-icon": "Ta bort ikon", "ViewTitle.show-description": "visa beskrivning", "ViewTitle.untitled-board": "Board utan titel", "WelcomePage.Description": "Boards är ett projekthanteringsverktyg som hjälper till att definiera, organisera, spåra och hantera arbete mellan team med hjälp av en välbekant Kanban-vy.", "WelcomePage.Explore.Button": "Starta en rundtur", "WelcomePage.Heading": "Välkommen till Boards", "WelcomePage.NoThanks.Text": "Nej tack, jag löser det själv", "WelcomePage.StartUsingIt.Text": "Börja använda den", "Workspace.editing-board-template": "Du redigerar en tavelmall.", "badge.guest": "Gäst", "boardSelector.confirm-link-board": "Koppla board till kanal", "boardSelector.confirm-link-board-button": "Ja, koppla board", "boardSelector.confirm-link-board-subtext": "När du kopplar \"{boardName}\" till kanalen kommer alla medlemmar i kanalen (befintliga och nya) att kunna redigera den, gäster exkluderade. Du kan när som helst koppla bort ett board från kanalen.", "boardSelector.confirm-link-board-subtext-with-other-channel": "När du kopplar \"{boardName}\" till kanalen kommer alla medlemmar i kanalen (befintliga och nya) att kunna redigera den, exkluderat gäster.{lineBreak}Denna board är kopplad till en annan kanal. Den kommer kopplas bort om du väljer att koppla den hit.", "boardSelector.create-a-board": "Skapa en board", "boardSelector.link": "Länk", "boardSelector.search-for-boards": "Sök efter boards", "boardSelector.title": "Länkade Boards", "boardSelector.unlink": "Koppla ifrån", "calendar.month": "Månad", "calendar.today": "IDAG", "calendar.week": "Vecka", "centerPanel.undefined": "Ingen {propertyName}", "centerPanel.unknown-user": "Okänd användare", "cloudMessage.learn-more": "Läs mer", "createImageBlock.failed": "Filen kunde inte laddas upp eftersom gränsen för filstorlek har uppnåtts.", "default-properties.badges": "Kommentarer och beskrivning", "default-properties.title": "Titel", "error.back-to-home": "Tillbaka till förstasidan", "error.back-to-team": "Tillbaka till team", "error.board-not-found": "Board finns inte.", "error.go-login": "Logga in", "error.invalid-read-only-board": "Du har inte tillgång till denna board. Logga in för att få tillgång till Boards.", "error.not-logged-in": "Din session kan ha löpt ut eller så är du inte inloggad. Logga in igen för att få tillgång till Boards.", "error.page.title": "Oops, något gick fel", "error.team-undefined": "Inte ett giltigt team.", "error.unknown": "Ett fel inträffade.", "generic.previous": "Föregående", "guest-no-board.subtitle": "Du har inte tillgång till något board i teamet ännu, vänta tills någon lägger till dig i ett board.", "guest-no-board.title": "Inga board ännu", "imagePaste.upload-failed": "Några filer kunde inte laddas upp eftersom gränsen för filstorlek har nåtts.", "limitedCard.title": "Dolda kort", "login.log-in-button": "Logga in", "login.log-in-title": "Logga in", "login.register-button": "eller skapa ett konto om du inte redan har ett", "new_channel_modal.create_board.empty_board_description": "Skapa en ny tom tavla", "new_channel_modal.create_board.empty_board_title": "Tom tavla", "new_channel_modal.create_board.select_template_placeholder": "Välj en mall", "new_channel_modal.create_board.title": "Skapa en tavla för den här kanalen", "notification-box-card-limit-reached.close-tooltip": "Sov i 10 dagar", "notification-box-card-limit-reached.contact-link": "notifiera din administratör", "notification-box-card-limit-reached.link": "Uppgradera till ett betal-abonnemang", "notification-box-card-limit-reached.title": "{cards} kort dolda från board", "notification-box-cards-hidden.title": "Åtgärden dolde ett annat kort", "notification-box.card-limit-reached.not-admin.text": "Om du vill komma åt arkiverade kort kan du {contactLink} för att uppgradera till ett betal-abonnemang.", "notification-box.card-limit-reached.text": "Gränsen för kort har nåtts, för att visa äldre kort, {link}", "person.add-user-to-board": "Lägg till {username} till board", "person.add-user-to-board-confirm-button": "Lägg till i board", "person.add-user-to-board-permissions": "Behörigheter", "person.add-user-to-board-question": "Vill du lägga till {username} till board?", "person.add-user-to-board-warning": "{username} är inte medlem i board och kommer inte att få några meddelanden om den.", "register.login-button": "eller logga in om du redan har ett konto", "register.signup-title": "Registrera dig för ett konto", "rhs-board-non-admin-msg": "Du är inte administratör för board", "rhs-boards.add": "Lägg till", "rhs-boards.dm": "DM", "rhs-boards.gm": "GM", "rhs-boards.header.dm": "detta direktmeddelande", "rhs-boards.header.gm": "detta gruppmeddelande", "rhs-boards.last-update-at": "Senast uppdaterad: {datetime}", "rhs-boards.link-boards-to-channel": "Länka boards till {channelName}", "rhs-boards.linked-boards": "Länkade boards", "rhs-boards.no-boards-linked-to-channel": "Inga boards är länkade till {channelName} ännu", "rhs-boards.no-boards-linked-to-channel-description": "Boards är ett projekthanteringsverktyg som hjälper till att definiera, organisera, spåra och hantera arbete mellan olika team med hjälp av en välbekant Kanban-vy.", "rhs-boards.unlink-board": "Ta bort länk till Board", "rhs-boards.unlink-board1": "Koppla bort board", "rhs-channel-boards-header.title": "Boards", "share-board.publish": "Publicera", "share-board.share": "Dela", "shareBoard.channels-select-group": "Channels", "shareBoard.confirm-change-team-role.body": "Alla i denna board som har lägre behörighet än rollen \"{role}\" kommer nu att befordras till {role}. Är du säker på att du vill ändra den minsta rollen för board?", "shareBoard.confirm-change-team-role.confirmBtnText": "Ändra lägsta board-rollen", "shareBoard.confirm-change-team-role.title": "Ändra lägsta board-rollen", "shareBoard.confirm-link-channel": "Koppla board till kanal", "shareBoard.confirm-link-channel-button": "Koppla kanal", "shareBoard.confirm-link-channel-button-with-other-channel": "Koppla och koppla bort här", "shareBoard.confirm-link-channel-subtext": "När du kopplar en kanal till en board kommer alla medlemmar (befintliga och nya) kunna redigera den, exklusive gäster.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "När du kopplar en kanal till en board kommer alla medlemmar (befintliga och nya) kunna redigera den, exklusive gäster.{lineBreak}Denna Board är just nu kopplad till en annan kanal. Den kommer kopplas bort om du väljer att koppla den till denna kanal.", "shareBoard.confirm-unlink.body": "När du kopplar bort en kanal från en board kommer alla medlemmar (befintliga och nya) tappa behörigheten om de inte blir tilldelade en egen behörighet.", "shareBoard.confirm-unlink.confirmBtnText": "Koppla bort kanal", "shareBoard.confirm-unlink.title": "Koppla bort kanal från board", "shareBoard.lastAdmin": "En Board måste ha minst en administratör", "shareBoard.members-select-group": "Medlemmar", "shareBoard.unknown-channel-display-name": "Okänd kanal", "tutorial_tip.finish_tour": "Klar", "tutorial_tip.got_it": "Då förstår jag", "tutorial_tip.ok": "Nästa", "tutorial_tip.out": "Välj bort att få tips.", "tutorial_tip.seen": "Sett detta tidigare?" } ================================================ FILE: webapp/i18n/tr.json ================================================ { "AdminBadge.SystemAdmin": "Yönetici", "AdminBadge.TeamAdmin": "Takım yöneticisi", "AppBar.Tooltip": "Bağlantılı panoları aç/kapat", "Attachment.Attachment-title": "Ek dosya", "AttachmentBlock.DeleteAction": "sil", "AttachmentBlock.addElement": "{type} ekle", "AttachmentBlock.delete": "Ek dosya silindi.", "AttachmentBlock.failed": "Dosya boyutu sınırı aşıldığından bu dosya yüklenemedi.", "AttachmentBlock.upload": "Ek dosya yükleniyor.", "AttachmentBlock.uploadSuccess": "Ek dosya yüklendi.", "AttachmentElement.delete-confirmation-dialog-button-text": "Sil", "AttachmentElement.download": "İndir", "AttachmentElement.upload-percentage": "Yükleniyor...(%{uploadPercent})", "BoardComponent.add-a-group": "+ Grup ekle", "BoardComponent.delete": "Sil", "BoardComponent.hidden-columns": "Gizli sütunlar", "BoardComponent.hide": "Gizle", "BoardComponent.new": "+ Yeni", "BoardComponent.no-property": "{property} yok", "BoardComponent.no-property-title": "{property} alanı boş olan ögeler buraya atanır. Bu sütun silinemez.", "BoardComponent.show": "Görüntüle", "BoardMember.schemeAdmin": "Yönetici", "BoardMember.schemeCommenter": "Yorumcu", "BoardMember.schemeEditor": "Düzenleyici", "BoardMember.schemeNone": "Yok", "BoardMember.schemeViewer": "Görüntüleyici", "BoardMember.unlinkChannel": "Bağlantıyı kaldır", "BoardPage.newVersion": "Yeni bir pano sürümü yayınlanmış. Yeniden yüklemek için buraya tıklayın.", "BoardPage.syncFailed": "Pano silinmiş ya da erişim izni geri alınmış olabilir.", "BoardTemplateSelector.add-template": "Yeni kalıp ekle", "BoardTemplateSelector.create-empty-board": "Boş bir pano ekle", "BoardTemplateSelector.delete-template": "Sil", "BoardTemplateSelector.description": "Kalıplardan birini kullanarak ya da sıfırdan başlayarak yan çubuğa bir pano ekleyin.", "BoardTemplateSelector.edit-template": "Düzenle", "BoardTemplateSelector.plugin.no-content-description": "Aşağıdaki kalıplardan birini kullanarak ya da sıfırdan başlayarak yan çubuğa bir pano ekleyin.", "BoardTemplateSelector.plugin.no-content-title": "Bir pano ekleyin", "BoardTemplateSelector.title": "Bir pano ekle", "BoardTemplateSelector.use-this-template": "Bu kalıp kullanılsın", "BoardsSwitcher.Title": "Pano arama", "BoardsUnfurl.Limited": "Kart arşivlendiğinden ek bilgiler gizleniyor", "BoardsUnfurl.Remainder": "+{remainder} diğer", "BoardsUnfurl.Updated": "Güncellenme: {time}", "Calculations.Options.average.displayName": "Ortalama", "Calculations.Options.average.label": "Ortalama", "Calculations.Options.count.displayName": "Sayı", "Calculations.Options.count.label": "Sayı", "Calculations.Options.countChecked.displayName": "İşaretlenmiş", "Calculations.Options.countChecked.label": "İşaretlenmiş sayısı", "Calculations.Options.countUnchecked.displayName": "İşaretlenmemiş", "Calculations.Options.countUnchecked.label": "İşaretlenmemiş sayısı", "Calculations.Options.countUniqueValue.displayName": "Eşsiz", "Calculations.Options.countUniqueValue.label": "Eşsiz değer sayısı", "Calculations.Options.countValue.displayName": "Değer", "Calculations.Options.countValue.label": "Değer sayısı", "Calculations.Options.dateRange.displayName": "Aralık", "Calculations.Options.dateRange.label": "Aralık", "Calculations.Options.earliest.displayName": "En erken", "Calculations.Options.earliest.label": "En erken", "Calculations.Options.latest.displayName": "En geç", "Calculations.Options.latest.label": "En geç", "Calculations.Options.max.displayName": "En fazla", "Calculations.Options.max.label": "En fazla", "Calculations.Options.median.displayName": "Orta değer", "Calculations.Options.median.label": "Orta değer", "Calculations.Options.min.displayName": "En az", "Calculations.Options.min.label": "En az", "Calculations.Options.none.displayName": "Hesapla", "Calculations.Options.none.label": "Yok", "Calculations.Options.percentChecked.displayName": "İşaretlenmiş", "Calculations.Options.percentChecked.label": "İşaretlenmiş yüzdesi", "Calculations.Options.percentUnchecked.displayName": "İşaretlenmemiş", "Calculations.Options.percentUnchecked.label": "İşaretlenmemiş yüzdesi", "Calculations.Options.range.displayName": "Aralık", "Calculations.Options.range.label": "Aralık", "Calculations.Options.sum.displayName": "Toplam", "Calculations.Options.sum.label": "Toplam", "CalendarCard.untitled": "Adlandırılmamış", "CardActionsMenu.copiedLink": "Kopyalandı!", "CardActionsMenu.copyLink": "Bağlantıyı kopyala", "CardActionsMenu.delete": "Sil", "CardActionsMenu.duplicate": "Kopyala", "CardBadges.title-checkboxes": "İşaret kutuları", "CardBadges.title-comments": "Yorumlar", "CardBadges.title-description": "Bu kartın bir açıklaması var", "CardDetail.Attach": "Dosya ekle", "CardDetail.Follow": "İzle", "CardDetail.Following": "İzleniyor", "CardDetail.add-content": "İçerik ekle", "CardDetail.add-icon": "Simge ekle", "CardDetail.add-property": "+ Bir özellik ekle", "CardDetail.addCardText": "kart metni ekle", "CardDetail.limited-body": "Professional ya da Enterprise tarifesine geçin.", "CardDetail.limited-button": "Üst tarifeye geç", "CardDetail.limited-title": "Bu kart gizli", "CardDetail.moveContent": "Kart içeriğini taşı", "CardDetail.new-comment-placeholder": "Bir yorum ekle...", "CardDetailProperty.confirm-delete-heading": "Özelliği silmeyi onaylayın", "CardDetailProperty.confirm-delete-subtext": "\"{propertyName}\" özelliğini silmek istediğinize emin misiniz? Bu işlem özelliği panodaki tüm kartlardan siler.", "CardDetailProperty.confirm-property-name-change-subtext": "\"{propertyName}\" {customText} özelliğini değiştirmek istediğinize emin misiniz? Bu işlem bu panodaki {numOfCards} kartı etkiler ve veri kaybına yol açabilir.", "CardDetailProperty.confirm-property-type-change": "Özellik türü değişimini onaylayın", "CardDetailProperty.delete-action-button": "Sil", "CardDetailProperty.property-change-action-button": "Özelliği değiştir", "CardDetailProperty.property-changed": "Özellik değiştirildi!", "CardDetailProperty.property-deleted": "{propertyName} silindi!", "CardDetailProperty.property-name-change-subtext": "\"{oldPropType}\" türünden \"{newPropType}\" türüne", "CardDetial.limited-link": "Tarifelerimiz hakkında ayrıntılı bilgi alın.", "CardDialog.delete-confirmation-dialog-attachment": "Ek dosyanın silinmesini onaylayın", "CardDialog.delete-confirmation-dialog-button-text": "Sil", "CardDialog.delete-confirmation-dialog-heading": "Kartı silmeyi onaylayın", "CardDialog.editing-template": "Bir kalıbı düzenliyorsunuz.", "CardDialog.nocard": "Bu kart bulunamadı ya da erişilebilir değil.", "Categories.CreateCategoryDialog.CancelText": "İptal", "Categories.CreateCategoryDialog.CreateText": "Ekle", "Categories.CreateCategoryDialog.Placeholder": "Kategorinize bir ad verin", "Categories.CreateCategoryDialog.UpdateText": "Güncelle", "CenterPanel.Login": "Oturum aç", "CenterPanel.Share": "Paylaş", "ChannelIntro.CreateBoard": "Bir pano ekle", "ColorOption.selectColor": "{color} rengi seçin", "Comment.delete": "Sil", "CommentsList.send": "Gönder", "ConfirmPerson.empty": "Boş", "ConfirmPerson.search": "Arama...", "ConfirmationDialog.cancel-action": "İptal", "ConfirmationDialog.confirm-action": "Onayla", "ContentBlock.Delete": "Sil", "ContentBlock.DeleteAction": "sil", "ContentBlock.addElement": "{type} ekle", "ContentBlock.checkbox": "işaret kutusu", "ContentBlock.divider": "ayıraç", "ContentBlock.editCardCheckbox": "değiştirilmiş işaret kutusu", "ContentBlock.editCardCheckboxText": "kart metnini düzenle", "ContentBlock.editCardText": "kart metnini düzenle", "ContentBlock.editText": "Metni düzenle...", "ContentBlock.image": "görsel", "ContentBlock.insertAbove": "Üste ekle", "ContentBlock.moveBlock": "kart içeriğini taşı", "ContentBlock.moveDown": "Alta taşı", "ContentBlock.moveUp": "Üste taşı", "ContentBlock.text": "metin", "DateFilter.empty": "Boş", "DateRange.clear": "Temizle", "DateRange.empty": "Boş", "DateRange.endDate": "Bitiş tarihi", "DateRange.today": "Bugün", "DeleteBoardDialog.confirm-cancel": "İptal", "DeleteBoardDialog.confirm-delete": "Sil", "DeleteBoardDialog.confirm-info": "“{boardTitle}” panosunu silmek istediğinize emin misiniz? Silme işlemi bu panodaki tüm kartları siler.", "DeleteBoardDialog.confirm-info-template": "“{boardTitle}” pano kalıbını silmek istediğinize emin misiniz?", "DeleteBoardDialog.confirm-tite": "Panoyu silmeyi onayla", "DeleteBoardDialog.confirm-tite-template": "Pano kalıbını silmeyi onayla", "Dialog.closeDialog": "Pencereyi kapat", "EditableDayPicker.today": "Bugün", "Error.mobileweb": "Mobil web desteği şu anda erken beta aşamasındadır. Tüm işlevler kullanılamıyor olabilir.", "Error.websocket-closed": "Websoket bağlantısı kesildi. Bu sorun sürerse, sunucu ya da web vekil sunucu yapılandırmanızı denetleyin.", "Filter.contains": "şunu içeren", "Filter.ends-with": "şununla biten", "Filter.includes": "şunu içeren", "Filter.is": "şu olan", "Filter.is-after": "şundan sonra", "Filter.is-before": "şundan önce", "Filter.is-empty": "boş olan", "Filter.is-not-empty": "boş olmayan", "Filter.is-not-set": "şuna ayarlanmamış olan", "Filter.is-set": "şuna ayarlanmış olan", "Filter.isafter": "şundan sonra", "Filter.isbefore": "şundan önce", "Filter.not-contains": "şunu içermeyen", "Filter.not-ends-with": "şununla bitmeyen", "Filter.not-includes": "şunu içermeyen", "Filter.not-starts-with": "şununla başlamayan", "Filter.starts-with": "şununla başlayan", "FilterByText.placeholder": "metni süz", "FilterComponent.add-filter": "+ Süzgeç ekle", "FilterComponent.delete": "Sil", "FilterValue.empty": "(boş)", "FindBoardsDialog.IntroText": "Pano arama", "FindBoardsDialog.NoResultsFor": "\"{searchQuery}\" için bir sonuç bulunamadı", "FindBoardsDialog.NoResultsSubtext": "Yazımı denetleyin ya da başka bir arama yapmayı deneyin.", "FindBoardsDialog.SubTitle": "Bulmak istediğiniz pano adını yazmaya başlayın. Gezinmek için YUKAR/AŞAĞI, seçmek için ENTER, vazgeçmek için ESC tuşlarını kullanın", "FindBoardsDialog.Title": "Pano arama", "GroupBy.hideEmptyGroups": "{count} boş grubu gizle", "GroupBy.showHiddenGroups": "{count} gizli grubu görüntüle", "GroupBy.ungroup": "Gruplamayı kaldır", "HideBoard.MenuOption": "Panoyu gizle", "KanbanCard.untitled": "Adlandırılmamış", "MentionSuggestion.is-not-board-member": "(pano üyesi değil)", "Mutator.new-board-from-template": "kalıptan yeni pano", "Mutator.new-card-from-template": "kalıptan yeni kart oluştur", "Mutator.new-template-from-card": "karttan yeni kalıp oluştur", "OnboardingTour.AddComments.Body": "Sorunlar hakkında yorum yapabilir ve Mattermost kullanıcılarının dikkatini çekmek için @anabilirsiniz.", "OnboardingTour.AddComments.Title": "Yorum yap", "OnboardingTour.AddDescription.Body": "Takım arkadaşlarınızın kartın ne ile ilgili olduğunu anlaması için kartınıza bir açıklama ekleyin.", "OnboardingTour.AddDescription.Title": "Açıklama ekle", "OnboardingTour.AddProperties.Body": "Daha güçlü kılmak için kartlara çeşitli özellikler ekleyin.", "OnboardingTour.AddProperties.Title": "Özellikler ekle", "OnboardingTour.AddView.Body": "Farklı görünümler kullanarak panonuzu düzenleyecek yeni bir görünüm oluşturmak için buraya gidin.", "OnboardingTour.AddView.Title": "Yeni bir görünüm ekle", "OnboardingTour.CopyLink.Body": "Kartlarınızı takım arkadaşlarınızla paylaşmak için bağlantıyı kopyalayıp bir kanala, doğrudan iletiye veya grup iletisine yapıştırın.", "OnboardingTour.CopyLink.Title": "Bağlantıyı kopyala", "OnboardingTour.OpenACard.Body": "Panoların işinizi düzenlemenize yardımcı olabileceği güçlü yolları keşfetmek için bir kart açın.", "OnboardingTour.OpenACard.Title": "Bir kart açın", "OnboardingTour.ShareBoard.Body": "Panonuzu içeride, ekibiniz ile paylaşabilir ya da kuruluşunuzun dışında herkese açık olarak yayınlayabilirsiniz.", "OnboardingTour.ShareBoard.Title": "Panoyu paylaş", "PersonProperty.board-members": "Pano üyeleri", "PersonProperty.me": "Benim", "PersonProperty.non-board-members": "Pano üyesi olmayanlar", "PropertyMenu.Delete": "Sil", "PropertyMenu.changeType": "Özellik türünü değiştir", "PropertyMenu.selectType": "Özellik türünü seçin", "PropertyMenu.typeTitle": "Tür", "PropertyType.Checkbox": "İşaret kutusu", "PropertyType.CreatedBy": "Oluşturan", "PropertyType.CreatedTime": "Oluşturulma zamanı", "PropertyType.Date": "Tarih", "PropertyType.Email": "E-posta", "PropertyType.MultiPerson": "Çok kişi", "PropertyType.MultiSelect": "Çoklu seçim", "PropertyType.Number": "Sayı", "PropertyType.Person": "Kişi", "PropertyType.Phone": "Telefon", "PropertyType.Select": "Seçin", "PropertyType.Text": "Metin", "PropertyType.Unknown": "Bilinmiyor", "PropertyType.UpdatedBy": "Son güncelleyen", "PropertyType.UpdatedTime": "Son güncelleme zamanı", "PropertyType.Url": "Adres", "PropertyValueElement.empty": "Boş", "RegistrationLink.confirmRegenerateToken": "Bu işlem daha önce paylaşılmış bağlantıları geçersiz kılacak. İlerlemek istiyor musunuz?", "RegistrationLink.copiedLink": "Kopyalandı!", "RegistrationLink.copyLink": "Bağlantıyı kopyala", "RegistrationLink.description": "Başkalarının hesap ekleyebilmesi için bu bağlantıyı paylaş:", "RegistrationLink.regenerateToken": "Kodu yeniden oluştur", "RegistrationLink.tokenRegenerated": "Kayıt bağlantısı yeniden oluşturuldu", "ShareBoard.PublishDescription": "Web üzerinde herkese açık olarak \"salt okunur\" bir bağlantı yayınlayın ve paylaşın.", "ShareBoard.PublishTitle": "Web üzerinde yayınla", "ShareBoard.ShareInternal": "İçeride paylaş", "ShareBoard.ShareInternalDescription": "İzni olan kullanıcılar bu bağlantıyı kullanabilecek.", "ShareBoard.Title": "Panoyu paylaş", "ShareBoard.confirmRegenerateToken": "Bu işlem daha önce paylaşılmış bağlantıları geçersiz kılacak. İlerlemek istiyor musunuz?", "ShareBoard.copiedLink": "Kopyalandı!", "ShareBoard.copyLink": "Bağlantıyı kopyala", "ShareBoard.regenerate": "Kodu yeniden oluştur", "ShareBoard.searchPlaceholder": "Kişi ve kanal arama", "ShareBoard.teamPermissionsText": "{teamName} takımındaki herkes", "ShareBoard.tokenRegenrated": "Kod yeniden oluşturuldu", "ShareBoard.userPermissionsRemoveMemberText": "Üyelikten çıkar", "ShareBoard.userPermissionsYouText": "(Siz)", "ShareTemplate.Title": "Kalıbı paylaş", "ShareTemplate.searchPlaceholder": "Kişi arama", "Sidebar.about": "Focalboard hakkında", "Sidebar.add-board": "+ Pano ekle", "Sidebar.changePassword": "Parola değiştir", "Sidebar.delete-board": "Panoyu sil", "Sidebar.duplicate-board": "Panoyu kopyala", "Sidebar.export-archive": "Arşivi dışa aktar", "Sidebar.import": "İçe aktar", "Sidebar.import-archive": "Arşivi içe aktar", "Sidebar.invite-users": "Kullanıcıları çağır", "Sidebar.logout": "Oturumu kapat", "Sidebar.new-category.badge": "Yeni", "Sidebar.new-category.drag-boards-cta": "Panoları sürükleyip buraya bırakın...", "Sidebar.no-boards-in-category": "İçeride bir pano yok", "Sidebar.product-tour": "Tanıtım turu", "Sidebar.random-icons": "Rastgele simgeler", "Sidebar.set-language": "Dili ayarla", "Sidebar.set-theme": "Temayı ayarla", "Sidebar.settings": "Ayarlar", "Sidebar.template-from-board": "Panodan yeni kalıp", "Sidebar.untitled-board": "(Adlandırılmamış pano)", "Sidebar.untitled-view": "(Adlandırılmamış görünüm)", "SidebarCategories.BlocksMenu.Move": "Şuraya taşı...", "SidebarCategories.CategoryMenu.CreateNew": "Yeni kategori ekle", "SidebarCategories.CategoryMenu.Delete": "Kategoriyi sił", "SidebarCategories.CategoryMenu.DeleteModal.Body": "{categoryName} içindeki panolar Panolar kategorisine taşınacak. Herhangi bir panodan çıkarılmayacaksınız.", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Bu kategori silinsin mi?", "SidebarCategories.CategoryMenu.Update": "Kategoriyi yeniden adlandır", "SidebarTour.ManageCategories.Body": "Özel kategoriler oluşturun ve yönetin. Kategoriler kullanıcıya özeldir, bu nedenle bir panoyu kendi kategorinize taşımanız aynı panoyu kullanan diğer üyeleri etkilemez.", "SidebarTour.ManageCategories.Title": "Kategori yönetimi", "SidebarTour.SearchForBoards.Body": "Panoları hızlıca aramak ve yan çubuğunuza eklemek için pano değiştiriciyi (Cmd/Ctrl + K) açın.", "SidebarTour.SearchForBoards.Title": "Pano arama", "SidebarTour.SidebarCategories.Body": "Tüm panolarınızı artık yeni yan çubuğunuz altında bulabilirsiniz. Artık çalışma alanları arasında geçiş yapmanıza gerek yok. Önceki çalışma alanlarınıza göre eklenmiş tek seferlik özel kategoriler, 7.2 sürümüne güncellemenizin bir parçası olarak otomatik şekilde eklenmiş olabilir. Bunları isteğinize göre kaldırabilir ya da düzenleyebilirsiniz.", "SidebarTour.SidebarCategories.Link": "Ayrıntılı bilgi alın", "SidebarTour.SidebarCategories.Title": "Yan çubuk kategorileri", "SiteStats.total_boards": "Toplam pano", "SiteStats.total_cards": "Toplam kart", "TableComponent.add-icon": "Simge ekle", "TableComponent.name": "Ad", "TableComponent.plus-new": "+ Yeni", "TableHeaderMenu.delete": "Sil", "TableHeaderMenu.duplicate": "Kopya oluştur", "TableHeaderMenu.hide": "Gizle", "TableHeaderMenu.insert-left": "Sola ekle", "TableHeaderMenu.insert-right": "Sağa ekle", "TableHeaderMenu.sort-ascending": "Artan sıralama", "TableHeaderMenu.sort-descending": "Azalan sıralama", "TableRow.DuplicateCard": "kartı kopyala", "TableRow.MoreOption": "Diğer işlemler", "TableRow.open": "Aç", "TopBar.give-feedback": "Geri bildirimde bulunun", "URLProperty.copiedLink": "Kopyalandı!", "URLProperty.copy": "Kopyala", "URLProperty.edit": "Düzenle", "UndoRedoHotKeys.canRedo": "Yinele", "UndoRedoHotKeys.canRedo-with-description": "{description} yinele", "UndoRedoHotKeys.canUndo": "Geri al", "UndoRedoHotKeys.canUndo-with-description": "{description} geri al", "UndoRedoHotKeys.cannotRedo": "Yinelenecek bir işlem yok", "UndoRedoHotKeys.cannotUndo": "Geri alınacak bir işlem yok", "ValueSelector.noOptions": "Herhangi bir seçenek yok. İlk seçeneği eklemek için yazmaya başlayın!", "ValueSelector.valueSelector": "Değer seçici", "ValueSelectorLabel.openMenu": "Menüyü aç", "VersionMessage.help": "Bu sürümdeki yeniliklere bakın.", "VersionMessage.learn-more": "Ayrıntılı bilgi alın", "View.AddView": "Görünüm ekle", "View.Board": "Pano", "View.DeleteView": "Görünümü sil", "View.DuplicateView": "Görünümü kopyala", "View.Gallery": "Galeri", "View.NewBoardTitle": "Pano görünümü", "View.NewCalendarTitle": "Takvim görünümü", "View.NewGalleryTitle": "Galeri görünümü", "View.NewTableTitle": "Tablo görünümü", "View.NewTemplateDefaultTitle": "Adlandırılmamış kalıp", "View.NewTemplateTitle": "Adlandırılmamış", "View.Table": "Tablo", "ViewHeader.add-template": "Yeni kalıp", "ViewHeader.delete-template": "Sil", "ViewHeader.display-by": "Görünüm: {property}", "ViewHeader.edit-template": "Düzenle", "ViewHeader.empty-card": "Boş kart", "ViewHeader.export-board-archive": "Pano arşivini dışa aktar", "ViewHeader.export-complete": "Dışa aktarıldı!", "ViewHeader.export-csv": "CSV olarak dışa aktar", "ViewHeader.export-failed": "Dışa aktarılamadı!", "ViewHeader.filter": "Süz", "ViewHeader.group-by": "Grupla: {property}", "ViewHeader.new": "Yeni", "ViewHeader.properties": "Özellikler", "ViewHeader.properties-menu": "Özellikler menüsü", "ViewHeader.search-text": "Kart arama", "ViewHeader.select-a-template": "Bir kalıp seçin", "ViewHeader.set-default-template": "Varsayılan olarak ata", "ViewHeader.sort": "Sırala", "ViewHeader.untitled": "Adlandırılmamış", "ViewHeader.view-header-menu": "Başlık menüsünü görüntüle", "ViewHeader.view-menu": "Menüyü görüntüle", "ViewLimitDialog.Heading": "Bir panoyu görüntüleme sınırına ulaşıldı", "ViewLimitDialog.PrimaryButton.Title.Admin": "Üst tarifeye geç", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "Yöneticiyi bilgilendir", "ViewLimitDialog.Subtext.Admin": "Professional ya da Enterprise tarifemize geçin.", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "Tarifelerimiz hakkında ayrıntılı bilgi alın.", "ViewLimitDialog.Subtext.RegularUser": "Yöneticinizi Professional ya da Enterprise tarifesine geçmesi hakkında bilgilendirin.", "ViewLimitDialog.UpgradeImg.AltText": "üst tarifeye geçiş görseli", "ViewLimitDialog.notifyAdmin.Success": "Yöneticiniz bilgilendirildi", "ViewTitle.hide-description": "açıklamayı gizle", "ViewTitle.pick-icon": "Simge seçin", "ViewTitle.random-icon": "Rastgele", "ViewTitle.remove-icon": "Simgeyi kaldır", "ViewTitle.show-description": "açıklamayı görüntüle", "ViewTitle.untitled-board": "Adlandırılmamış pano", "WelcomePage.Description": "Pano, alışılmış Kanban panosu görünümünde takımların işleri tanımlamasını, düzenlemesini, izlemesi ve yönetmesini sağlayan bir proje yönetimi aracıdır.", "WelcomePage.Explore.Button": "Tura çıkın", "WelcomePage.Heading": "Panolara hoş geldiniz", "WelcomePage.NoThanks.Text": "Hayır teşekkürler, kendim anlayacağım", "WelcomePage.StartUsingIt.Text": "Kullanmaya başlayın", "Workspace.editing-board-template": "Bir pano kalıbını düzenliyorsunuz.", "badge.guest": "Konuk", "boardPage.confirm-join-button": "Katıl", "boardPage.confirm-join-text": "Bir özel kanala, pano yöneticisi tarafından açıkça eklenmeden katılmak üzeresiniz. Bu özel kanala katılmak istediğinize emin misiniz?", "boardPage.confirm-join-title": "Özel kanala katıl", "boardSelector.confirm-link-board": "Panoyu kanala bağla", "boardSelector.confirm-link-board-button": "Evet, panoyu bağla", "boardSelector.confirm-link-board-subtext": "\"{boardName}\" panosunu kanala bağladığınızda, kanalın tüm üyeleri (var olan ve yeni) panoyu düzenleyebilir. Bu işlem konuk üyeleri kaldırır. Bir pano ile bir kanalın bağlantısını istediğiniz zaman kaldırabilirsiniz.", "boardSelector.confirm-link-board-subtext-with-other-channel": "\"{boardName}\" panosunu bir kanala bağladığınızda, kanalın tüm üyeleri (var olan ve yeni) panoyu düzenleyebilir. Bu işlem konukl üyeleri kaldırır.{lineBreak}Bu pano şu anda başka bir kanal ile bağlantılı. Bu kanala bağlamayı seçerseniz diğer kanal ile bağlantısı kesilecek.", "boardSelector.create-a-board": "Bir pano ekle", "boardSelector.link": "Bağlantı", "boardSelector.search-for-boards": "Pano arama", "boardSelector.title": "Panoları bağla", "boardSelector.unlink": "Bağlantıyı kaldır", "calendar.month": "Ay", "calendar.today": "Bugün", "calendar.week": "Hafta", "centerPanel.undefined": "{propertyName} yok", "centerPanel.unknown-user": "Kullanıcı bilinmiyor", "cloudMessage.learn-more": "Ayrıntılı bilgi alın", "createImageBlock.failed": "Dosya boyutu sınırı aşıldığından bu dosya yüklenemedi.", "default-properties.badges": "Yorumlar ve açıklama", "default-properties.title": "Başlık", "error.back-to-home": "Girişe dön", "error.back-to-team": "Takıma dön", "error.board-not-found": "Pano bulunamadı.", "error.go-login": "Oturum aç", "error.invalid-read-only-board": "Bu panoya erişme izniniz yok. Panolara erişmek için oturum açın.", "error.not-logged-in": "Oturumunuzun süresi dolmuş ya da oturum açmamışsınız. Panolara erişmek için yeniden oturum açın.", "error.page.title": "Bir şeyler ters gitti", "error.team-undefined": "Geçerli bir takım değil.", "error.unknown": "Bir sorun çıktı.", "generic.previous": "Önceki", "guest-no-board.subtitle": "Henüz bu takımdaki herhangi bir panoya erişme izniniz yok. Lütfen biri sizi bir panoya ekleyene kadar bekleyin.", "guest-no-board.title": "Henüz bir pano yok", "imagePaste.upload-failed": "Dosya boyutu sınırı aşıldığından bazı dosyalar yüklenemedi.", "limitedCard.title": "Kartlar gizli", "login.log-in-button": "Oturum aç", "login.log-in-title": "Oturum açın", "login.register-button": "ya da hesabınız yoksa bir hesap açın", "new_channel_modal.create_board.empty_board_description": "Yeni boş bir pano oluştur", "new_channel_modal.create_board.empty_board_title": "Boş pano", "new_channel_modal.create_board.select_template_placeholder": "Bir kalıp seçin", "new_channel_modal.create_board.title": "Bu kanal için bir pano oluştur", "notification-box-card-limit-reached.close-tooltip": "10 gün için sustur", "notification-box-card-limit-reached.contact-link": "yöneticinizi bilgilendirin", "notification-box-card-limit-reached.link": "Ücretli bir tarifeye geçin", "notification-box-card-limit-reached.title": "panoda {cards} kart gizli", "notification-box-cards-hidden.title": "Bu işlem başka bir kartı gizledi", "notification-box.card-limit-reached.not-admin.text": "Arşivlenmiş kartlara erişmek için {contactLink} ile görüşerek ücretli bir tarifeye geçmesini isteyin.", "notification-box.card-limit-reached.text": "Kart sınırına ulaşıldı. Eski kartları görüntülemek için {link}", "person.add-user-to-board": "{username} kullanıcısını panoya ekle", "person.add-user-to-board-confirm-button": "Panoya ekle", "person.add-user-to-board-permissions": "İzinler", "person.add-user-to-board-question": "{username} kullanıcısını panoya eklemek ister misiniz?", "person.add-user-to-board-warning": "{username} panonun bir üyesi değil ve pano ile ilgili herhangi bir bildirim almayacak.", "register.login-button": "ya da bir hesabınız varsa oturum açın", "register.signup-title": "Hesap açın", "rhs-board-non-admin-msg": "Panonun yöneticilerinden değilsiniz", "rhs-boards.add": "Ekle", "rhs-boards.dm": "Dİ", "rhs-boards.gm": "Gİ", "rhs-boards.header.dm": "bu doğrudan ileti", "rhs-boards.header.gm": "bu grup iletisi", "rhs-boards.last-update-at": "Son güncelleme: {datetime}", "rhs-boards.link-boards-to-channel": "Panoları {channelName} kanalına bağla", "rhs-boards.linked-boards": "Bağlı panolar", "rhs-boards.no-boards-linked-to-channel": "Henüz {channelName} kanalına bağlanmış bir pano yok", "rhs-boards.no-boards-linked-to-channel-description": "Panolar, takımlar arasındaki çalışmaları tanımlamak, organize etmek, izlemek ve yönetmek için kullanılabilen kandan panosuna benzer bir proje yönetimi aracıdır.", "rhs-boards.unlink-board": "Panonun bağlantısını kaldır", "rhs-boards.unlink-board1": "Pano bağlantısını kaldır", "rhs-channel-boards-header.title": "Panolar", "share-board.publish": "Yayınla", "share-board.share": "Paylaş", "shareBoard.channels-select-group": "Kanallar", "shareBoard.confirm-change-team-role.body": "Bu panoda izinleri \"{role}\" rolünden daha aşağıda olan herkes {role} rolüne yükseltilecek. Panonunen düşük rolünü değiştirmek istediğinize emin misiniz?", "shareBoard.confirm-change-team-role.confirmBtnText": "Panonun en düşük rolünü değiştir", "shareBoard.confirm-change-team-role.title": "Panonun en düşük rolünü değiştir", "shareBoard.confirm-link-channel": "Panoyu kanala bağla", "shareBoard.confirm-link-channel-button": "Kanalı bağla", "shareBoard.confirm-link-channel-button-with-other-channel": "Eski bağlantıyı kes ve bu kanala bağla", "shareBoard.confirm-link-channel-subtext": "Bir kanalı bir panoya bağladığınızda, kanalın tüm üyeleri (var olan ve yeni) panoyu düzenleyebilir. Bu işlem konuk üyeleri kaldırır.", "shareBoard.confirm-link-channel-subtext-with-other-channel": "Bir kanalı bir panoya bağladığınızda, kanalın tüm üyeleri (var olan ve yeni) panoyu düzenleyebilir. Bu işlem konuk üyeleri kaldırır.{lineBreak}Bu pano şu anda başka bir kanal ile bağlantılı. Bu kanala bağlamayı seçerseniz diğer kanal ile bağlantısı kesilecek.", "shareBoard.confirm-unlink.body": "Bir kanalın bir pano ile bağlantısını kaldırdığınızda, kanalın tüm üyeleri (var olan ve yeni), kendilerine özel olarak izin verilmedikçe, panoya erişimi kaybeder.", "shareBoard.confirm-unlink.confirmBtnText": "Kanalın bağlantısını kaldır", "shareBoard.confirm-unlink.title": "Kanalın pano ile bağlantısı kaldır", "shareBoard.lastAdmin": "Panoların en az bir yöneticisi olmalıdır", "shareBoard.members-select-group": "Üyeler", "shareBoard.unknown-channel-display-name": "Kanal bilinmiyor", "tutorial_tip.finish_tour": "Tamam", "tutorial_tip.got_it": "Anladım", "tutorial_tip.ok": "Sonraki", "tutorial_tip.out": "Bu ipuçları görüntülenmesin.", "tutorial_tip.seen": "Daha önce gördünüz mü?" } ================================================ FILE: webapp/i18n/uk.json ================================================ { "AppBar.Tooltip": "Перемкнути пов’язані дошки", "Attachment.Attachment-title": "Прикріплення", "AttachmentBlock.DeleteAction": "видалити", "AttachmentBlock.addElement": "додати {type}", "AttachmentBlock.delete": "Прикріплення успішно видалено.", "AttachmentBlock.failed": "Не вдалося завантажити цей файл, оскільки досягнуто обмеження розміру файлу.", "AttachmentBlock.upload": "Прикріплення завантажуються.", "AttachmentBlock.uploadSuccess": "Вкладення завантажено.", "AttachmentElement.delete-confirmation-dialog-button-text": "Видалити", "AttachmentElement.download": "Завантажити", "AttachmentElement.upload-percentage": "Завантаження...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Додати групу", "BoardComponent.delete": "Видалити", "BoardComponent.hidden-columns": "Приховані стовпці", "BoardComponent.hide": "Приховати", "BoardComponent.new": "+ Створити", "BoardComponent.no-property": "Немає {property}", "BoardComponent.no-property-title": "Елементи з порожнім полем {property} потраплять сюди. Цей стовпець неможливо видалити.", "BoardComponent.show": "Показати", "BoardMember.schemeAdmin": "Адміністратор", "BoardMember.schemeCommenter": "Коментатор", "BoardMember.schemeEditor": "Редактор", "BoardMember.schemeNone": "Жоден", "BoardMember.schemeViewer": "Спостерігач", "BoardMember.unlinkChannel": "Від’єднати", "BoardPage.newVersion": "Доступна оновлена версія Панелі, тицьни тут щоб оновити.", "BoardPage.syncFailed": "Можливо Панель видалено або права анульовано.", "BoardTemplateSelector.add-template": "Створити новий шаблон", "BoardTemplateSelector.create-empty-board": "Створити порожню доску", "BoardTemplateSelector.delete-template": "Видалити", "BoardTemplateSelector.description": "Додайте дошку на бічній панелі, використовуючи будь-який із наведених нижче шаблонів, або почніть з нуля.", "BoardTemplateSelector.edit-template": "Редагувати", "BoardTemplateSelector.plugin.no-content-description": "Додайте дошку на бічній панелі, використовуючи будь-який із наведених нижче шаблонів, або почніть з нуля.", "BoardTemplateSelector.plugin.no-content-title": "Створити дошку", "BoardTemplateSelector.title": "Створити доску", "BoardTemplateSelector.use-this-template": "Використати цей шаблон", "BoardsSwitcher.Title": "Знайти дошки", "BoardsUnfurl.Limited": "Додаткові деталі приховані бо картку архівовано", "BoardsUnfurl.Remainder": "+{remainder} більше", "BoardsUnfurl.Updated": "Оновлено {time}", "Calculations.Options.average.displayName": "Середній", "Calculations.Options.average.label": "Середній", "Calculations.Options.count.displayName": "Кількість", "Calculations.Options.count.label": "Кількість", "Calculations.Options.countChecked.displayName": "Перевірено", "Calculations.Options.countChecked.label": "Кількість перевірено", "Calculations.Options.countUnchecked.displayName": "Не перевірено", "Calculations.Options.countUnchecked.label": "Підрахунок не перевірено", "Calculations.Options.countUniqueValue.displayName": "Унікальний", "Calculations.Options.countUniqueValue.label": "Підрахувати унікальні значення", "Calculations.Options.countValue.displayName": "Значення", "Calculations.Options.countValue.label": "Розрахунок значення", "Calculations.Options.dateRange.displayName": "Діапазон", "Calculations.Options.dateRange.label": "Діапазон", "Calculations.Options.earliest.displayName": "Найраніший", "Calculations.Options.earliest.label": "Найраніший", "Calculations.Options.latest.displayName": "Останній", "Calculations.Options.latest.label": "Останній", "Calculations.Options.max.displayName": "Макс", "Calculations.Options.max.label": "Макс", "Calculations.Options.median.displayName": "Медіана", "Calculations.Options.median.label": "Медіана", "Calculations.Options.min.displayName": "Мін", "Calculations.Options.min.label": "Мін", "Calculations.Options.none.displayName": "Обчислити", "Calculations.Options.none.label": "Жодного", "Calculations.Options.percentChecked.displayName": "Перевірено", "Calculations.Options.percentChecked.label": "Відсоток перевірено", "Calculations.Options.percentUnchecked.displayName": "Не перевірено", "Calculations.Options.percentUnchecked.label": "Відсоток не перевірено", "Calculations.Options.range.displayName": "Діапазон", "Calculations.Options.range.label": "Діапазон", "Calculations.Options.sum.displayName": "Сума", "Calculations.Options.sum.label": "Сума", "CalendarCard.untitled": "Без назви", "CardActionsMenu.copiedLink": "Скопійовано!", "CardActionsMenu.copyLink": "Копіювати посилання", "CardActionsMenu.delete": "Видалити", "CardActionsMenu.duplicate": "Дублювати", "CardBadges.title-checkboxes": "Прапорці", "CardBadges.title-comments": "Коментарі", "CardBadges.title-description": "Ця картка має опис", "CardDetail.Attach": "Прикріпити", "CardDetail.Follow": "Слідкувати", "CardDetail.Following": "Відслідковувати", "CardDetail.add-content": "Додайте вміст", "CardDetail.add-icon": "Додати значок", "CardDetail.add-property": "+ Додати властивість", "CardDetail.addCardText": "додати текст картки", "CardDetail.limited-body": "Перейдіть на наш план Professional або Enterprise.", "CardDetail.limited-button": "Оновлення", "CardDetail.limited-title": "Ця прихована картка", "CardDetail.moveContent": "Перемістити вміст картки", "CardDetail.new-comment-placeholder": "Додати коментар...", "CardDetailProperty.confirm-delete-heading": "Підтвердьте видалення властивості", "CardDetailProperty.confirm-delete-subtext": "Ви впевнені, що хочете видалити властивість \"{propertyName}\"? При видаленні властивість буде видалено з усіх карток на цій дошці.", "CardDetailProperty.confirm-property-name-change-subtext": "Ви дійсно хочете змінити властивість \"{propertyName}\" {customText}? Це вплине на значення(-я) на {numOfCards} картці(-ах) на цій дошці і може призвести до втрати даних.", "CardDetailProperty.confirm-property-type-change": "Підтвердити зміну типу власності", "CardDetailProperty.delete-action-button": "Видалити", "CardDetailProperty.property-change-action-button": "Змінити властивість", "CardDetailProperty.property-changed": "Властивість змінена успішно!", "CardDetailProperty.property-deleted": "{propertyName} успішно видалено!", "CardDetailProperty.property-name-change-subtext": "тип з \"{oldPropType}\" в \"{newPropType}\"", "CardDetial.limited-link": "Дізнайтеся більше про наші плани.", "CardDialog.delete-confirmation-dialog-attachment": "Підтвердити видалення вкладення", "CardDialog.delete-confirmation-dialog-button-text": "Видалити", "CardDialog.delete-confirmation-dialog-heading": "Підтвердити видалення картки", "CardDialog.editing-template": "Ви редагуєте шаблон.", "CardDialog.nocard": "Ця картка не існує або недоступна.", "Categories.CreateCategoryDialog.CancelText": "Скасувати", "Categories.CreateCategoryDialog.CreateText": "Створити", "Categories.CreateCategoryDialog.Placeholder": "Назвіть свою категорію", "Categories.CreateCategoryDialog.UpdateText": "Оновити", "CenterPanel.Login": "Логін", "CenterPanel.Share": "Поділитися", "ChannelIntro.CreateBoard": "Створити дошку", "ColorOption.selectColor": "Виберіть колір {color}", "Comment.delete": "Видалити", "CommentsList.send": "Надіслати", "ConfirmPerson.empty": "Порожній", "ConfirmPerson.search": "Пошук...", "ConfirmationDialog.cancel-action": "Скасувати", "ConfirmationDialog.confirm-action": "Підтвердити", "ContentBlock.Delete": "Видалити", "ContentBlock.DeleteAction": "видалити", "ContentBlock.addElement": "додати {type}", "ContentBlock.checkbox": "прапорець", "ContentBlock.divider": "роздільник", "ContentBlock.editCardCheckbox": "позначений прапорець", "ContentBlock.editCardCheckboxText": "редагувати текст картки", "ContentBlock.editCardText": "редагувати текст картки", "ContentBlock.editText": "Редагувати текст...", "ContentBlock.image": "зображення", "ContentBlock.insertAbove": "Вставте вище", "ContentBlock.moveBlock": "перемістити вміст картки", "ContentBlock.moveDown": "Опустити", "ContentBlock.moveUp": "Підняти", "ContentBlock.text": "текст", "DateRange.clear": "Очистити", "DateRange.empty": "Пусто", "DateRange.endDate": "Дата закінчення", "DateRange.today": "Сьогодні", "DeleteBoardDialog.confirm-cancel": "Скасувати", "DeleteBoardDialog.confirm-delete": "Видалити", "DeleteBoardDialog.confirm-info": "Ви впевнені, що хочете видалити дошку “{boardTitle}”? Видалення призведе до видалення всіх карток на дошці.", "DeleteBoardDialog.confirm-info-template": "Ви впевнені, що хочете видалити шаблон дошки «{boardTitle}»?", "DeleteBoardDialog.confirm-tite": "Підтвердьте видалення дошки", "DeleteBoardDialog.confirm-tite-template": "Підтвердьте видалення шаблону дошки", "Dialog.closeDialog": "Закрити діалогове вікно", "EditableDayPicker.today": "Сьогодні", "Error.mobileweb": "Мобільна веб-підтримка зараз знаходиться на ранній стадії бета-тестування. Не всі функції можуть бути присутніми.", "Error.websocket-closed": "З'єднання через веб-сокет закрито, з'єднання перервано. Якщо це продовжується й далі, перевірте конфігурацію сервера або веб-проксі.", "Filter.contains": "містить", "Filter.ends-with": "закінчується на", "Filter.includes": "включає в себе", "Filter.is": "є", "Filter.is-empty": "пусто", "Filter.is-not-empty": "не порожній", "Filter.is-not-set": "не встановлено", "Filter.is-set": "встановлено", "Filter.not-contains": "не містить", "Filter.not-ends-with": "не закінчується", "Filter.not-includes": "не включає", "Filter.not-starts-with": "не починається з", "Filter.starts-with": "починається з", "FilterByText.placeholder": "фільтрувати текст", "FilterComponent.add-filter": "+ Додати фільтр", "FilterComponent.delete": "Видалити", "FilterValue.empty": "(порожній)", "FindBoardsDialog.IntroText": "Пошук дощок", "FindBoardsDialog.NoResultsFor": "Немає результатів для \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Перевірте правильність написання або спробуйте інший запит.", "FindBoardsDialog.SubTitle": "Введіть, щоб знайти дошку. Використовуйте ВГОРУ/ВНИЗ для перегляду. ENTER, щоб вибрати, ESC, щоб закрити", "FindBoardsDialog.Title": "Знайти дошки", "GroupBy.hideEmptyGroups": "Сховати {count} порожні групи", "GroupBy.showHiddenGroups": "Показати {count} прихованих груп", "GroupBy.ungroup": "Розгрупувати", "HideBoard.MenuOption": "Сховати дошку", "KanbanCard.untitled": "Без назви", "MentionSuggestion.is-not-board-member": "(не член правління)", "Mutator.new-board-from-template": "нова дошка з шаблону", "Mutator.new-card-from-template": "нова картка із шаблону", "Mutator.new-template-from-card": "новий шаблон із картки", "OnboardingTour.AddComments.Body": "Ви можете коментувати проблеми та навіть @згадувати інших користувачів Mattermost, щоб привернути їх увагу.", "OnboardingTour.AddComments.Title": "Додати коментарі", "OnboardingTour.AddDescription.Body": "Додайте опис до картки, щоб ваші товариші по команді знали, про що йде мова.", "OnboardingTour.AddDescription.Title": "Додайте опис", "OnboardingTour.AddProperties.Body": "Додайте карткам різні властивості, щоб зробити їх потужнішими.", "OnboardingTour.AddProperties.Title": "Додайте властивості", "OnboardingTour.AddView.Body": "Перейдіть сюди, щоб створити новий вид для організації дошки за допомогою різних макетів.", "OnboardingTour.AddView.Title": "Додайте новий вид", "OnboardingTour.CopyLink.Body": "Ви можете поділитися своїми картками з товаришами по команді, скопіювавши посилання та вставивши його в канал, пряме або групове повідомлення.", "OnboardingTour.CopyLink.Title": "Копіювати посилання", "OnboardingTour.OpenACard.Body": "Відкрийте картку, щоб дослідити потужні способи, за допомогою яких дошки можуть допомогти вам організувати вашу роботу.", "OnboardingTour.OpenACard.Title": "Відкрити картку", "OnboardingTour.ShareBoard.Body": "Ви можете поділитися своєю дошкою всередині, у своїй команді або опублікувати її публічно для видимості за межами вашої організації.", "OnboardingTour.ShareBoard.Title": "Поділитися дошкою", "PersonProperty.board-members": "Члени команди", "PersonProperty.me": "Я", "PersonProperty.non-board-members": "Не учасник команди", "PropertyMenu.Delete": "Видалити", "PropertyMenu.changeType": "Змінити тип власності", "PropertyMenu.selectType": "Виберіть тип властивості", "PropertyMenu.typeTitle": "Тип", "PropertyType.Checkbox": "Прапорець", "PropertyType.CreatedBy": "Створений", "PropertyType.CreatedTime": "Час створення", "PropertyType.Date": "Дата", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "Кілька осіб", "PropertyType.MultiSelect": "Множинний вибір", "PropertyType.Number": "Номер", "PropertyType.Person": "Особа", "PropertyType.Phone": "Телефон", "PropertyType.Select": "Обрати", "PropertyType.Text": "Текст", "PropertyType.Unknown": "Невідомий", "PropertyType.UpdatedBy": "Оновлено користувачем", "PropertyType.UpdatedTime": "Час останнього оновлення", "PropertyType.Url": "URL", "PropertyValueElement.empty": "Пусто", "RegistrationLink.confirmRegenerateToken": "Це призведе до скасування попередніх спільних посилань. Продовжити?", "RegistrationLink.copiedLink": "Скопійовано!", "RegistrationLink.copyLink": "Копіювати посилання", "RegistrationLink.description": "Поділіться цим посиланням, щоб інші могли створити облікові записи:", "RegistrationLink.regenerateToken": "Згенерувати новий токен", "RegistrationLink.tokenRegenerated": "Реєстраційне посилання відновлено", "ShareBoard.PublishDescription": "Опублікуйте та поділіться посиланням лише для читання з усіма в Інтернеті.", "ShareBoard.PublishTitle": "Опублікувати в Інтернеті", "ShareBoard.ShareInternal": "Поділитися всередині організації", "ShareBoard.ShareInternalDescription": "Користувачі, які мають дозволи, зможуть використовувати це посилання.", "ShareBoard.Title": "Поділиться Дошкою", "Sidebar.delete-board": "Видалити дошку", "SidebarCategories.CategoryMenu.Delete": "Видалити категорію", "SidebarCategories.CategoryMenu.DeleteModal.Title": "Видалити дану категорію?", "TableHeaderMenu.delete": "Видалити", "View.DeleteView": "Видалити вид", "ViewHeader.delete-template": "Видалити", "generic.previous": "Попередній", "shareBoard.unknown-channel-display-name": "Невідомий канал", "tutorial_tip.finish_tour": "Готово", "tutorial_tip.got_it": "Зрозуміло", "tutorial_tip.ok": "Далі", "tutorial_tip.out": "Відмовтеся від цих порад.", "tutorial_tip.seen": "Ви бачили це раніше?" } ================================================ FILE: webapp/i18n/vi.json ================================================ { "AppBar.Tooltip": "Chuyển sang các bảng đã liên kết", "Attachment.Attachment-title": "Đính kèm", "AttachmentBlock.DeleteAction": "xóa", "BoardComponent.add-a-group": "+ Thêm nhóm", "BoardComponent.delete": "Xóa", "BoardComponent.hidden-columns": "Cột ẩn", "BoardComponent.hide": "Ẩn", "BoardComponent.new": "+ Thêm", "BoardComponent.no-property": "Không {property}", "BoardComponent.show": "Hiện", "BoardMember.schemeAdmin": "Quản trị", "BoardMember.schemeCommenter": "Người bình luận", "BoardMember.schemeEditor": "Người soạn thảo", "BoardMember.schemeNone": "Không", "BoardMember.schemeViewer": "Người xem", "BoardMember.unlinkChannel": "Gỡ liên kết", "BoardPage.newVersion": "Có một phiên bản mới của bảng, click vào đây để nạp lại.", "Calculations.Options.average.displayName": "Trung bình", "Calculations.Options.average.label": "Trung bình", "TableComponent.add-icon": "Thêm icon", "TableComponent.name": "Tên", "TableComponent.plus-new": "+ Mới", "TableHeaderMenu.delete": "Xóa", "share-board.publish": "Công khai", "share-board.share": "Chia sẻ", "shareBoard.channels-select-group": "Kênh", "shareBoard.members-select-group": "Thành viên", "tutorial_tip.finish_tour": "Xong", "tutorial_tip.got_it": "Đã hiểu", "tutorial_tip.ok": "Tiếp theo" } ================================================ FILE: webapp/i18n/zh_Hans.json ================================================ { "AppBar.Tooltip": "切换链接的板块", "Attachment.Attachment-title": "附件", "AttachmentBlock.DeleteAction": "删除", "AttachmentBlock.addElement": "添加 {type}", "AttachmentBlock.delete": "附件已删除。", "AttachmentBlock.failed": "该文件无法上传,因为已经达到了文件大小的限制。", "AttachmentBlock.upload": "附件正在上传。", "AttachmentBlock.uploadSuccess": "附件已上传。", "AttachmentElement.delete-confirmation-dialog-button-text": "删除", "AttachmentElement.download": "下载", "AttachmentElement.upload-percentage": "上传中…({uploadPercent}%)", "BoardComponent.add-a-group": "+ 新增群组", "BoardComponent.delete": "删除", "BoardComponent.hidden-columns": "隐藏列", "BoardComponent.hide": "隐藏", "BoardComponent.new": "+ 新增", "BoardComponent.no-property": "无 {property}", "BoardComponent.no-property-title": "{property} 属性为空的项目将转到此处,该列无法删除。", "BoardComponent.show": "显示", "BoardMember.schemeAdmin": "管理", "BoardMember.schemeCommenter": "评论者", "BoardMember.schemeEditor": "编辑器", "BoardMember.schemeNone": "无", "BoardMember.schemeViewer": "视图", "BoardMember.unlinkChannel": "断开", "BoardPage.newVersion": "Boards 的新版本已可用,点击这里重新加载。", "BoardPage.syncFailed": "板块或许已被删除或访问授权已被撤销。", "BoardTemplateSelector.add-template": "创建新模板", "BoardTemplateSelector.create-empty-board": "创建空白板块", "BoardTemplateSelector.delete-template": "删除", "BoardTemplateSelector.description": "选择一个模板助你开始。或者创建一个空白板块,从零开始。", "BoardTemplateSelector.edit-template": "编辑", "BoardTemplateSelector.plugin.no-content-description": "使用下面定义好的任意模板给侧栏添加一个看板,或者从头开始。", "BoardTemplateSelector.plugin.no-content-title": "创建一个看板", "BoardTemplateSelector.title": "创建一个看板", "BoardTemplateSelector.use-this-template": "使用该模板", "BoardsSwitcher.Title": "查找板块", "BoardsUnfurl.Limited": "由于卡片被存档,其他细节被隐藏", "BoardsUnfurl.Remainder": "+{remainder} 更多", "BoardsUnfurl.Updated": "于 {time} 更新", "Calculations.Options.average.displayName": "平均", "Calculations.Options.average.label": "平均", "Calculations.Options.count.displayName": "计数", "Calculations.Options.count.label": "计数", "Calculations.Options.countChecked.displayName": "选中", "Calculations.Options.countChecked.label": "选中计数", "Calculations.Options.countUnchecked.displayName": "未选中", "Calculations.Options.countUnchecked.label": "未选中计数", "Calculations.Options.countUniqueValue.displayName": "唯一", "Calculations.Options.countUniqueValue.label": "唯一值计数", "Calculations.Options.countValue.displayName": "值", "Calculations.Options.countValue.label": "值计数", "Calculations.Options.dateRange.displayName": "范围", "Calculations.Options.dateRange.label": "范围", "Calculations.Options.earliest.displayName": "最早的", "Calculations.Options.earliest.label": "最早的", "Calculations.Options.latest.displayName": "最新的", "Calculations.Options.latest.label": "最新的", "Calculations.Options.max.displayName": "最大的", "Calculations.Options.max.label": "最大的", "Calculations.Options.median.displayName": "中位数", "Calculations.Options.median.label": "中位数", "Calculations.Options.min.displayName": "最小的", "Calculations.Options.min.label": "最小值", "Calculations.Options.none.displayName": "计算", "Calculations.Options.none.label": "无", "Calculations.Options.percentChecked.displayName": "已选中", "Calculations.Options.percentChecked.label": "已选中的百分比", "Calculations.Options.percentUnchecked.displayName": "未选中", "Calculations.Options.percentUnchecked.label": "未选中的百分比", "Calculations.Options.range.displayName": "范围", "Calculations.Options.range.label": "范围", "Calculations.Options.sum.displayName": "总计", "Calculations.Options.sum.label": "总计", "CalendarCard.untitled": "无内容", "CardActionsMenu.copiedLink": "复制成功!", "CardActionsMenu.copyLink": "复制链接", "CardActionsMenu.delete": "删除", "CardActionsMenu.duplicate": "重复", "CardBadges.title-checkboxes": "Checkbox", "CardBadges.title-comments": "评论", "CardBadges.title-description": "此卡片有描述内容", "CardDetail.Attach": "附加", "CardDetail.Follow": "关注", "CardDetail.Following": "关注中", "CardDetail.add-content": "新增内容", "CardDetail.add-icon": "新增图标", "CardDetail.add-property": "+ 新增属性", "CardDetail.addCardText": "新增卡片文本", "CardDetail.limited-body": "升级到我们的专业或企业计划。", "CardDetail.limited-button": "升级", "CardDetail.limited-title": "这张卡片是隐藏的", "CardDetail.moveContent": "移动卡片内容", "CardDetail.new-comment-placeholder": "新增评论...", "CardDetailProperty.confirm-delete-heading": "确认删除此属性", "CardDetailProperty.confirm-delete-subtext": "确定要删除属性:{propertyName}?也会同时删除这个版面中所有其他卡片的属性。", "CardDetailProperty.confirm-property-name-change-subtext": "你确定要改变属性名称\"{propertyName}\" {customText}?这将影响此板块中的{numOfCards}个卡片,并可能导致数据丢失。", "CardDetailProperty.confirm-property-type-change": "确认修改此属性的类型", "CardDetailProperty.delete-action-button": "删除", "CardDetailProperty.property-change-action-button": "修改属性", "CardDetailProperty.property-changed": "已成功修改属性!", "CardDetailProperty.property-deleted": "成功删除 {propertyName}!", "CardDetailProperty.property-name-change-subtext": "属性的类型从\"{oldPropType}\" 更改为\"{newPropType}\"", "CardDetial.limited-link": "了解更多关于我们的计划。", "CardDialog.delete-confirmation-dialog-attachment": "确认删除附件", "CardDialog.delete-confirmation-dialog-button-text": "删除", "CardDialog.delete-confirmation-dialog-heading": "确认删除卡片", "CardDialog.editing-template": "您正在编辑模板。", "CardDialog.nocard": "卡片不存在或者无法被存取。", "Categories.CreateCategoryDialog.CancelText": "取消", "Categories.CreateCategoryDialog.CreateText": "新增", "Categories.CreateCategoryDialog.Placeholder": "命名你的类别", "Categories.CreateCategoryDialog.UpdateText": "更新", "CenterPanel.Login": "登录", "CenterPanel.Share": "分享", "ChannelIntro.CreateBoard": "创建一个板块", "ColorOption.selectColor": "选择{color}", "Comment.delete": "删除", "CommentsList.send": "发送", "ConfirmPerson.empty": "空", "ConfirmPerson.search": "搜索...", "ConfirmationDialog.cancel-action": "取消", "ConfirmationDialog.confirm-action": "确认", "ContentBlock.Delete": "删除", "ContentBlock.DeleteAction": "删除", "ContentBlock.addElement": "新增 {type}", "ContentBlock.checkbox": "复选框", "ContentBlock.divider": "分割线", "ContentBlock.editCardCheckbox": "切换复选框", "ContentBlock.editCardCheckboxText": "编辑卡片文字", "ContentBlock.editCardText": "编辑卡片文字", "ContentBlock.editText": "编辑文字...", "ContentBlock.image": "图片", "ContentBlock.insertAbove": "在上方插入", "ContentBlock.moveBlock": "移动卡片内容", "ContentBlock.moveDown": "下移", "ContentBlock.moveUp": "上移", "ContentBlock.text": "文字", "DateRange.clear": "清除", "DateRange.empty": "空的", "DateRange.endDate": "结束日期", "DateRange.today": "今天", "DeleteBoardDialog.confirm-cancel": "取消", "DeleteBoardDialog.confirm-delete": "删除", "DeleteBoardDialog.confirm-info": "确定要删除版块\"{boardTitle}\"?删除后,也将删除此版块中的所有卡片。", "DeleteBoardDialog.confirm-info-template": "你确定要删除板块模板\"{boardTitle}\"吗?", "DeleteBoardDialog.confirm-tite": "确认删除板块", "DeleteBoardDialog.confirm-tite-template": "确认删除板块模板", "Dialog.closeDialog": "关闭对话框", "EditableDayPicker.today": "今天", "Error.mobileweb": "移动端页面的支持目前处于早期测试阶段。不是所有的功能都已实现。", "Error.websocket-closed": "Websocket 连接关闭,连接中断。如果这种情况仍然存在,请检查您的服务器或网页代理配置。", "Filter.contains": "包含", "Filter.ends-with": "结束于", "Filter.includes": "含有", "Filter.is": "是", "Filter.is-empty": "为空", "Filter.is-not-empty": "不为空", "Filter.is-not-set": "未设置", "Filter.is-set": "被设定为", "Filter.not-contains": "不包含", "Filter.not-ends-with": "不结束于", "Filter.not-includes": "不含有", "Filter.not-starts-with": "不开始于", "Filter.starts-with": "开始于", "FilterByText.placeholder": "过滤文本", "FilterComponent.add-filter": "+ 增加过滤条件", "FilterComponent.delete": "删除", "FilterValue.empty": "(空)", "FindBoardsDialog.IntroText": "搜索板块", "FindBoardsDialog.NoResultsFor": "没有\"{searchQuery}\"相关的结果", "FindBoardsDialog.NoResultsSubtext": "请检查拼写或者查找其他内容。", "FindBoardsDialog.SubTitle": "输入内容来查找板块。使用上/下浏览。ENTER选择,ESC取消", "FindBoardsDialog.Title": "查找板块", "GroupBy.hideEmptyGroups": "隐藏{count}个空组", "GroupBy.showHiddenGroups": "显示已隐藏的{count}个组", "GroupBy.ungroup": "未分组", "HideBoard.MenuOption": "隐藏板块", "KanbanCard.untitled": "无标题", "MentionSuggestion.is-not-board-member": "(非板块成员)", "Mutator.new-board-from-template": "从模板创建板块", "Mutator.new-card-from-template": "使用模板新增卡片", "Mutator.new-template-from-card": "从卡片新增模板", "OnboardingTour.AddComments.Body": "你可以对问题进行评论,甚至可以@提及你的Mattermost同伴,以引起他们的注意。", "OnboardingTour.AddComments.Title": "添加评论", "OnboardingTour.AddDescription.Body": "在你的卡片上添加描述,以便其他人了解卡片的内容。", "OnboardingTour.AddDescription.Title": "添加描述", "OnboardingTour.AddProperties.Body": "为卡片添加各种属性,使其更加强大。", "OnboardingTour.AddProperties.Title": "添加属性", "OnboardingTour.AddView.Body": "在这里创建一个新的视图,用不同的布局来组织你的板块。", "OnboardingTour.AddView.Title": "添加一个新的视图", "OnboardingTour.CopyLink.Body": "你可以通过频道,私信和群聊分享链接来和成员们一起共享卡片。", "OnboardingTour.CopyLink.Title": "复制链接", "OnboardingTour.OpenACard.Body": "打开卡片来探索板块的高效使用方法,从而助力你的整理项目。", "OnboardingTour.OpenACard.Title": "打开一个卡片", "OnboardingTour.ShareBoard.Body": "你可以分享板块,不管是与内部成员,还是公开发布到外部的机构。", "OnboardingTour.ShareBoard.Title": "分享板块", "PersonProperty.board-members": "板块成员", "PersonProperty.me": "我", "PersonProperty.non-board-members": "非板块成员", "PropertyMenu.Delete": "删除", "PropertyMenu.changeType": "修改属性类型", "PropertyMenu.selectType": "选择属性类型", "PropertyMenu.typeTitle": "类型", "PropertyType.Checkbox": "复选框", "PropertyType.CreatedBy": "创建者", "PropertyType.CreatedTime": "创建时间", "PropertyType.Date": "日期", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "多人", "PropertyType.MultiSelect": "多选", "PropertyType.Number": "数字", "PropertyType.Person": "个人", "PropertyType.Phone": "电话号码", "PropertyType.Select": "选取", "PropertyType.Text": "文字框", "PropertyType.Unknown": "未知", "PropertyType.UpdatedBy": "最后更新者", "PropertyType.UpdatedTime": "上次更新时间", "PropertyType.Url": "URL", "PropertyValueElement.empty": "空的", "RegistrationLink.confirmRegenerateToken": "此动作将使先前分享的链接无效。确定要进行吗?", "RegistrationLink.copiedLink": "已复制!", "RegistrationLink.copyLink": "复制链接", "RegistrationLink.description": "将此链接分享给他人以建立帐号:", "RegistrationLink.regenerateToken": "重新生成令牌", "RegistrationLink.tokenRegenerated": "已重新生成注册链接", "ShareBoard.PublishDescription": "发布并与所有人分享 \"只读 \"链接。", "ShareBoard.PublishTitle": "发布到网上", "ShareBoard.ShareInternal": "内部分享", "ShareBoard.ShareInternalDescription": "有权限的用户将能够使用这个链接。", "ShareBoard.Title": "分享板块", "ShareBoard.confirmRegenerateToken": "此动作将使先前分享的链接无效。确定要进行吗?", "ShareBoard.copiedLink": "已复制!", "ShareBoard.copyLink": "复制链接", "ShareBoard.regenerate": "重新生成令牌", "ShareBoard.searchPlaceholder": "搜索成员和频道", "ShareBoard.teamPermissionsText": "在{teamName}团队的每个人", "ShareBoard.tokenRegenrated": "已重新产生令牌", "ShareBoard.userPermissionsRemoveMemberText": "移除成员", "ShareBoard.userPermissionsYouText": "(你)", "ShareTemplate.Title": "分享模板", "ShareTemplate.searchPlaceholder": "搜索成员", "Sidebar.about": "关于 Focalboard", "Sidebar.add-board": "+ 新增版面", "Sidebar.changePassword": "变更密码", "Sidebar.delete-board": "删除版面", "Sidebar.duplicate-board": "复制板块", "Sidebar.export-archive": "导出档案", "Sidebar.import": "导入", "Sidebar.import-archive": "导入档案", "Sidebar.invite-users": "邀请使用者", "Sidebar.logout": "登出", "Sidebar.new-category.badge": "新建", "Sidebar.new-category.drag-boards-cta": "拖动板块到这里...", "Sidebar.no-boards-in-category": "里面没有板块", "Sidebar.product-tour": "产品导览", "Sidebar.random-icons": "随机图标", "Sidebar.set-language": "设定语言", "Sidebar.set-theme": "设置主题", "Sidebar.settings": "设定", "Sidebar.template-from-board": "从板块新增一个模板", "Sidebar.untitled-board": "(无标题版面)", "Sidebar.untitled-view": "(未命名视图)", "SidebarCategories.BlocksMenu.Move": "移动到...", "SidebarCategories.CategoryMenu.CreateNew": "创建新类别", "SidebarCategories.CategoryMenu.Delete": "删除类别", "SidebarCategories.CategoryMenu.DeleteModal.Body": "在于{categoryName}的板块会被移回板块类别。这并不会移除任何板块。", "SidebarCategories.CategoryMenu.DeleteModal.Title": "删除此类别?", "SidebarCategories.CategoryMenu.Update": "重命名类别", "SidebarTour.ManageCategories.Body": "新建并管理自定义的类别。类别是用户专属的,所以移动板块到你的类别不会影响到使用同个板块的其他成员。", "SidebarTour.ManageCategories.Title": "管理类别", "SidebarTour.SearchForBoards.Body": "打开类别切换器(Cmd/Ctrl+K)来快速查找并添加板块到你的侧边栏。", "SidebarTour.SearchForBoards.Title": "搜索板块", "SidebarTour.SidebarCategories.Body": "你所有的板块现会在侧边栏下被管理。无需在不同工作区中进行切换。基于你之前工作区的一次性自定义板块,将会作为v7.2版本更新自动创建。这个特性可以在设置里更改会或移除。", "SidebarTour.SidebarCategories.Link": "了解更多", "SidebarTour.SidebarCategories.Title": "侧边栏类别", "SiteStats.total_boards": "所有板块", "SiteStats.total_cards": "所有卡片", "TableComponent.add-icon": "加入图标", "TableComponent.name": "姓名", "TableComponent.plus-new": "+ 新增", "TableHeaderMenu.delete": "删除", "TableHeaderMenu.duplicate": "制作副本", "TableHeaderMenu.hide": "隐藏", "TableHeaderMenu.insert-left": "在左侧插入", "TableHeaderMenu.insert-right": "在右侧插入", "TableHeaderMenu.sort-ascending": "升序排列", "TableHeaderMenu.sort-descending": "降序排列", "TableRow.DuplicateCard": "复制卡片", "TableRow.MoreOption": "更多操作", "TableRow.open": "开启", "TopBar.give-feedback": "反馈问题", "URLProperty.copiedLink": "已复制!", "URLProperty.copy": "复制", "URLProperty.edit": "编辑", "UndoRedoHotKeys.canRedo": "撤回", "UndoRedoHotKeys.canRedo-with-description": "撤回 {description}", "UndoRedoHotKeys.canUndo": "撤销", "UndoRedoHotKeys.canUndo-with-description": "撤销 {description}", "UndoRedoHotKeys.cannotRedo": "已没有操作可撤回", "UndoRedoHotKeys.cannotUndo": "已没有操作可撤销", "ValueSelector.noOptions": "没有选项。现在添加一个!", "ValueSelector.valueSelector": "值选择器", "ValueSelectorLabel.openMenu": "打开菜单", "VersionMessage.help": "了解查看新版本有什么新特性。", "View.AddView": "添加视图", "View.Board": "板块", "View.DeleteView": "删除视图", "View.DuplicateView": "复制视图", "View.Gallery": "画廊", "View.NewBoardTitle": "版面视图", "View.NewCalendarTitle": "日历视图", "View.NewGalleryTitle": "画廊视图", "View.NewTableTitle": "图表视图", "View.NewTemplateDefaultTitle": "未命名模板", "View.NewTemplateTitle": "未命名", "View.Table": "图表", "ViewHeader.add-template": "+ 新模板", "ViewHeader.delete-template": "删除", "ViewHeader.display-by": "以{property}显示", "ViewHeader.edit-template": "编辑", "ViewHeader.empty-card": "空白卡片", "ViewHeader.export-board-archive": "导出版面归档", "ViewHeader.export-complete": "导出完成!", "ViewHeader.export-csv": "导出为 CSV", "ViewHeader.export-failed": "导出失败!", "ViewHeader.filter": "筛选", "ViewHeader.group-by": "以{property}分组", "ViewHeader.new": "新", "ViewHeader.properties": "属性", "ViewHeader.properties-menu": "属性菜单", "ViewHeader.search-text": "搜索卡片", "ViewHeader.select-a-template": "选择范本", "ViewHeader.set-default-template": "设为默认范本", "ViewHeader.sort": "排序", "ViewHeader.untitled": "无标题", "ViewHeader.view-header-menu": "查看标题菜单", "ViewHeader.view-menu": "查看菜单", "ViewLimitDialog.Heading": "已达到板块观看的限制", "ViewLimitDialog.PrimaryButton.Title.Admin": "升级", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "通知管理员", "ViewLimitDialog.Subtext.Admin": "升级到专业版或企业版。", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "了解更多关于我们的付费套装。", "ViewLimitDialog.Subtext.RegularUser": "通知你的管理员来升级到专业版和企业版。", "ViewLimitDialog.UpgradeImg.AltText": "升级图片", "ViewLimitDialog.notifyAdmin.Success": "已通知管理员", "ViewTitle.hide-description": "隐藏描述", "ViewTitle.pick-icon": "挑选图标", "ViewTitle.random-icon": "随机", "ViewTitle.remove-icon": "移除图标", "ViewTitle.show-description": "显示描述", "ViewTitle.untitled-board": "无标题板块", "WelcomePage.Description": "板块是一个项目管理工具,使用熟悉的看板视图,帮助你的团队策划、组织、跟踪和管理跨团队的工作。", "WelcomePage.Explore.Button": "探索", "WelcomePage.Heading": "欢迎来到板块", "WelcomePage.NoThanks.Text": "不了,请让我自己设置", "WelcomePage.StartUsingIt.Text": "开始使用", "Workspace.editing-board-template": "您正在编辑版面模板。", "badge.guest": "访客", "boardSelector.confirm-link-board": "连接板块到频道", "boardSelector.confirm-link-board-button": "是的,连接板块", "boardSelector.confirm-link-board-subtext": "当你连接“{boardName}”到频道时,所有频道的成员(现有的或新的)都可以编辑。这并不包括访客。你随时都可以取消板块与频道的连接。", "boardSelector.confirm-link-board-subtext-with-other-channel": "当你连接\"{boardName}\"到频道时,此频道的所有成员(现有的和新的)将可以进行编辑,这并不包括访客。{lineBreak} 此板块目前与另一个频道已有连接,如果在此进行新的连接,那么将会自动取消之前连接的频道。", "boardSelector.create-a-board": "创建板块", "boardSelector.link": "连接", "boardSelector.search-for-boards": "搜索板块", "boardSelector.title": "连接板块", "boardSelector.unlink": "取消连接", "calendar.month": "月", "calendar.today": "今天", "calendar.week": "周", "centerPanel.undefined": "不{propertyName}", "centerPanel.unknown-user": "陌生用户", "cloudMessage.learn-more": "了解更多", "createImageBlock.failed": "图片上传失败,超过大小限制。", "default-properties.badges": "评论和描述", "default-properties.title": "标题", "error.back-to-home": "回到主页", "error.back-to-team": "回到团队", "error.board-not-found": "未找到板块。", "error.go-login": "登陆", "error.invalid-read-only-board": "你没有权限访问此板块,请登陆后再进行访问板块。", "error.not-logged-in": "尚未登陆或会话超时,请登陆后再进行访问板块。", "error.page.title": "抱歉,出现了一些错误", "error.team-undefined": "不是有效的团队。", "error.unknown": "发生了一些错误。", "generic.previous": "上一个", "guest-no-board.subtitle": "你尚未有权限访问此团队的任何一个板块,请等待某人把你添加到某个板块。", "guest-no-board.title": "尚未有板块", "imagePaste.upload-failed": "图片上传失败,超过大小限制。", "limitedCard.title": "卡片已隐藏", "login.log-in-button": "登录", "login.log-in-title": "登录", "login.register-button": "或创建一个帐户(如果您没有帐户)", "new_channel_modal.create_board.empty_board_description": "创建一个空白的板块", "new_channel_modal.create_board.empty_board_title": "空白板块", "new_channel_modal.create_board.select_template_placeholder": "选择模板", "new_channel_modal.create_board.title": "为此频道创建一个新板块", "notification-box-card-limit-reached.close-tooltip": "小睡十天", "notification-box-card-limit-reached.contact-link": "通知你的管理员", "notification-box-card-limit-reached.link": "升级到付费版", "notification-box-card-limit-reached.title": "板块上的{cards}卡片已隐藏", "notification-box-cards-hidden.title": "此行动已隐藏其他卡片", "notification-box.card-limit-reached.not-admin.text": "要访问存档的卡片,你需要通过 {contactLink} 来升级到付费版。", "notification-box.card-limit-reached.text": "已达到卡片上限,如需查看旧卡片请点{link}", "person.add-user-to-board": "将 {username} 加入板块", "person.add-user-to-board-confirm-button": "添加到板块", "person.add-user-to-board-permissions": "权限", "person.add-user-to-board-question": "你想将 {username} 加入板块吗?", "person.add-user-to-board-warning": "{username} 不是此板块的成员,因此不会受到任何关于此板块的通知。", "register.login-button": "或登录(如果您已拥有帐户)", "register.signup-title": "注册您的帐户", "rhs-board-non-admin-msg": "你不是板块的管理员", "rhs-boards.add": "添加", "rhs-boards.dm": "私信", "rhs-boards.gm": "群聊", "rhs-boards.header.dm": "此私信", "rhs-boards.header.gm": "此群聊信息", "rhs-boards.last-update-at": "最后更新日为:{datetime}", "rhs-boards.link-boards-to-channel": "把板块连接到 {channelName}", "rhs-boards.linked-boards": "连接板块", "rhs-boards.no-boards-linked-to-channel": "尚未有板块与{channelName} 连接", "rhs-boards.no-boards-linked-to-channel-description": "板块是一个能帮助我们定义,组织,追踪和管理团队工作的一个专业管理工具,可通过使用熟悉的看板视图。", "rhs-boards.unlink-board": "取消连接板块", "rhs-boards.unlink-board1": "取消连接板块", "rhs-channel-boards-header.title": "板块", "share-board.publish": "发布", "share-board.share": "分享", "shareBoard.channels-select-group": "频道", "shareBoard.confirm-change-team-role.body": "此板块低于“{role}”的所有人都将于现在被提升到{role}。你确认要更改此板块的最低职责?", "shareBoard.confirm-change-team-role.confirmBtnText": "更改板块的最低职责", "shareBoard.confirm-change-team-role.title": "更改板块的最低职责", "shareBoard.confirm-link-channel": "连接板块到频道", "shareBoard.confirm-link-channel-button": "连接频道", "shareBoard.confirm-link-channel-button-with-other-channel": "再此取消或进行连接", "shareBoard.confirm-link-channel-subtext": "当你把频道连接到一个板块,此频道里的所有成员(现有的或新的)都可以进行编辑,这并不包括访客。", "shareBoard.confirm-link-channel-subtext-with-other-channel": "当你把频道连接到一个板块,此频道里的所有成员(现有的或新的)都可以进行编辑,这并不包括访客。{lineBreak}此板块目前与另一个频道已有连接,如果在此进行新的连接,那么将会自动取消之前连接的频道。", "shareBoard.confirm-unlink.body": "当你取消频道与板块的连接,频道的所有成员(现有的或新的)将会失去板块的访问权限,除非单独给予许可。", "shareBoard.confirm-unlink.confirmBtnText": "取消连接频道", "shareBoard.confirm-unlink.title": "取消连接此板块的频道", "shareBoard.lastAdmin": "板块至少得有一位管理员", "shareBoard.members-select-group": "成员", "shareBoard.unknown-channel-display-name": "未知频道", "tutorial_tip.finish_tour": "完成", "tutorial_tip.got_it": "了解", "tutorial_tip.ok": "下一个", "tutorial_tip.out": "选择不使用这些提示。", "tutorial_tip.seen": "之前有见到过吗?" } ================================================ FILE: webapp/i18n/zh_Hant.json ================================================ { "AppBar.Tooltip": "切換看板", "Attachment.Attachment-title": "附件", "AttachmentBlock.DeleteAction": "刪除", "AttachmentBlock.addElement": "添加 {type}", "AttachmentBlock.delete": "已刪除附件", "AttachmentBlock.failed": "無法上傳文件。 附件大小已達到限制。", "AttachmentBlock.upload": "附件正在上傳。", "AttachmentBlock.uploadSuccess": "附件已上傳", "AttachmentElement.delete-confirmation-dialog-button-text": "刪除", "AttachmentElement.download": "下載", "AttachmentElement.upload-percentage": "正在上傳...({uploadPercent}%)", "BoardComponent.add-a-group": "+ 新增群組", "BoardComponent.delete": "刪除", "BoardComponent.hidden-columns": "隱藏列", "BoardComponent.hide": "隱藏", "BoardComponent.new": "+ 新增", "BoardComponent.no-property": "無 {property}", "BoardComponent.no-property-title": "{property} 屬性為空的項目將轉到此處。該列無法刪除。", "BoardComponent.show": "顯示", "BoardMember.schemeAdmin": "管理員", "BoardMember.schemeCommenter": "評論者", "BoardMember.schemeEditor": "編輯者", "BoardMember.schemeNone": "無", "BoardMember.schemeViewer": "閱覽者", "BoardMember.unlinkChannel": "取消連結", "BoardPage.newVersion": "新版本的版面已可用,點此處重新載入。", "BoardPage.syncFailed": "版面可能已被刪除或已被撤銷存取。", "BoardTemplateSelector.add-template": "創建新模板", "BoardTemplateSelector.create-empty-board": "建立空看板", "BoardTemplateSelector.delete-template": "刪除", "BoardTemplateSelector.description": "使用下方定義的模板或從頭開始,從側邊欄新增一個區塊。", "BoardTemplateSelector.edit-template": "編輯", "BoardTemplateSelector.plugin.no-content-description": "在側邊欄新增一個板塊,可以使用下方定義的任意範本或從新開始。", "BoardTemplateSelector.plugin.no-content-title": "建立看板", "BoardTemplateSelector.title": "建立看板", "BoardTemplateSelector.use-this-template": "使用此範本", "BoardsSwitcher.Title": "尋找看板", "BoardsUnfurl.Limited": "由於該卡片被封存,其他細節都被影藏", "BoardsUnfurl.Remainder": "+{remainder} 更多", "BoardsUnfurl.Updated": "更新時間 {time}", "Calculations.Options.average.displayName": "平均", "Calculations.Options.average.label": "平均", "Calculations.Options.count.displayName": "數量", "Calculations.Options.count.label": "數量", "Calculations.Options.countChecked.displayName": "已選取", "Calculations.Options.countChecked.label": "選取數量", "Calculations.Options.countUnchecked.displayName": "未選取", "Calculations.Options.countUnchecked.label": "未選取數量", "Calculations.Options.countUniqueValue.displayName": "唯一值", "Calculations.Options.countUniqueValue.label": "唯一值數量", "Calculations.Options.countValue.displayName": "值", "Calculations.Options.countValue.label": "總計", "Calculations.Options.dateRange.displayName": "區間", "Calculations.Options.dateRange.label": "區間", "Calculations.Options.earliest.displayName": "最前的", "Calculations.Options.earliest.label": "最前的", "Calculations.Options.latest.displayName": "最後的", "Calculations.Options.latest.label": "最後的", "Calculations.Options.max.displayName": "最大的", "Calculations.Options.max.label": "最大的", "Calculations.Options.median.displayName": "中位數", "Calculations.Options.median.label": "中位數", "Calculations.Options.min.displayName": "最小的", "Calculations.Options.min.label": "最小的", "Calculations.Options.none.displayName": "計算", "Calculations.Options.none.label": "無", "Calculations.Options.percentChecked.displayName": "已選取", "Calculations.Options.percentChecked.label": "已選取百分比", "Calculations.Options.percentUnchecked.displayName": "未選取", "Calculations.Options.percentUnchecked.label": "未選取百分比", "Calculations.Options.range.displayName": "範圍", "Calculations.Options.range.label": "範圍", "Calculations.Options.sum.displayName": "總和", "Calculations.Options.sum.label": "總和", "CalendarCard.untitled": "無標題", "CardActionsMenu.copiedLink": "複製!", "CardActionsMenu.copyLink": "複製連結", "CardActionsMenu.delete": "刪除", "CardActionsMenu.duplicate": "重複", "CardBadges.title-checkboxes": "選取框", "CardBadges.title-comments": "評論", "CardBadges.title-description": "此卡片有說明", "CardDetail.Attach": "附加", "CardDetail.Follow": "追蹤", "CardDetail.Following": "追蹤中", "CardDetail.add-content": "新增內容", "CardDetail.add-icon": "新增圖示", "CardDetail.add-property": "+ 新增屬性", "CardDetail.addCardText": "新增卡片文本", "CardDetail.limited-body": "升級到專業版或是企業版", "CardDetail.limited-button": "升級", "CardDetail.limited-title": "此卡片被影藏", "CardDetail.moveContent": "移動卡片內容", "CardDetail.new-comment-placeholder": "新增評論…", "CardDetailProperty.confirm-delete-heading": "確認刪除屬性", "CardDetailProperty.confirm-delete-subtext": "您確定要刪除屬性“{propertyName}”嗎? 刪除它會從該板的所有卡中刪除該屬性。", "CardDetailProperty.confirm-property-name-change-subtext": "您確定要更改屬性“{propertyName}”{customText} 嗎? 這將影響此板中 {numOfCards} 卡的值,並可能導致數據丟失。", "CardDetailProperty.confirm-property-type-change": "確認屬性變更", "CardDetailProperty.delete-action-button": "刪除", "CardDetailProperty.property-change-action-button": "變更屬性", "CardDetailProperty.property-changed": "已成功變更屬性!", "CardDetailProperty.property-deleted": "成功刪除 {propertyName}!", "CardDetailProperty.property-name-change-subtext": "類型從 \"{oldPropType}\" 變更為 \"{newPropType}\"", "CardDetial.limited-link": "了解更多我們的計畫.", "CardDialog.delete-confirmation-dialog-attachment": "確認刪除附件", "CardDialog.delete-confirmation-dialog-button-text": "刪除", "CardDialog.delete-confirmation-dialog-heading": "確認刪除卡片", "CardDialog.editing-template": "您正在編輯範本。", "CardDialog.nocard": "卡片不存在或者無法被存取。", "Categories.CreateCategoryDialog.CancelText": "取消", "Categories.CreateCategoryDialog.CreateText": "新增", "Categories.CreateCategoryDialog.Placeholder": "命名你的類別", "Categories.CreateCategoryDialog.UpdateText": "更新", "CenterPanel.Login": "登入", "CenterPanel.Share": "分享", "ChannelIntro.CreateBoard": "建立看板", "ColorOption.selectColor": "{color} 選擇顏色", "Comment.delete": "刪除", "CommentsList.send": "發送", "ConfirmPerson.empty": "空白", "ConfirmPerson.search": "查詢...", "ConfirmationDialog.cancel-action": "取消", "ConfirmationDialog.confirm-action": "確認", "ContentBlock.Delete": "刪除", "ContentBlock.DeleteAction": "刪除", "ContentBlock.addElement": "新增 {type}", "ContentBlock.checkbox": "復選框", "ContentBlock.divider": "分隔線", "ContentBlock.editCardCheckbox": "切換復選框", "ContentBlock.editCardCheckboxText": "編輯卡片文字", "ContentBlock.editCardText": "編輯卡片文字", "ContentBlock.editText": "編輯文字...", "ContentBlock.image": "圖片", "ContentBlock.insertAbove": "在上方插入", "ContentBlock.moveBlock": "移動卡片內容", "ContentBlock.moveDown": "下移", "ContentBlock.moveUp": "上移", "ContentBlock.text": "文字", "DateRange.clear": "清除", "DateRange.empty": "空白", "DateRange.endDate": "結束日期", "DateRange.today": "今日", "DeleteBoardDialog.confirm-cancel": "取消", "DeleteBoardDialog.confirm-delete": "刪除", "DeleteBoardDialog.confirm-info": "您確定要刪除圖板“{boardTitle}”嗎? 刪除它會刪除棋盤中的所有卡片。", "DeleteBoardDialog.confirm-info-template": "你確定要刪除此板塊名稱{boardTitle}範例?", "DeleteBoardDialog.confirm-tite": "確認刪除看板", "DeleteBoardDialog.confirm-tite-template": "確認刪除看板範本", "Dialog.closeDialog": "關閉對話框", "EditableDayPicker.today": "今天", "Error.mobileweb": "手機板目前處於測試階段,不會呈現所有功能.", "Error.websocket-closed": "Websocket 連線中斷,如果此問題持續發生,請檢查網路。", "Filter.contains": "包含", "Filter.ends-with": "結尾是", "Filter.includes": "含有", "Filter.is": "是", "Filter.is-empty": "為空", "Filter.is-not-empty": "不為空", "Filter.is-not-set": "尚未設定", "Filter.is-set": "已設定", "Filter.not-contains": "不包含", "Filter.not-ends-with": "不以結束", "Filter.not-includes": "不包含", "Filter.not-starts-with": "不以開始", "Filter.starts-with": "起始於", "FilterByText.placeholder": "過濾文字", "FilterComponent.add-filter": "+ 增加過濾條件", "FilterComponent.delete": "刪除", "FilterValue.empty": "(空白)", "FindBoardsDialog.IntroText": "查詢看板", "FindBoardsDialog.NoResultsFor": "「{searchQuery}」搜尋未果", "FindBoardsDialog.NoResultsSubtext": "檢查錯字或嘗試其他搜尋.", "FindBoardsDialog.SubTitle": "輸入已找到面板.使用 UP/DOWN來瀏覽.ENTER來搜尋, ESC 來取消", "FindBoardsDialog.Title": "尋找看板", "GroupBy.hideEmptyGroups": "隱藏 {count}個空群組", "GroupBy.showHiddenGroups": "顯示{count}個被隱藏的空群組", "GroupBy.ungroup": "未分组", "HideBoard.MenuOption": "隱藏面板", "KanbanCard.untitled": "無標題", "MentionSuggestion.is-not-board-member": "(非面板管理者)", "Mutator.new-board-from-template": "新的面板模組", "Mutator.new-card-from-template": "使用範本新增卡片", "Mutator.new-template-from-card": "從卡片新增範本", "OnboardingTour.AddComments.Body": "你可以對問題進行評論,甚至標記提到你的Mattermost夥伴,引起他們的注意。", "OnboardingTour.AddComments.Title": "新增評論", "OnboardingTour.AddDescription.Body": "在卡片上新增描述讓其他成員知道此卡片內容.", "OnboardingTour.AddDescription.Title": "新增敘述", "OnboardingTour.AddProperties.Body": "為卡片新增各式屬性使其更加強大", "OnboardingTour.AddProperties.Title": "新增屬性", "OnboardingTour.AddView.Body": "轉到此處創建一個新視圖以使用不同的佈局組織您的看板。", "OnboardingTour.AddView.Title": "新增視圖", "OnboardingTour.CopyLink.Body": "您可以通過複製鏈接並將其粘貼到頻道、直接消息或群組消息中來與隊友分享您的名片。", "OnboardingTour.CopyLink.Title": "複製連結", "OnboardingTour.OpenACard.Body": "打開卡片查看看板可以幫助你組織工作的優秀方法。", "OnboardingTour.OpenACard.Title": "瀏覽卡片", "OnboardingTour.ShareBoard.Body": "您可以在內部、團隊內部分享看板,或公開發布讓組織外部查看。", "OnboardingTour.ShareBoard.Title": "分享看板", "PersonProperty.board-members": "看板成員", "PersonProperty.me": "我", "PersonProperty.non-board-members": "不是看板成員", "PropertyMenu.Delete": "刪除", "PropertyMenu.changeType": "修改屬性類型", "PropertyMenu.selectType": "選擇屬性類型", "PropertyMenu.typeTitle": "類型", "PropertyType.Checkbox": "勾選方塊", "PropertyType.CreatedBy": "建立者", "PropertyType.CreatedTime": "建立時間", "PropertyType.Date": "日期", "PropertyType.Email": "Email", "PropertyType.MultiPerson": "多人", "PropertyType.MultiSelect": "多選", "PropertyType.Number": "數字", "PropertyType.Person": "個人", "PropertyType.Phone": "電話號碼", "PropertyType.Select": "選取", "PropertyType.Text": "文字框", "PropertyType.Unknown": "未知", "PropertyType.UpdatedBy": "最後更新者", "PropertyType.UpdatedTime": "最後更新時間", "PropertyType.Url": "網址", "PropertyValueElement.empty": "空白", "RegistrationLink.confirmRegenerateToken": "此動作將使先前分享的連結無效。確定要進行嗎?", "RegistrationLink.copiedLink": "已複製!", "RegistrationLink.copyLink": "複製連結", "RegistrationLink.description": "將此連結分享給他人以建立帳號:", "RegistrationLink.regenerateToken": "重新產生 token", "RegistrationLink.tokenRegenerated": "已重新產生註冊鏈結", "ShareBoard.PublishDescription": "發布只能讀取的連結。", "ShareBoard.PublishTitle": "發布至網路", "ShareBoard.ShareInternal": "內部分享", "ShareBoard.ShareInternalDescription": "擁有權限的使用者才能使用此連結。", "ShareBoard.Title": "分享看板", "ShareBoard.confirmRegenerateToken": "此動作將使先前分享的鏈結無效。確定要進行嗎?", "ShareBoard.copiedLink": "已複製!", "ShareBoard.copyLink": "複製連結", "ShareBoard.regenerate": "重新產生權杖", "ShareBoard.searchPlaceholder": "查詢人和頻道", "ShareBoard.teamPermissionsText": "在{teamName}的所有人", "ShareBoard.tokenRegenrated": "已重新產生權杖", "ShareBoard.userPermissionsRemoveMemberText": "移除成員", "ShareBoard.userPermissionsYouText": "(你)", "ShareTemplate.Title": "分享範本", "ShareTemplate.searchPlaceholder": "查詢人", "Sidebar.about": "關於 Focalboard", "Sidebar.add-board": "+ 新增看板", "Sidebar.changePassword": "變更密碼", "Sidebar.delete-board": "刪除版面", "Sidebar.duplicate-board": "複製看板", "Sidebar.export-archive": "匯出打包檔", "Sidebar.import": "匯入", "Sidebar.import-archive": "匯入打包檔", "Sidebar.invite-users": "邀請使用者", "Sidebar.logout": "登出", "Sidebar.new-category.badge": "新的", "Sidebar.new-category.drag-boards-cta": "拖板到這裡...", "Sidebar.no-boards-in-category": "沒有看板在裡面", "Sidebar.product-tour": "產品導覽", "Sidebar.random-icons": "隨機圖示", "Sidebar.set-language": "設定語言", "Sidebar.set-theme": "設定佈景主題", "Sidebar.settings": "設定", "Sidebar.template-from-board": "新的看板模板", "Sidebar.untitled-board": "(無標題版面)", "Sidebar.untitled-view": "(無題視圖)", "SidebarCategories.BlocksMenu.Move": "移動至…", "SidebarCategories.CategoryMenu.CreateNew": "新增分類", "SidebarCategories.CategoryMenu.Delete": "刪除分類", "SidebarCategories.CategoryMenu.DeleteModal.Body": "{categoryName} 中的看板將移回看板類別。 您不會從任何板上刪除。", "SidebarCategories.CategoryMenu.DeleteModal.Title": "刪除這個分類?", "SidebarCategories.CategoryMenu.Update": "重新命名分類", "SidebarTour.ManageCategories.Body": "創建和管理自定義類別。 類別是特定於用戶的,因此將圖板移至您的類別不會影響使用同一圖板的其他成員。", "SidebarTour.ManageCategories.Title": "管理分類", "SidebarTour.SearchForBoards.Body": "打開板切換器 (Cmd/Ctrl + K) 以快速搜索板並將其添加到側邊欄。", "SidebarTour.SearchForBoards.Title": "查詢看板", "SidebarTour.SidebarCategories.Body": "您所有的看板現在都在您的新側邊欄下進行了組織。 不再在工作區之間切換。 作為 v7.2 升級的一部分,可能會自動為您創建基於您之前工作區的一次性自定義類別。 這些可以根據您的喜好刪除或編輯。", "SidebarTour.SidebarCategories.Link": "更多", "SidebarTour.SidebarCategories.Title": "邊欄類別", "SiteStats.total_boards": "所有看板", "SiteStats.total_cards": "總卡片數", "TableComponent.add-icon": "加入圖示", "TableComponent.name": "姓名", "TableComponent.plus-new": "+ 新增", "TableHeaderMenu.delete": "刪除", "TableHeaderMenu.duplicate": "制作副本", "TableHeaderMenu.hide": "隱藏", "TableHeaderMenu.insert-left": "在左側插入", "TableHeaderMenu.insert-right": "在右側插入", "TableHeaderMenu.sort-ascending": "升序排列", "TableHeaderMenu.sort-descending": "降序排列", "TableRow.DuplicateCard": "複製卡片", "TableRow.MoreOption": "更多操作", "TableRow.open": "開啟", "TopBar.give-feedback": "提供回饋", "URLProperty.copiedLink": "已複製!", "URLProperty.copy": "複製", "URLProperty.edit": "編輯", "UndoRedoHotKeys.canRedo": "重新執行", "UndoRedoHotKeys.canRedo-with-description": "撤銷{description}", "UndoRedoHotKeys.canUndo": "撤銷", "UndoRedoHotKeys.canUndo-with-description": "重新執行 {description}", "UndoRedoHotKeys.cannotRedo": "沒有可以重寫的", "UndoRedoHotKeys.cannotUndo": "沒有可以取消的", "ValueSelector.noOptions": "沒有選項.開始輸入第一個字!", "ValueSelector.valueSelector": "值選擇器", "ValueSelectorLabel.openMenu": "開啟選單", "VersionMessage.help": "查看這個版本有什麼新功能.", "View.AddView": "新增視圖", "View.Board": "版面", "View.DeleteView": "刪除視圖", "View.DuplicateView": "建立視圖副本", "View.Gallery": "畫廊", "View.NewBoardTitle": "版面視圖", "View.NewCalendarTitle": "行事曆檢視", "View.NewGalleryTitle": "畫廊視圖", "View.NewTableTitle": "圖表視圖", "View.NewTemplateDefaultTitle": "沒有標題的模板", "View.NewTemplateTitle": "沒有標題", "View.Table": "圖表", "ViewHeader.add-template": "新範本", "ViewHeader.delete-template": "刪除", "ViewHeader.display-by": "依據{property}顯示", "ViewHeader.edit-template": "編輯", "ViewHeader.empty-card": "清空卡片", "ViewHeader.export-board-archive": "匯出版面打包檔", "ViewHeader.export-complete": "匯出完成!", "ViewHeader.export-csv": "匯出為 CSV", "ViewHeader.export-failed": "匯出失敗!", "ViewHeader.filter": "篩選", "ViewHeader.group-by": "以 {property} 分組", "ViewHeader.new": "新", "ViewHeader.properties": "屬性", "ViewHeader.properties-menu": "屬性菜單", "ViewHeader.search-text": "搜尋文字", "ViewHeader.select-a-template": "選擇範本", "ViewHeader.set-default-template": "設為預設", "ViewHeader.sort": "排序", "ViewHeader.untitled": "無標題", "ViewHeader.view-header-menu": "查看標題菜單", "ViewHeader.view-menu": "查看菜單", "ViewLimitDialog.Heading": "已達到每個看板觀看限制", "ViewLimitDialog.PrimaryButton.Title.Admin": "升級", "ViewLimitDialog.PrimaryButton.Title.RegularUser": "通知管理者", "ViewLimitDialog.Subtext.Admin": "升級到專業版或企業版", "ViewLimitDialog.Subtext.Admin.PricingPageLink": "了解更多我們的計畫。", "ViewLimitDialog.Subtext.RegularUser": "通知你的管理員升級到專業版或是企業版", "ViewLimitDialog.UpgradeImg.AltText": "升級圖片", "ViewLimitDialog.notifyAdmin.Success": "已經通知管理者", "ViewTitle.hide-description": "隱藏敘述", "ViewTitle.pick-icon": "挑選圖示", "ViewTitle.random-icon": "隨機", "ViewTitle.remove-icon": "移除圖示", "ViewTitle.show-description": "顯示敘述", "ViewTitle.untitled-board": "未命名版面", "WelcomePage.Description": "看板是一個專案管理工具,可以使用熟悉的圖表幫助我們定義、組織、追蹤和管理跨團隊工作。", "WelcomePage.Explore.Button": "探索", "WelcomePage.Heading": "歡迎來到看板", "WelcomePage.NoThanks.Text": "不需要,自己想辦法", "WelcomePage.StartUsingIt.Text": "開始使用", "Workspace.editing-board-template": "您正在編輯版面範本。", "badge.guest": "訪客", "boardSelector.confirm-link-board": "連結看板與頻道", "boardSelector.confirm-link-board-button": "是,連結看板", "boardSelector.confirm-link-board-subtext": "當你將\"{boardName}\"連接到頻道時,該頻道的所有成員(現有的和新的)都可以編輯。並不包含訪客身分。你可以在任何時候從一個頻道上取消看板的連接。", "boardSelector.confirm-link-board-subtext-with-other-channel": "當你將\"{boardName}\"連接到頻道時,該頻道的所有成員(現有的和新的)都可以編輯。並不包含訪客身分。{lineBreak} 看板目前正連接到另一個頻道。如果选择在這裡連接它,將取消另一個連接。", "boardSelector.create-a-board": "建立看板", "boardSelector.link": "連結", "boardSelector.search-for-boards": "搜尋看板", "boardSelector.title": "連結看板", "boardSelector.unlink": "未連結", "calendar.month": "月份", "calendar.today": "今日", "calendar.week": "週別", "centerPanel.undefined": "沒有 {propertyName}", "centerPanel.unknown-user": "未知使用者", "cloudMessage.learn-more": "學習更多", "createImageBlock.failed": "無法上傳檔案,檔案大小超過限制。", "default-properties.badges": "評論和描述", "default-properties.title": "標題", "error.back-to-home": "返回首頁", "error.back-to-team": "回到團隊", "error.board-not-found": "沒有找到看板.", "error.go-login": "登入", "error.invalid-read-only-board": "沒有權限進入此看板.登入後才能訪問.", "error.not-logged-in": "已被登出,請再次登入使用看板。", "error.page.title": "很抱歉,發生了些錯誤", "error.team-undefined": "不是有效的團隊。", "error.unknown": "發生一個錯誤。", "generic.previous": "上一篇", "guest-no-board.subtitle": "你尚未有權限進入此看板,請等人把你加入任何看板。", "guest-no-board.title": "尚未有看板", "imagePaste.upload-failed": "有些檔案無法上傳.檔案大小達上限", "limitedCard.title": "影藏卡片", "login.log-in-button": "登錄", "login.log-in-title": "登錄", "login.register-button": "或創建一個帳戶(如果您沒有帳戶)", "new_channel_modal.create_board.empty_board_description": "建立新的空白看板", "new_channel_modal.create_board.empty_board_title": "空白看板", "new_channel_modal.create_board.select_template_placeholder": "選擇一個範本", "new_channel_modal.create_board.title": "在這個頻道新建一個看板", "notification-box-card-limit-reached.close-tooltip": "小睡十天", "notification-box-card-limit-reached.contact-link": "通知管理員", "notification-box-card-limit-reached.link": "升級到付費版", "notification-box-card-limit-reached.title": "將看板上{cards}卡片隱藏", "notification-box-cards-hidden.title": "此行為隱藏了其他卡片", "notification-box.card-limit-reached.not-admin.text": "要存取已封存的卡片,你可以點擊{contactLink}升級到付費版。", "notification-box.card-limit-reached.text": "已達卡片上限,觀看舊卡片請點{link}", "person.add-user-to-board": "將{username} 加入看板", "person.add-user-to-board-confirm-button": "新增看板", "person.add-user-to-board-permissions": "權限", "person.add-user-to-board-question": "你想將{username} 加入到看板嗎?", "person.add-user-to-board-warning": "{username}不是看板的成員,也不會收到任何有關的通知.", "register.login-button": "或登錄(如果您已擁有帳戶)", "register.signup-title": "註冊您的帳戶", "rhs-board-non-admin-msg": "你不是看板的管理者", "rhs-boards.add": "新增", "rhs-boards.dm": "私人訊息", "rhs-boards.gm": "群組訊息", "rhs-boards.header.dm": "此私人訊息", "rhs-boards.header.gm": "此群組訊息", "rhs-boards.last-update-at": "最後更新日: {datetime}", "rhs-boards.link-boards-to-channel": "將看板連接到{channelName}", "rhs-boards.linked-boards": "連結看板", "rhs-boards.no-boards-linked-to-channel": "還沒有看板與{channelName}連接", "rhs-boards.no-boards-linked-to-channel-description": "看板是一個專案管理工具,可以使用熟悉的圖表幫助我們定義、組織、追蹤和管理跨團隊工作。", "rhs-boards.unlink-board": "未連結看板", "rhs-boards.unlink-board1": "未連結看板", "rhs-channel-boards-header.title": "板塊", "share-board.publish": "發布", "share-board.share": "分享", "shareBoard.channels-select-group": "頻道", "shareBoard.confirm-change-team-role.body": "此看板上所有低於\"{role}\"的人都將被提升到{role}。你確定要改變這個看板最低角色?", "shareBoard.confirm-change-team-role.confirmBtnText": "改變最小的看板規則", "shareBoard.confirm-change-team-role.title": "改變最小的看板規則", "shareBoard.confirm-link-channel": "連接看板到頻道", "shareBoard.confirm-link-channel-button": "連接頻道", "shareBoard.confirm-link-channel-button-with-other-channel": "解除連接或連接這", "shareBoard.confirm-link-channel-subtext": "當你連接頻道到看板,該頻道所有成員(包含新的與現有的)都可以編輯,不包括訪客。", "shareBoard.confirm-link-channel-subtext-with-other-channel": "當你將一個頻道連接到看板時,該頻道的所有成員(現有的和新的)都可以編輯。並不包含訪客身分{lineBreak}看板目前正連接到另一個頻道。如果选择在這裡連接它,將取消另一個連接。", "shareBoard.confirm-unlink.body": "當你取消頻道與看板連接,所有頻道成員(現在和新的)都將無法失去查看權限,除非單獨獲得許可。", "shareBoard.confirm-unlink.confirmBtnText": "解除連結頻道", "shareBoard.confirm-unlink.title": "從看板上取消頻道連接", "shareBoard.lastAdmin": "看板必須有一位管理者", "shareBoard.members-select-group": "會員", "shareBoard.unknown-channel-display-name": "未知管道", "tutorial_tip.finish_tour": "完成", "tutorial_tip.got_it": "了解", "tutorial_tip.ok": "下一步", "tutorial_tip.out": "不接受這個提示.", "tutorial_tip.seen": "以前有見過嗎?" } ================================================ FILE: webapp/package.json ================================================ { "name": "focalboard", "version": "8.0.0", "private": true, "description": "", "scripts": { "pack": "cross-env NODE_ENV=production webpack --config webpack.prod.js", "packdev": "cross-env NODE_ENV=dev webpack --config webpack.dev.js", "watchdev": "cross-env NODE_ENV=dev webpack --watch --progress --config webpack.dev.js", "deveditor": "cross-env NODE_ENV=dev webpack server --config webpack.editor.js", "test": "jest", "updatesnapshot": "jest --updateSnapshot", "check": "eslint --ext .tsx,.ts . --quiet --cache && stylelint **/*.scss", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache && stylelint --fix **/*.scss", "fix:scss": "prettier --write './src/**/*.scss'", "i18n-extract": "formatjs extract \"src/**/*.{ts,tsx}\" --ignore \"**/*.d.ts\" \"../**/*.d.ts\" --out-file i18n/tmp.json && formatjs compile i18n/tmp.json --out-file i18n/en.json && npx rimraf i18n/tmp.json", "runserver-test": "cd cypress && \"../../bin/focalboard-server\"", "cypress:ci": "start-server-and-test runserver-test http://localhost:8088 cypress:run", "cypress:debug": "start-server-and-test runserver-test http://localhost:8088 cypress:open", "cypress:run": "cypress run", "cypress:run:chrome": "cypress run --browser chrome", "cypress:run:firefox": "cypress run --browser firefox", "cypress:run:edge": "cypress run --browser edge", "cypress:run:electron": "cypress run --browser electron", "cypress:open": "cypress open" }, "dependencies": { "@draft-js-plugins/editor": "^4.1.2", "@draft-js-plugins/emoji": "^4.6.0", "@draft-js-plugins/mention": "^5.1.2", "@fullcalendar/core": "^5.10.1", "@fullcalendar/daygrid": "^5.10.1", "@fullcalendar/interaction": "^5.10.1", "@fullcalendar/react": "^5.10.1", "@mattermost/compass-icons": "^0.1.39", "@reduxjs/toolkit": "^1.8.0", "@tippyjs/react": "4.2.6", "classnames": "^2.5.1", "color": "^4.2.1", "draft-js": "^0.11.7", "emoji-mart": "^3.0.1", "fstream": "^1.0.12", "fullcalendar": "^5.10.2", "imagemin-gifsicle": "^7.0.0", "imagemin-mozjpeg": "^10.0.0", "imagemin-optipng": "^8.0.0", "imagemin-pngquant": "^9.0.2", "imagemin-svgo": "^10.0.1", "imagemin-webp": "^7.0.0", "lodash": "^4.17.21", "marked": "^4.0.12", "mini-create-react-context": "^0.4.1", "moment": "^2.29.1", "nanoevents": "^5.1.13", "react": "17.0.2", "react-beautiful-dnd": "^13.1.1", "react-day-picker": "^7.4.10", "react-dnd": "^14.0.2", "react-dnd-html5-backend": "^14.0.0", "react-dnd-scrolling": "^1.2.1", "react-dnd-touch-backend": "^14.0.0", "react-dom": "17.0.2", "react-hot-keys": "^2.7.1", "react-hotkeys-hook": "^3.4.4", "react-intl": "^5.20.0", "react-redux": "7.2.4", "react-router-dom": "^5.2.0", "react-select": "^5.2.2", "trim-newlines": "^4.0.2" }, "jest": { "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", "\\.(scss|css)$": "/__mocks__/styleMock.js" }, "globals": { "ts-jest": { "tsconfig": "./src/tsconfig.json" } }, "transform": { "^.+\\.tsx?$": "@swc/jest" }, "transformIgnorePatterns": [ "/nanoevents/" ], "maxWorkers": "50%", "testEnvironment": "jsdom", "collectCoverage": true, "collectCoverageFrom": [ "src/**/*.{ts,tsx,js,jsx}", "!src/test/**" ] }, "devDependencies": { "@formatjs/cli": "^4.8.2", "@formatjs/ts-transformer": "^3.9.2", "@swc/jest": "^0.2.20", "@testing-library/cypress": "^8.0.2", "@testing-library/dom": "^8.11.4", "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.5.0", "@types/color": "^3.0.3", "@types/draft-js": "^0.11.9", "@types/emoji-mart": "^3.0.9", "@types/jest": "^27.4.1", "@types/marked": "^4.0.3", "@types/nanoevents": "^1.0.0", "@types/react": "^17.0.43", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.14", "@types/react-intl": "^3.0.0", "@types/react-redux": "^7.1.23", "@types/react-router-dom": "^5.3.3", "@types/react-select": "^5.0.0", "@types/redux-mock-store": "^1.0.3", "@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/parser": "^5.16.0", "copy-webpack-plugin": "^10.2.4", "cross-env": "^7.0.3", "css-loader": "^6.7.1", "cypress": "^9.5.2", "cypress-failed-log": "^2.9.5", "cypress-real-events": "^1.7.0", "eslint": "^8.11.0", "eslint-import-resolver-webpack": "0.13.2", "eslint-plugin-babel": "^5.3.1", "eslint-plugin-cypress": "2.12.1", "eslint-plugin-header": "3.1.1", "eslint-plugin-import": "2.25.4", "eslint-plugin-jquery": "1.5.1", "eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#23abcf9988f7fa00d26929f11841aab7ccb16b2b", "eslint-plugin-no-only-tests": "2.6.0", "eslint-plugin-react": "7.29.4", "fetch-mock-jest": "^1.5.1", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.5.0", "image-webpack-loader": "^8.1.0", "isomorphic-fetch": "^3.0.0", "jest": "27.5.1", "jest-mock": "27.5.1", "prettier": "^2.6.1", "redux-mock-store": "^1.5.4", "sass": "^1.49.9", "sass-loader": "^12.6.0", "start-server-and-test": "^1.14.0", "style-loader": "^3.3.1", "stylelint": "^14.6.1", "stylelint-config-sass-guidelines": "^9.0.1", "terser-webpack-plugin": "^5.3.1", "ts-jest": "^27.1.4", "ts-loader": "^9.2.8", "typescript": "^4.6.3", "webpack": "^5.70.0", "webpack-cli": "^4.9.2", "webpack-dev-server": "^4.11.1", "webpack-merge": "^5.8.0" }, "optionalDependencies": { "cypress": "^9.5.2" } } ================================================ FILE: webapp/src/app.tsx ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {useEffect} from 'react' import {IntlProvider} from 'react-intl' import {DndProvider} from 'react-dnd' import {HTML5Backend} from 'react-dnd-html5-backend' import {TouchBackend} from 'react-dnd-touch-backend' import {History} from 'history' import TelemetryClient from './telemetry/telemetryClient' import {getMessages} from './i18n' import {FlashMessages} from './components/flashMessages' import NewVersionBanner from './components/newVersionBanner' import {Utils} from './utils' import {fetchMe, getMe} from './store/users' import {getLanguage, fetchLanguage} from './store/language' import {useAppSelector, useAppDispatch} from './store/hooks' import {fetchClientConfig} from './store/clientConfig' import FocalboardRouter from './router' import {IUser} from './user' type Props = { history?: History } const App = (props: Props): JSX.Element => { const language = useAppSelector(getLanguage) const me = useAppSelector(getMe) const dispatch = useAppDispatch() useEffect(() => { dispatch(fetchLanguage()) dispatch(fetchMe()) dispatch(fetchClientConfig()) }, []) useEffect(() => { if (me) { TelemetryClient.setUser(me) } }, [me]) return (
) } export default React.memo(App) ================================================ FILE: webapp/src/archiver.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {IAppWindow} from './types' import {Block} from './blocks/block' import {Board} from './blocks/board' import mutator from './mutator' import {Utils} from './utils' declare let window: IAppWindow class Archiver { static async exportBoardArchive(board: Board): Promise { this.exportArchive(mutator.exportBoardArchive(board.id)) } static async exportFullArchive(teamID: string): Promise { this.exportArchive(mutator.exportFullArchive(teamID)) } private static exportArchive(prom: Promise): void { // TODO: don't download whole archive before presenting SaveAs dialog. prom.then((response) => { response.blob(). then((blob) => { const link = document.createElement('a') link.style.display = 'none' const date = new Date() const filename = `archive-${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.boardarchive` const file = new Blob([blob], {type: 'application/octet-stream'}) link.href = URL.createObjectURL(file) link.download = filename document.body.appendChild(link) // FireFox support link.click() // TODO: Review if this is needed in the future, this is to fix the problem with linux webview links if (window.openInNewBrowser) { window.openInNewBrowser(link.href) } // TODO: Remove or reuse link and revolkObjectURL to avoid memory leak }) }) } private static async importArchiveFromFile(file: File): Promise { const response = await mutator.importFullArchive(file) if (response.status !== 200) { Utils.log('ERROR importing archive: ' + response.text()) } } static isValidBlock(block: Block): boolean { if (!block.id || !block.boardId) { return false } return true } static importFullArchive(onComplete?: () => void): void { const input = document.createElement('input') input.type = 'file' input.accept = '.boardarchive' input.onchange = async () => { const file = input.files && input.files[0] if (file) { await Archiver.importArchiveFromFile(file) } onComplete?.() } input.style.display = 'none' document.body.appendChild(input) input.click() // TODO: Remove or reuse input } } export {Archiver} ================================================ FILE: webapp/src/blockIcons.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {randomEmojiList} from './emojiList' class BlockIcons { static readonly shared = new BlockIcons() randomIcon(): string { const index = Math.floor(Math.random() * randomEmojiList.length) const icon = randomEmojiList[index] return icon } } export {BlockIcons} ================================================ FILE: webapp/src/blocks/__snapshots__/block.test.ts.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`block tests correctly generate patches from two blocks should add fields on the new fields added and remove it in the undo 1`] = ` Array [ Object { "deletedFields": Array [], "updatedFields": Object { "newField": "new field", }, }, Object { "deletedFields": Array [ "newField", ], "updatedFields": Object {}, }, ] `; exports[`block tests correctly generate patches from two blocks should generate two empty patches for the same block 1`] = ` Array [ Object { "deletedFields": Array [], "updatedFields": Object {}, }, Object { "deletedFields": Array [], "updatedFields": Object {}, }, ] `; exports[`block tests correctly generate patches from two blocks should remove field on the new block added and add it again in the undo 1`] = ` Array [ Object { "deletedFields": Array [ "test", ], "updatedFields": Object {}, }, Object { "deletedFields": Array [], "updatedFields": Object { "test": "test", }, }, ] `; exports[`block tests correctly generate patches from two blocks should update propertie on the main object and revert it back on the undo 1`] = ` Array [ Object { "deletedFields": Array [], "parentId": "new-parent-id", "updatedFields": Object {}, }, Object { "deletedFields": Array [], "parentId": "old-parent-id", "updatedFields": Object {}, }, ] `; ================================================ FILE: webapp/src/blocks/__snapshots__/board.test.ts.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`board tests correctly generate patches for boards and blocks should add fields on update and remove it in the undo 1`] = ` Array [ Object { "blockIDs": Array [ "test-old-block-id", ], "blockPatches": Array [ Object { "deletedFields": Array [], "updatedFields": Object { "newField": "new field", }, }, ], "boardIDs": Array [ "test-board-id", ], "boardPatches": Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, ], }, Object { "blockIDs": Array [ "test-old-block-id", ], "blockPatches": Array [ Object { "deletedFields": Array [ "newField", ], "updatedFields": Object {}, }, ], "boardIDs": Array [ "test-board-id", ], "boardPatches": Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, ], }, ] `; exports[`board tests correctly generate patches for boards and blocks should generate two empty patches for the same board and block 1`] = ` Array [ Object { "blockIDs": Array [ "test-card-id", ], "blockPatches": Array [ Object { "deletedFields": Array [], "updatedFields": Object {}, }, ], "boardIDs": Array [ "test-board-id", ], "boardPatches": Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, ], }, Object { "blockIDs": Array [ "test-card-id", ], "blockPatches": Array [ Object { "deletedFields": Array [], "updatedFields": Object {}, }, ], "boardIDs": Array [ "test-board-id", ], "boardPatches": Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, ], }, ] `; exports[`board tests correctly generate patches from two boards should add card properties on the redo and remove them on the undo 1`] = ` Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [ Object { "id": "new-property-id", "name": "property-name", "options": Array [ Object { "color": "propColorYellow", "id": "opt", "value": "val", }, ], "type": "select", }, ], "updatedProperties": Object {}, }, Object { "deletedCardProperties": Array [ "new-property-id", ], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, ] `; exports[`board tests correctly generate patches from two boards should add card properties on the redo and undo if they exists in both, but differ 1`] = ` Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [ Object { "id": "new-property-id", "name": "property-name", "options": Array [ Object { "color": "propColorYellow", "id": "opt", "value": "val", }, ], "type": "select", }, ], "updatedProperties": Object {}, }, Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [ Object { "id": "new-property-id", "name": "a-different-name", "options": Array [ Object { "color": "propColorYellow", "id": "opt", "value": "val", }, ], "type": "select", }, ], "updatedProperties": Object {}, }, ] `; exports[`board tests correctly generate patches from two boards should add card properties on the redo and undo if they exists in both, but their options are different 1`] = ` Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [ Object { "id": "new-property-id", "name": "property-name", "options": Array [ Object { "color": "propColorYellow", "id": "opt", "value": "val", }, ], "type": "select", }, ], "updatedProperties": Object {}, }, Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [ Object { "id": "new-property-id", "name": "property-name", "options": Array [ Object { "color": "propColorBrown", "id": "another-opt", "value": "val", }, ], "type": "select", }, ], "updatedProperties": Object {}, }, ] `; exports[`board tests correctly generate patches from two boards should add properties on the update patch and remove them on the undo 1`] = ` Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object { "prop1": "val1", }, }, Object { "deletedCardProperties": Array [], "deletedProperties": Array [ "prop1", ], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, ] `; exports[`board tests correctly generate patches from two boards should generate two empty patches for the same board 1`] = ` Array [ Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, Object { "deletedCardProperties": Array [], "deletedProperties": Array [], "updatedCardProperties": Array [], "updatedProperties": Object {}, }, ] `; ================================================ FILE: webapp/src/blocks/attachmentBlock.tsx ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block, createBlock} from './block' type AttachmentBlockFields = { fileId: string } type AttachmentBlock = Block & { type: 'attachment' fields: AttachmentBlockFields isUploading: boolean uploadingPercent: number } function createAttachmentBlock(block?: Block): AttachmentBlock { return { ...createBlock(block), type: 'attachment', fields: { fileId: block?.fields.attachmentId || block?.fields.fileId || '', }, isUploading: false, uploadingPercent: 0, } } export {AttachmentBlock, createAttachmentBlock} ================================================ FILE: webapp/src/blocks/block.test.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {TestBlockFactory} from '../test/testBlockFactory' import {createPatchesFromBlocks, createBlock} from './block' describe('block tests', () => { const board = TestBlockFactory.createBoard() const card = TestBlockFactory.createCard(board) describe('correctly generate patches from two blocks', () => { it('should generate two empty patches for the same block', () => { const textBlock = TestBlockFactory.createText(card) const result = createPatchesFromBlocks(textBlock, textBlock) expect(result).toMatchSnapshot() }) it('should add fields on the new fields added and remove it in the undo', () => { const oldBlock = TestBlockFactory.createText(card) const newBlock = createBlock(oldBlock) newBlock.fields.newField = 'new field' const result = createPatchesFromBlocks(newBlock, oldBlock) expect(result).toMatchSnapshot() }) it('should remove field on the new block added and add it again in the undo', () => { const oldBlock = TestBlockFactory.createText(card) const newBlock = createBlock(oldBlock) oldBlock.fields.test = 'test' const result = createPatchesFromBlocks(newBlock, oldBlock) expect(result).toMatchSnapshot() }) it('should update propertie on the main object and revert it back on the undo', () => { const oldBlock = TestBlockFactory.createText(card) const newBlock = createBlock(oldBlock) oldBlock.parentId = 'old-parent-id' newBlock.parentId = 'new-parent-id' const result = createPatchesFromBlocks(newBlock, oldBlock) expect(result).toMatchSnapshot() }) }) }) ================================================ FILE: webapp/src/blocks/block.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import difference from 'lodash/difference' import {Utils} from '../utils' const contentBlockTypes = ['text', 'image', 'divider', 'checkbox', 'h1', 'h2', 'h3', 'list-item', 'attachment', 'quote', 'video'] as const // ToDo: remove type board const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment', 'attachment', 'unknown'] as const type ContentBlockTypes = typeof contentBlockTypes[number] type BlockTypes = typeof blockTypes[number] interface BlockPatch { parentId?: string schema?: number type?: BlockTypes title?: string // eslint-disable-next-line @typescript-eslint/no-explicit-any updatedFields?: Record deletedFields?: string[] deleteAt?: number } interface Block { id: string boardId: string parentId: string createdBy: string modifiedBy: string schema: number type: BlockTypes title: string // eslint-disable-next-line @typescript-eslint/no-explicit-any fields: Record createAt: number updateAt: number deleteAt: number limited?: boolean } interface FileInfo { url?: string archived?: boolean extension?: string name?: string size?: number } function createBlock(block?: Block): Block { const now = Date.now() return { id: block?.id || Utils.createGuid(Utils.blockTypeToIDType(block?.type)), schema: 1, boardId: block?.boardId || '', parentId: block?.parentId || '', createdBy: block?.createdBy || '', modifiedBy: block?.modifiedBy || '', type: block?.type || 'unknown', fields: block?.fields ? {...block?.fields} : {}, title: block?.title || '', createAt: block?.createAt || now, updateAt: block?.updateAt || now, deleteAt: block?.deleteAt || 0, limited: Boolean(block?.limited), } } // createPatchesFromBlocks creates two BlockPatch instances, one that // contains the delta to update the block and another one for the undo // action, in case it happens function createPatchesFromBlocks(newBlock: Block, oldBlock: Block): BlockPatch[] { const newDeletedFields = difference(Object.keys(newBlock.fields), Object.keys(oldBlock.fields)) const newUpdatedFields: Record = {} const newUpdatedData: Record = {} Object.keys(newBlock.fields).forEach((val) => { if (oldBlock.fields[val] !== newBlock.fields[val]) { newUpdatedFields[val] = newBlock.fields[val] } }) Object.keys(newBlock).forEach((val) => { if (val !== 'fields' && (oldBlock as any)[val] !== (newBlock as any)[val]) { newUpdatedData[val] = (newBlock as any)[val] } }) const oldDeletedFields = difference(Object.keys(oldBlock.fields), Object.keys(newBlock.fields)) const oldUpdatedFields: Record = {} const oldUpdatedData: Record = {} Object.keys(oldBlock.fields).forEach((val) => { if (oldBlock.fields[val] !== newBlock.fields[val]) { oldUpdatedFields[val] = oldBlock.fields[val] } }) Object.keys(oldBlock).forEach((val) => { if (val !== 'fields' && (oldBlock as any)[val] !== (newBlock as any)[val]) { oldUpdatedData[val] = (oldBlock as any)[val] } }) return [ { ...newUpdatedData, updatedFields: newUpdatedFields, deletedFields: oldDeletedFields, }, { ...oldUpdatedData, updatedFields: oldUpdatedFields, deletedFields: newDeletedFields, }, ] } export type {ContentBlockTypes, BlockTypes, FileInfo} export {blockTypes, contentBlockTypes, Block, BlockPatch, createBlock, createPatchesFromBlocks} ================================================ FILE: webapp/src/blocks/board.test.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {TestBlockFactory} from '../test/testBlockFactory' import {createPatchesFromBoards, createBoard, IPropertyTemplate, createPatchesFromBoardsAndBlocks} from './board' import {createBlock} from './block' describe('board tests', () => { describe('correctly generate patches from two boards', () => { it('should generate two empty patches for the same board', () => { const board = TestBlockFactory.createBoard() const result = createPatchesFromBoards(board, board) expect(result).toMatchSnapshot() }) it('should add properties on the update patch and remove them on the undo', () => { const board = TestBlockFactory.createBoard() board.properties = { prop1: 'val1', prop2: 'val2', } const oldBoard = createBoard(board) oldBoard.properties = { prop2: 'val2', } const result = createPatchesFromBoards(board, oldBoard) expect(result).toMatchSnapshot() }) it('should add card properties on the redo and remove them on the undo', () => { const board = TestBlockFactory.createBoard() const oldBoard = createBoard(board) board.cardProperties.push({ id: 'new-property-id', name: 'property-name', type: 'select', options: [{ id: 'opt', value: 'val', color: 'propColorYellow', }], }) const result = createPatchesFromBoards(board, oldBoard) expect(result).toMatchSnapshot() }) it('should add card properties on the redo and undo if they exists in both, but differ', () => { const cardProperty = { id: 'new-property-id', name: 'property-name', type: 'select', options: [{ id: 'opt', value: 'val', color: 'propColorYellow', }], } as IPropertyTemplate const board = TestBlockFactory.createBoard() const oldBoard = createBoard(board) board.cardProperties = [cardProperty] oldBoard.cardProperties = [{...cardProperty, name: 'a-different-name'}] const result = createPatchesFromBoards(board, oldBoard) expect(result).toMatchSnapshot() }) it('should add card properties on the redo and undo if they exists in both, but their options are different', () => { const cardProperty = { id: 'new-property-id', name: 'property-name', type: 'select', options: [{ id: 'opt', value: 'val', color: 'propColorYellow', }], } as IPropertyTemplate const board = TestBlockFactory.createBoard() const oldBoard = createBoard(board) board.cardProperties = [cardProperty] oldBoard.cardProperties = [{ ...cardProperty, options: [{ id: 'another-opt', value: 'val', color: 'propColorBrown', }], }] const result = createPatchesFromBoards(board, oldBoard) expect(result).toMatchSnapshot() }) }) describe('correctly generate patches for boards and blocks', () => { const board = TestBlockFactory.createBoard() board.id = 'test-board-id' const card = TestBlockFactory.createCard() card.id = 'test-card-id' it('should generate two empty patches for the same board and block', () => { const result = createPatchesFromBoardsAndBlocks(board, board, [card.id], [card], [card]) expect(result).toMatchSnapshot() }) it('should add fields on update and remove it in the undo', () => { const oldBlock = TestBlockFactory.createText(card) oldBlock.id = 'test-old-block-id' const newBlock = createBlock(oldBlock) newBlock.fields.newField = 'new field' const result = createPatchesFromBoardsAndBlocks(board, board, [newBlock.id], [newBlock], [oldBlock]) expect(result).toMatchSnapshot() }) }) }) ================================================ FILE: webapp/src/blocks/board.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import difference from 'lodash/difference' import {Utils, IDType} from '../utils' import {Block, BlockPatch, createPatchesFromBlocks} from './block' import {Card} from './card' const BoardTypeOpen = 'O' const BoardTypePrivate = 'P' const boardTypes = [BoardTypeOpen, BoardTypePrivate] type BoardTypes = typeof boardTypes[number] enum MemberRole { Viewer = 'viewer', Commenter = 'commenter', Editor = 'editor', Admin = 'admin', None = '', } type Board = { id: string teamId: string channelId?: string createdBy: string modifiedBy: string type: BoardTypes minimumRole: MemberRole title: string description: string icon?: string showDescription: boolean isTemplate: boolean templateVersion: number properties: Record cardProperties: IPropertyTemplate[] createAt: number updateAt: number deleteAt: number } type BoardPatch = { type?: BoardTypes minimumRole?: MemberRole title?: string description?: string icon?: string showDescription?: boolean // eslint-disable-next-line @typescript-eslint/no-explicit-any updatedProperties?: Record deletedProperties?: string[] // eslint-disable-next-line @typescript-eslint/no-explicit-any updatedCardProperties?: IPropertyTemplate[] deletedCardProperties?: string[] } type BoardMember = { boardId: string userId: string roles?: string minimumRole: MemberRole schemeAdmin: boolean schemeEditor: boolean schemeCommenter: boolean schemeViewer: boolean synthetic: boolean } type BoardsAndBlocks = { boards: Board[] blocks: Block[] } type BoardsAndBlocksPatch = { boardIDs: string[] boardPatches: BoardPatch[] blockIDs: string[] blockPatches: BlockPatch[] } type PropertyTypeEnum = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'multiPerson' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy' | 'unknown' interface IPropertyOption { id: string value: string color: string } // A template for card properties attached to a board interface IPropertyTemplate { id: string name: string type: PropertyTypeEnum options: IPropertyOption[] } function createBoard(board?: Board): Board { const now = Date.now() let cardProperties: IPropertyTemplate[] = [] const selectProperties = cardProperties.find((o) => o.type === 'select') if (!selectProperties) { const property: IPropertyTemplate = { id: Utils.createGuid(IDType.BlockID), name: 'Status', type: 'select', options: [], } cardProperties.push(property) } if (board?.cardProperties) { // Deep clone of card properties and their options cardProperties = board?.cardProperties.map((o: IPropertyTemplate) => { return { id: o.id, name: o.name, type: o.type, options: o.options ? o.options.map((option) => ({...option})) : [], } }) } return { id: board?.id || Utils.createGuid(IDType.Board), teamId: board?.teamId || '', channelId: board?.channelId || '', createdBy: board?.createdBy || '', modifiedBy: board?.modifiedBy || '', type: board?.type || BoardTypePrivate, minimumRole: board?.minimumRole || MemberRole.None, title: board?.title || '', description: board?.description || '', icon: board?.icon || '', showDescription: board?.showDescription || false, isTemplate: board?.isTemplate || false, templateVersion: board?.templateVersion || 0, properties: board?.properties || {}, cardProperties, createAt: board?.createAt || now, updateAt: board?.updateAt || now, deleteAt: board?.deleteAt || 0, } } type BoardGroup = { option: IPropertyOption cards: Card[] } // getPropertiesDifference returns a list of the property IDs that are // contained in propsA but are not contained in propsB function getPropertiesDifference(propsA: IPropertyTemplate[], propsB: IPropertyTemplate[]): string[] { const diff: string[] = [] propsA.forEach((val) => { if (!propsB.find((p) => p.id === val.id)) { diff.push(val.id) } }) return diff } // isPropertyEqual checks that both the contents of the property and // its options are equal function isPropertyEqual(propA: IPropertyTemplate, propB: IPropertyTemplate): boolean { for (const val of Object.keys(propA)) { if (val !== 'options' && (propA as any)[val] !== (propB as any)[val]) { return false } } if (propA.options.length !== propB.options.length) { return false } for (const opt of propA.options) { const optionB = propB.options.find((o) => o.id === opt.id) if (!optionB) { return false } for (const val of Object.keys(opt)) { if ((opt as any)[val] !== (optionB as any)[val]) { return false } } } return true } // createCardPropertiesPatches creates two BoardPatch instances, one that // contains the delta to update the board cardProperties and another one for // the undo action, in case it happens function createCardPropertiesPatches(newCardProperties: IPropertyTemplate[], oldCardProperties: IPropertyTemplate[]): BoardPatch[] { const newDeletedCardProperties = getPropertiesDifference(newCardProperties, oldCardProperties) const oldDeletedCardProperties = getPropertiesDifference(oldCardProperties, newCardProperties) const newUpdatedCardProperties: IPropertyTemplate[] = [] newCardProperties.forEach((val) => { const oldCardProperty = oldCardProperties.find((o) => o.id === val.id) if (!oldCardProperty || !isPropertyEqual(val, oldCardProperty)) { newUpdatedCardProperties.push(val) } }) const oldUpdatedCardProperties: IPropertyTemplate[] = [] oldCardProperties.forEach((val) => { const newCardProperty = newCardProperties.find((o) => o.id === val.id) if (!newCardProperty || !isPropertyEqual(val, newCardProperty)) { oldUpdatedCardProperties.push(val) } }) return [ { updatedCardProperties: newUpdatedCardProperties, deletedCardProperties: oldDeletedCardProperties, }, { updatedCardProperties: oldUpdatedCardProperties, deletedCardProperties: newDeletedCardProperties, }, ] } // createPatchesFromBoards creates two BoardPatch instances, one that // contains the delta to update the board and another one for the undo // action, in case it happens function createPatchesFromBoards(newBoard: Board, oldBoard: Board): BoardPatch[] { const newDeletedProperties = difference(Object.keys(newBoard.properties || {}), Object.keys(oldBoard.properties || {})) const newUpdatedProperties: Record = {} Object.keys(newBoard.properties || {}).forEach((val) => { if (oldBoard.properties[val] !== newBoard.properties[val]) { newUpdatedProperties[val] = newBoard.properties[val] } }) const newData: Record = {} Object.keys(newBoard).forEach((val) => { if (val !== 'properties' && val !== 'cardProperties' && (oldBoard as any)[val] !== (newBoard as any)[val]) { newData[val] = (newBoard as any)[val] } }) const oldDeletedProperties = difference(Object.keys(oldBoard.properties || {}), Object.keys(newBoard.properties || {})) const oldUpdatedProperties: Record = {} Object.keys(oldBoard.properties || {}).forEach((val) => { if (newBoard.properties[val] !== oldBoard.properties[val]) { oldUpdatedProperties[val] = oldBoard.properties[val] } }) const oldData: Record = {} Object.keys(oldBoard).forEach((val) => { if (val !== 'properties' && val !== 'cardProperties' && (newBoard as any)[val] !== (oldBoard as any)[val]) { oldData[val] = (oldBoard as any)[val] } }) const [cardPropertiesPatch, cardPropertiesUndoPatch] = createCardPropertiesPatches(newBoard.cardProperties, oldBoard.cardProperties) return [ { ...newData, ...cardPropertiesPatch, updatedProperties: newUpdatedProperties, deletedProperties: oldDeletedProperties, }, { ...oldData, ...cardPropertiesUndoPatch, updatedProperties: oldUpdatedProperties, deletedProperties: newDeletedProperties, }, ] } function createPatchesFromBoardsAndBlocks(updatedBoard: Board, oldBoard: Board, updatedBlockIDs: string[], updatedBlocks: Block[], oldBlocks: Block[]): BoardsAndBlocksPatch[] { const blockUpdatePatches = [] as BlockPatch[] const blockUndoPatches = [] as BlockPatch[] updatedBlocks.forEach((newBlock, i) => { const [updatePatch, undoPatch] = createPatchesFromBlocks(newBlock, oldBlocks[i]) blockUpdatePatches.push(updatePatch) blockUndoPatches.push(undoPatch) }) const [boardUpdatePatch, boardUndoPatch] = createPatchesFromBoards(updatedBoard, oldBoard) const updatePatch: BoardsAndBlocksPatch = { blockIDs: updatedBlockIDs, blockPatches: blockUpdatePatches, boardIDs: [updatedBoard.id], boardPatches: [boardUpdatePatch], } const undoPatch: BoardsAndBlocksPatch = { blockIDs: updatedBlockIDs, blockPatches: blockUndoPatches, boardIDs: [updatedBoard.id], boardPatches: [boardUndoPatch], } return [updatePatch, undoPatch] } export { Board, BoardPatch, BoardMember, BoardsAndBlocks, BoardsAndBlocksPatch, PropertyTypeEnum, IPropertyOption, IPropertyTemplate, BoardGroup, createBoard, BoardTypes, BoardTypeOpen, BoardTypePrivate, MemberRole, createPatchesFromBoards, createPatchesFromBoardsAndBlocks, createCardPropertiesPatches, } ================================================ FILE: webapp/src/blocks/boardView.test.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {TestBlockFactory} from '../test/testBlockFactory' import {sortBoardViewsAlphabetically} from './boardView' test('boardView: sort with ASCII', async () => { const view1 = TestBlockFactory.createBoardView() view1.title = 'Maybe' const view2 = TestBlockFactory.createBoardView() view2.title = 'Active' const views = [view1, view2] const sorted = sortBoardViewsAlphabetically(views) expect(sorted).toEqual([view2, view1]) }) test('boardView: sort with leading emoji', async () => { const view1 = TestBlockFactory.createBoardView() view1.title = '🤔 Maybe' const view2 = TestBlockFactory.createBoardView() view2.title = '🚀 Active' const views = [view1, view2] const sorted = sortBoardViewsAlphabetically(views) expect(sorted).toEqual([view2, view1]) }) test('boardView: sort with non-latin characters', async () => { const view1 = TestBlockFactory.createBoardView() view1.title = 'zebra' const view2 = TestBlockFactory.createBoardView() view2.title = 'ñu' const views = [view1, view2] const sorted = sortBoardViewsAlphabetically(views) expect(sorted).toEqual([view2, view1]) }) ================================================ FILE: webapp/src/blocks/boardView.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block, createBlock} from './block' import {FilterGroup, createFilterGroup} from './filterGroup' type IViewType = 'board' | 'table' | 'gallery' | 'calendar' type ISortOption = { propertyId: '__title' | string, reversed: boolean } type KanbanCalculationFields = { calculation: string propertyId: string } type BoardViewFields = { viewType: IViewType groupById?: string dateDisplayPropertyId?: string sortOptions: ISortOption[] visiblePropertyIds: string[] visibleOptionIds: string[] hiddenOptionIds: string[] collapsedOptionIds: string[] filter: FilterGroup cardOrder: string[] columnWidths: Record columnCalculations: Record kanbanCalculations: Record defaultTemplateId: string } type BoardView = Block & { fields: BoardViewFields } function createBoardView(block?: Block): BoardView { return { ...createBlock(block), type: 'view', fields: { viewType: block?.fields.viewType || 'board', groupById: block?.fields.groupById, dateDisplayPropertyId: block?.fields.dateDisplayPropertyId, sortOptions: block?.fields.sortOptions?.map((o: ISortOption) => ({...o})) || [], visiblePropertyIds: block?.fields.visiblePropertyIds?.slice() || [], visibleOptionIds: block?.fields.visibleOptionIds?.slice() || [], hiddenOptionIds: block?.fields.hiddenOptionIds?.slice() || [], collapsedOptionIds: block?.fields.collapsedOptionIds?.slice() || [], filter: createFilterGroup(block?.fields.filter), cardOrder: block?.fields.cardOrder?.slice() || [], columnWidths: {...(block?.fields.columnWidths || {})}, columnCalculations: {...(block?.fields.columnCalculations) || {}}, kanbanCalculations: {...(block?.fields.kanbanCalculations) || {}}, defaultTemplateId: block?.fields.defaultTemplateId || '', }, } } function sortBoardViewsAlphabetically(views: BoardView[]): BoardView[] { // Strip leading emoji to prevent unintuitive results return views.map((v) => { return {view: v, title: v.title.replace(/^\p{Emoji}*\s*/u, '')} }).sort((v1, v2) => v1.title.localeCompare(v2.title)).map((v) => v.view) } export {BoardView, IViewType, ISortOption, sortBoardViewsAlphabetically, createBoardView, KanbanCalculationFields} ================================================ FILE: webapp/src/blocks/card.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block, createBlock} from './block' type CardFields = { icon?: string isTemplate?: boolean properties: Record contentOrder: Array } type Card = Block & { fields: CardFields } function createCard(block?: Block): Card { const contentOrder: Array = [] const contentIds = block?.fields?.contentOrder?.filter((id: any) => id !== null) if (contentIds?.length > 0) { for (const contentId of contentIds) { if (typeof contentId === 'string') { contentOrder.push(contentId) } else { contentOrder.push(contentId.slice()) } } } return { ...createBlock(block), type: 'card', fields: { icon: block?.fields.icon || '', properties: {...(block?.fields.properties || {})}, contentOrder, isTemplate: block?.fields.isTemplate || false, }, } } export {Card, createCard} ================================================ FILE: webapp/src/blocks/checkboxBlock.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {ContentBlock} from './contentBlock' import {Block, createBlock} from './block' type CheckboxBlock = ContentBlock & { type: 'checkbox' } function createCheckboxBlock(block?: Block): CheckboxBlock { return { ...createBlock(block), type: 'checkbox', } } export {CheckboxBlock, createCheckboxBlock} ================================================ FILE: webapp/src/blocks/commentBlock.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block, createBlock} from './block' type CommentBlock = Block & { type: 'comment' } function createCommentBlock(block?: Block): CommentBlock { return { ...createBlock(block), type: 'comment', } } export {CommentBlock, createCommentBlock} ================================================ FILE: webapp/src/blocks/contentBlock.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block, createBlock} from './block' type IContentBlockWithCords = { block: Block cords: {x: number, y?: number, z?: number} } type ContentBlock = Block const createContentBlock = createBlock export {ContentBlock, IContentBlockWithCords, createContentBlock} ================================================ FILE: webapp/src/blocks/dividerBlock.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block, createBlock} from './block' import {ContentBlock} from './contentBlock' type DividerBlock = ContentBlock & { type: 'divider' } function createDividerBlock(block?: Block): DividerBlock { return { ...createBlock(block), type: 'divider', } } export {DividerBlock, createDividerBlock} ================================================ FILE: webapp/src/blocks/filterClause.test.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {areEqual, createFilterClause} from './filterClause' describe('filterClause tests', () => { it('create filter clause', () => { const clause = createFilterClause({ propertyId: 'myPropertyId', condition: 'contains', values: [], }) expect(clause).toEqual({ propertyId: 'myPropertyId', condition: 'contains', values: [], }) }) it('test filter clauses are equal', () => { const clause = createFilterClause({ propertyId: 'myPropertyId', condition: 'contains', values: ['abc', 'def'], }) const newClause = createFilterClause(clause) const testEqual = areEqual(clause, newClause) expect(testEqual).toBeTruthy() }) it('test filter clauses are Not equal property ID', () => { const clause = createFilterClause({ propertyId: 'myPropertyId', condition: 'contains', values: ['abc', 'def'], }) const newClause = createFilterClause(clause) newClause.propertyId = 'DifferentID' const testEqual = areEqual(clause, newClause) expect(testEqual).toBeFalsy() }) it('test filter clauses are Not equal condition', () => { const clause = createFilterClause({ propertyId: 'myPropertyId', condition: 'contains', values: ['abc', 'def'], }) const newClause = createFilterClause(clause) newClause.condition = 'notContains' const testEqual = areEqual(clause, newClause) expect(testEqual).toBeFalsy() }) it('test filter clauses are Not equal values', () => { const clause = createFilterClause({ propertyId: 'myPropertyId', condition: 'contains', values: ['abc', 'def'], }) const newClause = createFilterClause(clause) newClause.values = ['abc, def'] const testEqual = areEqual(clause, newClause) expect(testEqual).toBeFalsy() }) }) ================================================ FILE: webapp/src/blocks/filterClause.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Utils} from '../utils' type FilterCondition = 'includes' | 'notIncludes' | 'isEmpty' | 'isNotEmpty' | 'isSet' | 'isNotSet' | 'is' | 'contains' | 'notContains' | 'startsWith' | 'notStartsWith' | 'endsWith' | 'notEndsWith' | 'isBefore' | 'isAfter' type FilterClause = { propertyId: string condition: FilterCondition values: string[] } function createFilterClause(o?: FilterClause): FilterClause { return { propertyId: o?.propertyId || '', condition: o?.condition || 'includes', values: o?.values?.slice() || [], } } function areEqual(a: FilterClause, b: FilterClause): boolean { return ( a.propertyId === b.propertyId && a.condition === b.condition && Utils.arraysEqual(a.values, b.values) ) } export {FilterClause, FilterCondition, createFilterClause, areEqual} ================================================ FILE: webapp/src/blocks/filterGroup.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {FilterClause, createFilterClause} from './filterClause' type FilterGroupOperation = 'and' | 'or' // A FilterGroup has 2 forms: (A or B or C) OR (A and B and C) type FilterGroup = { operation: FilterGroupOperation filters: Array } function isAFilterGroupInstance(object: (FilterClause | FilterGroup)): object is FilterGroup { return 'operation' in object && 'filters' in object } function createFilterGroup(o?: FilterGroup): FilterGroup { let filters: Array = [] if (o?.filters) { filters = o.filters.map((p: (FilterClause | FilterGroup)) => { if (isAFilterGroupInstance(p)) { return createFilterGroup(p) } return createFilterClause(p) }) } return { operation: o?.operation || 'and', filters, } } export {FilterGroup, FilterGroupOperation, createFilterGroup, isAFilterGroupInstance} ================================================ FILE: webapp/src/blocks/h1Block.tsx ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {ContentBlock} from './contentBlock' import {Block, createBlock} from './block' type H1Block = ContentBlock & { type: 'h1' } function createH1Block(block?: Block): H1Block { return { ...createBlock(block), type: 'h1', } } export {H1Block, createH1Block} ================================================ FILE: webapp/src/blocks/h2Block.tsx ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {ContentBlock} from './contentBlock' import {Block, createBlock} from './block' type H2Block = ContentBlock & { type: 'h2' } function createH2Block(block?: Block): H2Block { return { ...createBlock(block), type: 'h2', } } export {H2Block, createH2Block} ================================================ FILE: webapp/src/blocks/h3Block.tsx ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {ContentBlock} from './contentBlock' import {Block, createBlock} from './block' type H3Block = ContentBlock & { type: 'h3' } function createH3Block(block?: Block): H3Block { return { ...createBlock(block), type: 'h3', } } export {H3Block, createH3Block} ================================================ FILE: webapp/src/blocks/imageBlock.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block, createBlock} from './block' import {ContentBlock} from './contentBlock' type ImageBlockFields = { fileId: string } type ImageBlock = ContentBlock & { type: 'image' fields: ImageBlockFields } function createImageBlock(block?: Block): ImageBlock { return { ...createBlock(block), type: 'image', fields: { fileId: block?.fields.fileId || '', }, } } export {ImageBlock, createImageBlock} ================================================ FILE: webapp/src/blocks/sharing.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. interface ISharing { id: string enabled: boolean token: string modifiedBy?: string updateAt?: number } export {ISharing} ================================================ FILE: webapp/src/blocks/team.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. interface ITeam { readonly id: string readonly title: string readonly signupToken: string // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly settings: Readonly> readonly modifiedBy?: string readonly updateAt?: number } export {ITeam} ================================================ FILE: webapp/src/blocks/textBlock.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {ContentBlock} from './contentBlock' import {Block, createBlock} from './block' type TextBlock = ContentBlock & { type: 'text' } function createTextBlock(block?: Block): TextBlock { return { ...createBlock(block), type: 'text', } } export {TextBlock, createTextBlock} ================================================ FILE: webapp/src/blocks/workspace.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. interface IWorkspace { readonly id: string readonly title: string readonly signupToken: string // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly settings: Readonly> readonly modifiedBy?: string readonly updateAt?: number } export {IWorkspace} ================================================ FILE: webapp/src/boardCloudLimits/index.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. export const LimitUnlimited = 0 export interface BoardsCloudLimits { cards: number used_cards: number card_limit_timestamp: number views: number } ================================================ FILE: webapp/src/boardUtils.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Card} from './blocks/card' import {IPropertyTemplate, IPropertyOption, BoardGroup} from './blocks/board' function groupCardsByOptions(cards: Card[], optionIds: string[], groupByProperty?: IPropertyTemplate): BoardGroup[] { const groups = [] for (const optionId of optionIds) { if (optionId) { const option = groupByProperty?.options.find((o) => o.id === optionId) if (option) { const c = cards.filter((o) => optionId === o.fields?.properties[groupByProperty!.id]) const group: BoardGroup = { option, cards: c, } groups.push(group) } else { // if optionId not found, its an old (deleted) option that can be ignored } } else { // Empty group const emptyGroupCards = cards.filter((card) => { const groupByOptionId = card.fields.properties[groupByProperty?.id || ''] return !groupByOptionId || !groupByProperty?.options.find((option) => option.id === groupByOptionId) }) const group: BoardGroup = { option: {id: '', value: `No ${groupByProperty?.name}`, color: ''}, cards: emptyGroupCards, } groups.push(group) } } return groups } function getOptionGroups(cards: Card[], visibleOptionIds: string[], hiddenOptionIds: string[], groupByProperty?: IPropertyTemplate): {visible: BoardGroup[], hidden: BoardGroup[]} { let unassignedOptionIds: string[] = [] if (groupByProperty) { unassignedOptionIds = groupByProperty.options. filter((o: IPropertyOption) => !visibleOptionIds.includes(o.id) && !hiddenOptionIds.includes(o.id)). map((o: IPropertyOption) => o.id) } const allVisibleOptionIds = [...visibleOptionIds, ...unassignedOptionIds] // If the empty group positon is not explicitly specified, make it the first visible column if (!allVisibleOptionIds.includes('') && !hiddenOptionIds.includes('')) { allVisibleOptionIds.unshift('') } const visibleGroups = groupCardsByOptions(cards, allVisibleOptionIds, groupByProperty) const hiddenGroups = groupCardsByOptions(cards, hiddenOptionIds, groupByProperty) return {visible: visibleGroups, hidden: hiddenGroups} } export function getVisibleAndHiddenGroups(cards: Card[], visibleOptionIds: string[], hiddenOptionIds: string[], groupByProperty?: IPropertyTemplate): {visible: BoardGroup[], hidden: BoardGroup[]} { if (groupByProperty?.type === 'createdBy' || groupByProperty?.type === 'updatedBy' || groupByProperty?.type === 'person') { return getPersonGroups(cards, groupByProperty, hiddenOptionIds) } return getOptionGroups(cards, visibleOptionIds, hiddenOptionIds, groupByProperty) } function getPersonGroups(cards: Card[], groupByProperty: IPropertyTemplate, hiddenOptionIds: string[]): {visible: BoardGroup[], hidden: BoardGroup[]} { const groups = cards.reduce((unique: {[key: string]: Card[]}, item: Card): {[key: string]: Card[]} => { let key = item.fields.properties[groupByProperty.id] as string if (groupByProperty?.type === 'createdBy') { key = item.createdBy } else if (groupByProperty?.type === 'updatedBy') { key = item.modifiedBy } const curGroup = unique[key] ?? [] return {...unique, [key]: [...curGroup, item]} }, {}) const hiddenGroups: BoardGroup[] = [] const visibleGroups: BoardGroup[] = [] Object.entries(groups).forEach(([key, value]) => { const propertyOption = {id: key, value: key, color: ''} as IPropertyOption if (hiddenOptionIds.find((e) => e === key)) { hiddenGroups.push({option: propertyOption, cards: value}) } else { visibleGroups.push({option: propertyOption, cards: value}) } }) return {visible: visibleGroups, hidden: hiddenGroups} } ================================================ FILE: webapp/src/boardsCloudLimits/index.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. export const LimitUnlimited = 0 export interface BoardsCloudLimits { cards: number used_cards: number card_limit_timestamp: number views: number } ================================================ FILE: webapp/src/cardFilter.test.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {mocked} from 'jest-mock' import {createFilterClause} from './blocks/filterClause' import {createFilterGroup} from './blocks/filterGroup' import {CardFilter} from './cardFilter' import {TestBlockFactory} from './test/testBlockFactory' import {Utils} from './utils' import {IPropertyTemplate} from './blocks/board' jest.mock('./utils') const mockedUtils = mocked(Utils, true) const dayMillis = 24 * 60 * 60 * 1000 describe('src/cardFilter', () => { const board = TestBlockFactory.createBoard() board.id = '1' const card1 = TestBlockFactory.createCard(board) card1.id = '1' card1.title = 'card1' card1.fields.properties.propertyId = 'Status' card1.createAt = new Date('December 7, 2023').getTime() card1.updateAt = new Date('December 7, 2023').getTime() card1.deleteAt = new Date('December 7, 2023').getTime() const filterClause = createFilterClause({propertyId: 'propertyId', condition: 'isNotEmpty', values: ['Status']}) describe('verify isClauseMet method', () => { test('should be true with isNotEmpty clause', () => { const filterClauseIsNotEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isNotEmpty', values: ['Status']}) const result = CardFilter.isClauseMet(filterClauseIsNotEmpty, [], card1) expect(result).toBeTruthy() }) test('should be false with isEmpty clause', () => { const filterClauseIsEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isEmpty', values: ['Status']}) const result = CardFilter.isClauseMet(filterClauseIsEmpty, [], card1) expect(result).toBeFalsy() }) test('should be true with includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: ['Status']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [], card1) expect(result).toBeTruthy() }) test('should be true with includes and no values clauses', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: []}) const result = CardFilter.isClauseMet(filterClauseIncludes, [], card1) expect(result).toBeTruthy() }) test('should be false with notIncludes clause', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: ['Status']}) const result = CardFilter.isClauseMet(filterClauseNotIncludes, [], card1) expect(result).toBeFalsy() }) test('should be true with notIncludes and no values clauses', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: []}) const result = CardFilter.isClauseMet(filterClauseNotIncludes, [], card1) expect(result).toBeTruthy() }) }) describe('verify isClauseMet method - person property', () => { const personCard = TestBlockFactory.createCard(board) personCard.id = '1' personCard.title = 'card1' personCard.fields.properties.personPropertyID = 'personid1' const template: IPropertyTemplate = { id: 'personPropertyID', name: 'myPerson', type: 'person', options: [], } test('should be true with isNotEmpty clause', () => { const filterClauseIsNotEmpty = createFilterClause({propertyId: 'personPropertyID', condition: 'isNotEmpty', values: []}) const result = CardFilter.isClauseMet(filterClauseIsNotEmpty, [template], personCard) expect(result).toBeTruthy() }) test('should be false with isEmpty clause', () => { const filterClauseIsEmpty = createFilterClause({propertyId: 'personPropertyID', condition: 'isEmpty', values: []}) const result = CardFilter.isClauseMet(filterClauseIsEmpty, [template], personCard) expect(result).toBeFalsy() }) test('verify empty includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: []}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid1']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause multiple values', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid2', 'personid1']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify not includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'notIncludes', values: ['personid2']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) }) describe('verify isClauseMet method - multi-person property', () => { const personCard = TestBlockFactory.createCard(board) personCard.id = '1' personCard.title = 'card1' personCard.fields.properties.personPropertyID = ['personid1', 'personid2'] const template: IPropertyTemplate = { id: 'personPropertyID', name: 'myPerson', type: 'multiPerson', options: [], } test('should be true with isNotEmpty clause', () => { const filterClauseIsNotEmpty = createFilterClause({propertyId: 'personPropertyID', condition: 'isNotEmpty', values: []}) const result = CardFilter.isClauseMet(filterClauseIsNotEmpty, [template], personCard) expect(result).toBeTruthy() }) test('should be false with isEmpty clause', () => { const filterClauseIsEmpty = createFilterClause({propertyId: 'personPropertyID', condition: 'isEmpty', values: []}) const result = CardFilter.isClauseMet(filterClauseIsEmpty, [template], personCard) expect(result).toBeFalsy() }) test('verify empty includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: []}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid1']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause 2', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid2']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause multiple values', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid3', 'personid1']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause multiple values 2', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid3', 'personid2']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify not includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'notIncludes', values: ['personid3']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify not includes clause, multiple values', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'notIncludes', values: ['personid3', 'personid4']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) }) describe('verify isClauseMet method - (createdBy) person property', () => { const personCard = TestBlockFactory.createCard(board) personCard.id = '1' personCard.title = 'card1' personCard.createdBy = 'personid1' const template: IPropertyTemplate = { id: 'personPropertyID', name: 'myPerson', type: 'createdBy', options: [], } test('verify empty includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: []}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid1']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify includes clause multiple values', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'includes', values: ['personid3', 'personid1']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) test('verify not includes clause', () => { const filterClauseIncludes = createFilterClause({propertyId: 'personPropertyID', condition: 'notIncludes', values: ['personid2']}) const result = CardFilter.isClauseMet(filterClauseIncludes, [template], personCard) expect(result).toBeTruthy() }) }) describe('verify isClauseMet method - single date property', () => { // Date Properties are stored as 12PM UTC. const now = new Date(Date.now()) const propertyDate = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 12) const dateCard = TestBlockFactory.createCard(board) dateCard.id = '1' dateCard.title = 'card1' dateCard.fields.properties.datePropertyID = '{ "from": ' + propertyDate.toString() + ' }' const checkDayBefore = propertyDate - dayMillis const checkDayAfter = propertyDate + dayMillis const template: IPropertyTemplate = { id: 'datePropertyID', name: 'myDate', type: 'date', options: [], } test('should be true with isSet clause', () => { const filterClauseIsSet = createFilterClause({propertyId: 'datePropertyID', condition: 'isSet', values: []}) const result = CardFilter.isClauseMet(filterClauseIsSet, [template], dateCard) expect(result).toBeTruthy() }) test('should be false with notSet clause', () => { const filterClauseIsNotSet = createFilterClause({propertyId: 'datePropertyID', condition: 'isNotSet', values: []}) const result = CardFilter.isClauseMet(filterClauseIsNotSet, [template], dateCard) expect(result).toBeFalsy() }) test('verify isBefore clause', () => { const filterClauseIsBefore = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: [checkDayAfter.toString()]}) const result = CardFilter.isClauseMet(filterClauseIsBefore, [template], dateCard) expect(result).toBeTruthy() const filterClauseIsNotBefore = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: [checkDayBefore.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseIsNotBefore, [template], dateCard) expect(result2).toBeFalsy() }) test('verify isAfter clauses', () => { const filterClauseisAfter = createFilterClause({propertyId: 'datePropertyID', condition: 'isAfter', values: [checkDayBefore.toString()]}) const result = CardFilter.isClauseMet(filterClauseisAfter, [template], dateCard) expect(result).toBeTruthy() const filterClauseisNotAfter = createFilterClause({propertyId: 'datePropertyID', condition: 'isAfter', values: [checkDayAfter.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseisNotAfter, [template], dateCard) expect(result2).toBeFalsy() }) test('verify is clause', () => { const filterClauseIs = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [propertyDate.toString()]}) const result = CardFilter.isClauseMet(filterClauseIs, [template], dateCard) expect(result).toBeTruthy() const filterClauseIsNot = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [checkDayBefore.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseIsNot, [template], dateCard) expect(result2).toBeFalsy() }) }) describe('verify isClauseMet method - date range property', () => { // Date Properties are stored as 12PM UTC. const now = new Date(Date.now()) const fromDate = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 12) const toDate = fromDate + (2 * dayMillis) const dateCard = TestBlockFactory.createCard(board) dateCard.id = '1' dateCard.title = 'card1' dateCard.fields.properties.datePropertyID = '{ "from": ' + fromDate.toString() + ', "to": ' + toDate.toString() + ' }' const beforeRange = fromDate - dayMillis const afterRange = toDate + dayMillis const inRange = fromDate + dayMillis const template: IPropertyTemplate = { id: 'datePropertyID', name: 'myDate', type: 'date', options: [], } test('verify isBefore clause', () => { const filterClauseIsBeforeEmpty = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: []}) const resulta = CardFilter.isClauseMet(filterClauseIsBeforeEmpty, [template], dateCard) expect(resulta).toBeTruthy() const filterClauseIsBefore = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: [beforeRange.toString()]}) const result = CardFilter.isClauseMet(filterClauseIsBefore, [template], dateCard) expect(result).toBeFalsy() const filterClauseIsInRange = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: [inRange.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseIsInRange, [template], dateCard) expect(result2).toBeTruthy() const filterClauseIsAfter = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: [afterRange.toString()]}) const result3 = CardFilter.isClauseMet(filterClauseIsAfter, [template], dateCard) expect(result3).toBeTruthy() }) test('verify isAfter clauses', () => { const filterClauseIsAfterEmpty = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: []}) const resulta = CardFilter.isClauseMet(filterClauseIsAfterEmpty, [template], dateCard) expect(resulta).toBeTruthy() const filterClauseIsAfter = createFilterClause({propertyId: 'datePropertyID', condition: 'isAfter', values: [afterRange.toString()]}) const result = CardFilter.isClauseMet(filterClauseIsAfter, [template], dateCard) expect(result).toBeFalsy() const filterClauseIsInRange = createFilterClause({propertyId: 'datePropertyID', condition: 'isAfter', values: [inRange.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseIsInRange, [template], dateCard) expect(result2).toBeTruthy() const filterClauseIsBefore = createFilterClause({propertyId: 'datePropertyID', condition: 'isAfter', values: [beforeRange.toString()]}) const result3 = CardFilter.isClauseMet(filterClauseIsBefore, [template], dateCard) expect(result3).toBeTruthy() }) test('verify is clause', () => { const filterClauseIsEmpty = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: []}) const resulta = CardFilter.isClauseMet(filterClauseIsEmpty, [template], dateCard) expect(resulta).toBeTruthy() const filterClauseIsBefore = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [beforeRange.toString()]}) const result = CardFilter.isClauseMet(filterClauseIsBefore, [template], dateCard) expect(result).toBeFalsy() const filterClauseIsInRange = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [inRange.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseIsInRange, [template], dateCard) expect(result2).toBeTruthy() const filterClauseIsAfter = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [afterRange.toString()]}) const result3 = CardFilter.isClauseMet(filterClauseIsAfter, [template], dateCard) expect(result3).toBeFalsy() }) }) describe('verify isClauseMet method - (createdTime) date property', () => { const createDate = new Date(card1.createAt) const checkDate = Date.UTC(createDate.getFullYear(), createDate.getMonth(), createDate.getDate(), createDate.getHours(), createDate.getMinutes(), createDate.getSeconds(), createDate.getMilliseconds()) const checkDayBefore = checkDate - dayMillis const checkDayAfter = checkDate + dayMillis const template: IPropertyTemplate = { id: 'datePropertyID', name: 'myDate', type: 'createdTime', options: [], } test('should be true with isSet clause', () => { const filterClauseIsSet = createFilterClause({propertyId: 'datePropertyID', condition: 'isSet', values: []}) const result = CardFilter.isClauseMet(filterClauseIsSet, [template], card1) expect(result).toBeTruthy() }) test('should be false with notSet clause', () => { const filterClauseIsNotSet = createFilterClause({propertyId: 'datePropertyID', condition: 'isNotSet', values: []}) const result = CardFilter.isClauseMet(filterClauseIsNotSet, [template], card1) expect(result).toBeFalsy() }) test('verify isBefore clause', () => { const filterClauseIsBefore = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: [checkDayAfter.toString()]}) const result = CardFilter.isClauseMet(filterClauseIsBefore, [template], card1) expect(result).toBeTruthy() const filterClauseIsNotBefore = createFilterClause({propertyId: 'datePropertyID', condition: 'isBefore', values: [checkDate.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseIsNotBefore, [template], card1) expect(result2).toBeFalsy() }) test('verify isAfter clauses', () => { const filterClauseisAfter = createFilterClause({propertyId: 'datePropertyID', condition: 'isAfter', values: [checkDayBefore.toString()]}) const result = CardFilter.isClauseMet(filterClauseisAfter, [template], card1) expect(result).toBeTruthy() const filterClauseisNotAfter = createFilterClause({propertyId: 'datePropertyID', condition: 'isAfter', values: [checkDate.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseisNotAfter, [template], card1) expect(result2).toBeFalsy() }) test('verify is clause', () => { // Is should find on that date regardless of time. const filterClauseIs = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [checkDate.toString()]}) const result = CardFilter.isClauseMet(filterClauseIs, [template], card1) expect(result).toBeTruthy() const filterClauseIsNot = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [checkDayBefore.toString()]}) const result2 = CardFilter.isClauseMet(filterClauseIsNot, [template], card1) expect(result2).toBeFalsy() const filterClauseIsNot2 = createFilterClause({propertyId: 'datePropertyID', condition: 'is', values: [checkDayAfter.toString()]}) const result3 = CardFilter.isClauseMet(filterClauseIsNot2, [template], card1) expect(result3).toBeFalsy() }) }) describe('verify isFilterGroupMet method', () => { test('should return true with no filter', () => { const filterGroup = createFilterGroup({ operation: 'and', filters: [], }) const result = CardFilter.isFilterGroupMet(filterGroup, [], card1) expect(result).toBeTruthy() }) test('should return true with or operation and 2 filterCause, one is false ', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'or', filters: [ filterClauseNotIncludes, filterClause, ], }) const result = CardFilter.isFilterGroupMet(filterGroup, [], card1) expect(result).toBeTruthy() }) test('should return true with or operation and 2 filterCause, 1 filtergroup in filtergroup, one filterClause is false ', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: ['Status']}) const filterGroupInFilterGroup = createFilterGroup({ operation: 'or', filters: [ filterClauseNotIncludes, filterClause, ], }) const filterGroup = createFilterGroup({ operation: 'or', filters: [], }) filterGroup.filters.push(filterGroupInFilterGroup) const result = CardFilter.isFilterGroupMet(filterGroup, [], card1) expect(result).toBeTruthy() }) test('should return false with or operation and two filterCause, two are false ', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: ['Status']}) const filterClauseEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isEmpty', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'or', filters: [ filterClauseNotIncludes, filterClauseEmpty, ], }) const result = CardFilter.isFilterGroupMet(filterGroup, [], card1) expect(result).toBeFalsy() }) test('should return false with and operation and 2 filterCause, one is false ', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'and', filters: [ filterClauseNotIncludes, filterClause, ], }) const result = CardFilter.isFilterGroupMet(filterGroup, [], card1) expect(result).toBeFalsy() }) test('should return true with and operation and 2 filterCause, two are true ', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'and', filters: [ filterClauseIncludes, filterClause, ], }) const result = CardFilter.isFilterGroupMet(filterGroup, [], card1) expect(result).toBeTruthy() }) test('should return true with or operation and 2 filterCause, 1 filtergroup in filtergroup, one filterClause is false ', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: ['Status']}) const filterGroupInFilterGroup = createFilterGroup({ operation: 'and', filters: [ filterClauseNotIncludes, filterClause, ], }) const filterGroup = createFilterGroup({ operation: 'and', filters: [], }) filterGroup.filters.push(filterGroupInFilterGroup) const result = CardFilter.isFilterGroupMet(filterGroup, [], card1) expect(result).toBeFalsy() }) }) describe('verify propertyThatMeetsFilterClause method', () => { test('should return Utils.assertFailure and filterClause propertyId ', () => { const filterClauseIsNotEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isNotEmpty', values: ['Status']}) const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIsNotEmpty, []) expect(mockedUtils.assertFailure).toBeCalledTimes(1) expect(result.id).toEqual(filterClauseIsNotEmpty.propertyId) }) test('should return filterClause propertyId with non-select template and isNotEmpty clause ', () => { const filterClauseIsNotEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isNotEmpty', values: ['Status']}) const templateFilter: IPropertyTemplate = { id: filterClauseIsNotEmpty.propertyId, name: 'template', type: 'text', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIsNotEmpty, [templateFilter]) expect(result.id).toEqual(filterClauseIsNotEmpty.propertyId) expect(result.value).toBeFalsy() }) test('should return filterClause propertyId with select template , an option and isNotEmpty clause ', () => { const filterClauseIsNotEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isNotEmpty', values: ['Status']}) const templateFilter: IPropertyTemplate = { id: filterClauseIsNotEmpty.propertyId, name: 'template', type: 'select', options: [{ id: 'idOption', value: '', color: '', }], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIsNotEmpty, [templateFilter]) expect(result.id).toEqual(filterClauseIsNotEmpty.propertyId) expect(result.value).toEqual('idOption') }) test('should return filterClause propertyId with select template , no option and isNotEmpty clause ', () => { const filterClauseIsNotEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isNotEmpty', values: ['Status']}) const templateFilter: IPropertyTemplate = { id: filterClauseIsNotEmpty.propertyId, name: 'template', type: 'select', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIsNotEmpty, [templateFilter]) expect(result.id).toEqual(filterClauseIsNotEmpty.propertyId) expect(result.value).toBeFalsy() }) test('should return filterClause propertyId with template, and includes clause with values', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: ['Status']}) const templateFilter: IPropertyTemplate = { id: filterClauseIncludes.propertyId, name: 'template', type: 'text', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIncludes, [templateFilter]) expect(result.id).toEqual(filterClauseIncludes.propertyId) expect(result.value).toEqual(filterClauseIncludes.values[0]) }) test('should return filterClause propertyId with template, and includes clause with no values', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: []}) const templateFilter: IPropertyTemplate = { id: filterClauseIncludes.propertyId, name: 'template', type: 'text', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIncludes, [templateFilter]) expect(result.id).toEqual(filterClauseIncludes.propertyId) expect(result.value).toBeFalsy() }) test('should return filterClause propertyId with template, and notIncludes clause', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: []}) const templateFilter: IPropertyTemplate = { id: filterClauseNotIncludes.propertyId, name: 'template', type: 'text', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseNotIncludes, [templateFilter]) expect(result.id).toEqual(filterClauseNotIncludes.propertyId) expect(result.value).toBeFalsy() }) test('should return filterClause propertyId with template, and isEmpty clause', () => { const filterClauseIsEmpty = createFilterClause({propertyId: 'propertyId', condition: 'isEmpty', values: []}) const templateFilter: IPropertyTemplate = { id: filterClauseIsEmpty.propertyId, name: 'template', type: 'text', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIsEmpty, [templateFilter]) expect(result.id).toEqual(filterClauseIsEmpty.propertyId) expect(result.value).toBeFalsy() }) }) describe('verify propertyThatMeetsFilterClause method - Person properties', () => { test('should return filterClause propertyId with template, and isEmpty clause', () => { const filterClauseIsEmpty = createFilterClause({propertyId: 'propertyId', condition: 'is', values: []}) const templateFilter: IPropertyTemplate = { id: filterClauseIsEmpty.propertyId, name: 'template', type: 'createdBy', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIsEmpty, [templateFilter]) expect(result.id).toEqual(filterClauseIsEmpty.propertyId) expect(result.value).toBeFalsy() }) test('should return filterClause propertyId with template, and isEmpty clause', () => { const filterClauseIsEmpty = createFilterClause({propertyId: 'propertyId', condition: 'is', values: []}) const templateFilter: IPropertyTemplate = { id: filterClauseIsEmpty.propertyId, name: 'template', type: 'createdBy', options: [], } const result = CardFilter.propertyThatMeetsFilterClause(filterClauseIsEmpty, [templateFilter]) expect(result.id).toEqual(filterClauseIsEmpty.propertyId) expect(result.value).toBeFalsy() }) }) describe('verify propertiesThatMeetFilterGroup method', () => { test('should return {} with undefined filterGroup', () => { const result = CardFilter.propertiesThatMeetFilterGroup(undefined, []) expect(result).toEqual({}) }) test('should return {} with filterGroup without filter', () => { const filterGroup = createFilterGroup({ operation: 'and', filters: [], }) const result = CardFilter.propertiesThatMeetFilterGroup(filterGroup, []) expect(result).toEqual({}) }) test('should return {} with filterGroup, or operation and no template', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'or', filters: [ filterClauseIncludes, filterClause, ], }) const result = CardFilter.propertiesThatMeetFilterGroup(filterGroup, []) expect(result).toEqual({}) }) test('should return a result with filterGroup, or operation and template', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'or', filters: [ filterClauseIncludes, filterClause, ], }) const templateFilter: IPropertyTemplate = { id: filterClauseIncludes.propertyId, name: 'template', type: 'text', options: [], } const result = CardFilter.propertiesThatMeetFilterGroup(filterGroup, [templateFilter]) expect(result).toBeDefined() expect(result.propertyId).toEqual(filterClauseIncludes.values[0]) }) test('should return {} with filterGroup, and operation and no template', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'and', filters: [ filterClauseIncludes, filterClause, ], }) const result = CardFilter.propertiesThatMeetFilterGroup(filterGroup, []) expect(result).toEqual({}) }) test('should return a result with filterGroup, and operation and template', () => { const filterClauseIncludes = createFilterClause({propertyId: 'propertyId', condition: 'includes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'and', filters: [ filterClauseIncludes, filterClause, ], }) const templateFilter: IPropertyTemplate = { id: filterClauseIncludes.propertyId, name: 'template', type: 'text', options: [], } const result = CardFilter.propertiesThatMeetFilterGroup(filterGroup, [templateFilter]) expect(result).toBeDefined() expect(result.propertyId).toEqual(filterClauseIncludes.values[0]) }) }) describe('verify applyFilterGroup method', () => { test('should return array with card1', () => { const filterClauseNotIncludes = createFilterClause({propertyId: 'propertyId', condition: 'notIncludes', values: ['Status']}) const filterGroup = createFilterGroup({ operation: 'or', filters: [ filterClauseNotIncludes, filterClause, ], }) const result = CardFilter.applyFilterGroup(filterGroup, [], [card1]) expect(result).toBeDefined() expect(result[0]).toEqual(card1) }) }) describe('verfiy applyFilterGroup method for case-sensitive search', () => { test('should return array with card1 when search by test as Card1', () => { const filterClauseNotContains = createFilterClause({propertyId: 'title', condition: 'contains', values: ['Card1']}) const filterGroup = createFilterGroup({ operation: 'and', filters: [ filterClauseNotContains, ], }) const result = CardFilter.applyFilterGroup(filterGroup, [], [card1]) expect(result.length).toEqual(1) }) }) describe('verify applyFilter for title', () => { test('should not return array with card1', () => { const filterClauseNotContains = createFilterClause({propertyId: 'title', condition: 'notContains', values: ['card1']}) const filterGroup = createFilterGroup({ operation: 'and', filters: [ filterClauseNotContains, ], }) const result = CardFilter.applyFilterGroup(filterGroup, [], [card1]) expect(result.length).toEqual(0) }) }) }) ================================================ FILE: webapp/src/cardFilter.ts ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {DateUtils} from 'react-day-picker' import {DateProperty} from './properties/date/date' import {IPropertyTemplate} from './blocks/board' import {Card} from './blocks/card' import {FilterClause} from './blocks/filterClause' import {FilterGroup, isAFilterGroupInstance} from './blocks/filterGroup' import {Utils} from './utils' const halfDay = 12 * 60 * 60 * 1000 class CardFilter { static createDatePropertyFromString(initialValue: string): DateProperty { let dateProperty: DateProperty = {} if (initialValue) { const singleDate = new Date(Number(initialValue)) if (singleDate && DateUtils.isDate(singleDate)) { dateProperty.from = singleDate.getTime() } else { try { dateProperty = JSON.parse(initialValue) } catch { //Don't do anything, return empty dateProperty } } } return dateProperty } static applyFilterGroup(filterGroup: FilterGroup, templates: readonly IPropertyTemplate[], cards: Card[]): Card[] { return cards.filter((card) => this.isFilterGroupMet(filterGroup, templates, card)) } static isFilterGroupMet(filterGroup: FilterGroup, templates: readonly IPropertyTemplate[], card: Card): boolean { const {filters} = filterGroup if (filterGroup.filters.length < 1) { return true // No filters = always met } if (filterGroup.operation === 'or') { for (const filter of filters) { if (isAFilterGroupInstance(filter)) { if (this.isFilterGroupMet(filter, templates, card)) { return true } } else if (this.isClauseMet(filter, templates, card)) { return true } } return false } Utils.assert(filterGroup.operation === 'and') for (const filter of filters) { if (isAFilterGroupInstance(filter)) { if (!this.isFilterGroupMet(filter, templates, card)) { return false } } else if (!this.isClauseMet(filter, templates, card)) { return false } } return true } static isClauseMet(filter: FilterClause, templates: readonly IPropertyTemplate[], card: Card): boolean { let value = card.fields.properties[filter.propertyId] if (filter.propertyId === 'title') { value = card.title.toLowerCase() } const template = templates.find((o) => o.id === filter.propertyId) let dateValue: DateProperty | undefined if (template?.type === 'date') { dateValue = this.createDatePropertyFromString(value as string) } if (!value && template) { if (template.type === 'createdBy') { value = card.createdBy } else if (template.type === 'updatedBy') { value = card.modifiedBy } else if (template && template.type === 'createdTime') { value = card.createAt.toString() dateValue = this.createDatePropertyFromString(value as string) } else if (template && template.type === 'updatedTime') { value = card.updateAt.toString() dateValue = this.createDatePropertyFromString(value as string) } } switch (filter.condition) { case 'includes': { if (filter.values?.length < 1) { break } // No values = ignore clause (always met) return (filter.values.find((cValue) => (Array.isArray(value) ? value.includes(cValue) : cValue === value)) !== undefined) } case 'notIncludes': { if (filter.values?.length < 1) { break } // No values = ignore clause (always met) return (filter.values.find((cValue) => (Array.isArray(value) ? value.includes(cValue) : cValue === value)) === undefined) } case 'isEmpty': { return (value || '').length <= 0 } case 'isNotEmpty': { return (value || '').length > 0 } case 'isSet': { return Boolean(value) } case 'isNotSet': { return !value } case 'is': { if (filter.values.length === 0) { return true } if (dateValue !== undefined) { const numericFilter = parseInt(filter.values[0], 10) if (template && (template.type === 'createdTime' || template.type === 'updatedTime')) { // createdTime and updatedTime include the time // So to check if create and/or updated "is" date. // Need to add and subtract 12 hours and check range if (dateValue.from) { return dateValue.from > (numericFilter - halfDay) && dateValue.from < (numericFilter + halfDay) } return false } if (dateValue.from && dateValue.to) { return dateValue.from <= numericFilter && dateValue.to >= numericFilter } return dateValue.from === numericFilter } return filter.values[0]?.toLowerCase() === value } case 'contains': { if (filter.values.length === 0) { return true } return (value as string || '').includes(filter.values[0]?.toLowerCase()) } case 'notContains': { if (filter.values.length === 0) { return true } return !(value as string || '').includes(filter.values[0]?.toLowerCase()) } case 'startsWith': { if (filter.values.length === 0) { return true } return (value as string || '').startsWith(filter.values[0]?.toLowerCase()) } case 'notStartsWith': { if (filter.values.length === 0) { return true } return !(value as string || '').startsWith(filter.values[0]?.toLowerCase()) } case 'endsWith': { if (filter.values.length === 0) { return true } return (value as string || '').endsWith(filter.values[0]?.toLowerCase()) } case 'notEndsWith': { if (filter.values.length === 0) { return true } return !(value as string || '').endsWith(filter.values[0]?.toLowerCase()) } case 'isBefore': { if (filter.values.length === 0) { return true } if (dateValue !== undefined) { const numericFilter = parseInt(filter.values[0], 10) if (template && (template.type === 'createdTime' || template.type === 'updatedTime')) { // createdTime and updatedTime include the time // So to check if create and/or updated "isBefore" date. // Need to subtract 12 hours to filter if (dateValue.from) { return dateValue.from < (numericFilter - halfDay) } return false } return dateValue.from ? dateValue.from < numericFilter : false } return false } case 'isAfter': { if (filter.values.length === 0) { return true } if (dateValue !== undefined) { const numericFilter = parseInt(filter.values[0], 10) if (template && (template.type === 'createdTime' || template.type === 'updatedTime')) { // createdTime and updatedTime include the time // So to check if create and/or updated "isAfter" date. // Need to add 12 hours to filter if (dateValue.from) { return dateValue.from > (numericFilter + halfDay) } return false } if (dateValue.to) { return dateValue.to > numericFilter } return dateValue.from ? dateValue.from > numericFilter : false } return false } default: { Utils.assertFailure(`Invalid filter condition ${filter.condition}`) } } return true } static propertiesThatMeetFilterGroup(filterGroup: FilterGroup | undefined, templates: readonly IPropertyTemplate[]): Record { // TODO: Handle filter groups if (!filterGroup) { return {} } const filters = filterGroup.filters.filter((o) => !isAFilterGroupInstance(o)) if (filters.length < 1) { return {} } if (filterGroup.operation === 'or') { // Just need to meet the first clause const property = this.propertyThatMeetsFilterClause(filters[0] as FilterClause, templates) const result: Record = {} if (property.value) { result[property.id] = property.value } return result } // And: Need to meet all clauses const result: Record = {} filters.forEach((filterClause) => { const property = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates) if (property.value) { result[property.id] = property.value } }) return result } static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: readonly IPropertyTemplate[]): { id: string, value?: string } { const template = templates.find((o) => o.id === filterClause.propertyId) if (!template) { Utils.assertFailure(`propertyThatMeetsFilterClause. Cannot find template: ${filterClause.propertyId}`) return {id: filterClause.propertyId} } if (template.type === 'createdBy' || template.type === 'updatedBy') { return {id: filterClause.propertyId} } switch (filterClause.condition) { case 'includes': { if (filterClause.values.length < 1) { return {id: filterClause.propertyId} } return {id: filterClause.propertyId, value: filterClause.values[0]} } case 'notIncludes': { return {id: filterClause.propertyId} } case 'isEmpty': { return {id: filterClause.propertyId} } case 'isNotEmpty': { if (template.type === 'select') { if (template.options.length > 0) { const option = template.options[0] return {id: filterClause.propertyId, value: option.id} } return {id: filterClause.propertyId} } // TODO: Handle non-select types return {id: filterClause.propertyId} } default: { // Handle filter clause that cannot be set return {id: filterClause.propertyId} } } } } export {CardFilter} ================================================ FILE: webapp/src/components/__snapshots__/addContentMenuItem.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/addContentMenuItem return a checkbox menu item 1`] = `
`; exports[`components/addContentMenuItem return a divider menu item 1`] = `
`; exports[`components/addContentMenuItem return a text menu item 1`] = `
`; exports[`components/addContentMenuItem return an error and empty element from unknown type 1`] = `
`; exports[`components/addContentMenuItem return an image menu item 1`] = `
`; ================================================ FILE: webapp/src/components/__snapshots__/blockIconSelector.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/blockIconSelector return an icon correctly 1`] = `
`; exports[`components/blockIconSelector return menu on click 1`] = `
================================================ FILE: website/site/themes/layouts/partials/counters.html ================================================

{{ .Site.Params.counters.title | markdownify }}

{{ range .Site.Params.counters.item }}
{{ .to }} {{ .description }}
{{ end }}
================================================ FILE: website/site/themes/layouts/partials/footer.html ================================================

{{ with .Site.Params.footer.copyright }}{{ . | markdownify }}{{ end }}

================================================ FILE: website/site/themes/layouts/partials/head.html ================================================ {{ .Title }} {{ with .Site.Params.author }}{{ end }} {{ with .Site.Params.description }}{{ end }} {{ with .Site.LanguageCode }}{{ end }} {{ if not .Site.Params.OpenGraph.hide }} {{ end }} {{ range .Site.Params.custom_css }} {{ end }} ================================================ FILE: website/site/themes/layouts/partials/hero.html ================================================

{{ with .Site.Params.hero.title }}{{ . | markdownify }}{{ end }}

{{ with .Site.Params.hero.subtitle }}{{ . | markdownify }}{{ end }}

================================================ FILE: website/site/themes/layouts/partials/intro.html ================================================
{{ range .Site.Params.intro.item }}

{{ .title }}

{{ .description }}

{{ .button}}

{{ end }}
{{ if .Site.Params.intro.video.enable }}
{{.Site.Params.intro.video.title | markdownify }}
{{ end }}
================================================ FILE: website/site/themes/layouts/partials/js.html ================================================ ================================================ FILE: website/site/themes/layouts/partials/nav.html ================================================ ================================================ FILE: website/site/themes/layouts/partials/nav2.html ================================================ ================================================ FILE: website/site/themes/layouts/partials/services.html ================================================

{{ with .Site.Params.services.title }}{{ . | markdownify }}{{ end }}

{{ with .Site.Params.services.description }}{{ . | markdownify }}{{ end }}

{{ range .Site.Params.services.item }}

{{ .title | markdownify }}

{{ .description | markdownify }}

{{ end }}
================================================ FILE: website/site/themes/layouts/partials/testimonials.html ================================================

{{ with .Site.Params.testimonials.title }}{{ . | markdownify }}{{ end }}

{{ with .Site.Params.testimonials.description }}{{ . | markdownify }}{{ end }}

{{ range .Site.Params.testimonials.item }}

{{ .quote | markdownify}}

{{ .alt }}

{{ .person | markdownify }}

{{ end }}
================================================ FILE: website/site/themes/layouts/partials/work.html ================================================

{{ with .Site.Params.work.title }}{{ . }}{{ end }}

{{ with .Site.Params.work.description }}{{ . | markdownify }}{{ end }}

{{ range .Site.Params.work.row }}
{{ end }}

{{ .Site.Params.work.footertext | markdownify }}

================================================ FILE: win-wpf/.gitignore ================================================ packages obj msix temp dist *.msix *.suo *.csproj.user ================================================ FILE: win-wpf/AppxManifest.xml ================================================ Focalboard Mattermost, Inc. Focalboard Desktop Edition Assets\StoreLogo.png ================================================ FILE: win-wpf/Focalboard/App.config ================================================
50, 20 1024, 800 False ================================================ FILE: win-wpf/Focalboard/App.xaml ================================================  ================================================ FILE: win-wpf/Focalboard/App.xaml.cs ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. using System; using System.Diagnostics; using System.IO; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Windows; using Windows.Storage; namespace Focalboard { /// /// Interaction logic for App.xaml /// public partial class App : Application { public string sessionToken = ""; public int port; private Mutex mutex; App() { SingleInstanceCheck(); Startup += App_Startup; } public void SingleInstanceCheck() { bool isOnlyInstance = false; mutex = new Mutex(true, @"Focalboard", out isOnlyInstance); if (!isOnlyInstance) { ShowExistingWindow(); Shutdown(); } } [DllImport("User32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); // shows the window of the single-instance that is already open private void ShowExistingWindow() { var currentProcess = Process.GetCurrentProcess(); var processes = Process.GetProcessesByName(currentProcess.ProcessName); foreach (var process in processes) { // the single-instance already open should have a MainWindowHandle if (process.MainWindowHandle != IntPtr.Zero) { // restores the window in case it was minimized const int SW_SHOWNORMAL = 1; ShowWindow(process.MainWindowHandle, SW_SHOWNORMAL); // brings the window to the foreground SetForegroundWindow(process.MainWindowHandle); return; } } } private void App_Startup(object sender, StartupEventArgs e) { Debug.WriteLine($"App_Startup()"); try { InitServer(); } catch (Exception ex) { MessageBox.Show($"InitServer ERROR: {ex.ToString()}", "Focalboard"); Shutdown(); } } private void InitServer() { port = FindFreePort(); Debug.WriteLine("port: {0}", port); sessionToken = CreateSessionToken(); // Need to set CWD so the server can read the config file var appFolder = Utils.GetAppFolder(); Directory.SetCurrentDirectory(appFolder); string appDataFolder; try { appDataFolder = ApplicationData.Current.LocalFolder.Path; } catch { var documentsFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); appDataFolder = Path.Combine(documentsFolder, "Focalboard"); Directory.CreateDirectory(appDataFolder); // Not a UWP app, store in Documents // FIXUP code: Copy from old DB location var oldDBPath = Path.Combine(documentsFolder, "focalboard.db"); var newDBPath = Path.Combine(appDataFolder, "focalboard.db"); if (!File.Exists(newDBPath) && File.Exists(oldDBPath)) { Debug.WriteLine($"Moving DB file from: {oldDBPath} to {newDBPath}"); File.Move(oldDBPath, newDBPath); } } var dbPath = Path.Combine(appDataFolder, "focalboard.db"); Debug.WriteLine($"dbPath: {dbPath}"); var filesPath = Path.Combine(appDataFolder, "files"); Debug.WriteLine($"filesPath: {filesPath}"); var cwd = Directory.GetCurrentDirectory(); var webFolder = Path.Combine(cwd, @"pack"); webFolder = webFolder.Replace(@"\", @"/"); filesPath = filesPath.Replace(@"\", @"/"); dbPath = dbPath.Replace(@"\", @"/"); byte[] webFolderBytes = Encoding.UTF8.GetBytes(webFolder); byte[] filesPathBytes = Encoding.UTF8.GetBytes(filesPath); byte[] sessionTokenBytes = Encoding.UTF8.GetBytes(sessionToken); byte[] dbPathBytes = Encoding.UTF8.GetBytes(dbPath); byte[] configFilePathBytes = Encoding.UTF8.GetBytes(""); GoFunctions.StartServer(webFolderBytes, filesPathBytes, port, sessionTokenBytes, dbPathBytes, configFilePathBytes); Debug.WriteLine("Server started"); } private string CreateSessionToken() { using (RandomNumberGenerator rng = new RNGCryptoServiceProvider()) { byte[] tokenData = new byte[32]; rng.GetBytes(tokenData); string token = Convert.ToBase64String(tokenData); return token; } } private int FindFreePort() { int port = 0; Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); try { var localEP = new IPEndPoint(IPAddress.Any, 0); socket.Bind(localEP); localEP = (IPEndPoint)socket.LocalEndPoint; port = localEP.Port; } finally { socket.Close(); } return port; } } static class GoFunctions { [DllImport(@"focalboard-server.dll", CharSet = CharSet.Unicode, CallingConvention = CallingConvention.StdCall)] public static extern void StartServer(byte[] webPath, byte[] filesPath, int port, byte[] singleUserToken, byte[] dbConfigString, byte[] configFilePath); [DllImport(@"focalboard-server.dll", CharSet = CharSet.Unicode, CallingConvention = CallingConvention.StdCall)] public static extern void StopServer(); } } ================================================ FILE: win-wpf/Focalboard/Focalboard.csproj ================================================  Debug AnyCPU {7B3F5C74-96AC-4521-9268-28BF2D91FCF4} WinExe Focalboard Focalboard v4.8 512 {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 4 true true publish\ true Disk false Foreground 7 Days false false true 0 1.0.0.%2a false false true AnyCPU true full false bin\Debug\ DEBUG;TRACE prompt 4 AnyCPU pdbonly true bin\Release\ TRACE prompt 4 true bin\x64\Debug\ DEBUG;TRACE full x64 7.3 prompt true bin\x64\Release\ TRACE true pdbonly x64 7.3 prompt true Always focalboard.ico ..\packages\Microsoft.Web.WebView2.1.0.705.50\lib\net45\Microsoft.Web.WebView2.Core.dll ..\packages\Microsoft.Web.WebView2.1.0.705.50\lib\net45\Microsoft.Web.WebView2.WinForms.dll ..\packages\Microsoft.Web.WebView2.1.0.705.50\lib\net45\Microsoft.Web.WebView2.Wpf.dll False $(MSBuildProgramFiles32)\Reference Assemblies\Microsoft\Framework\.NETCore\v4.5\System.Runtime.WindowsRuntime.dll False 4.0 $(MSBuildProgramFiles32)\Windows Kits\10\UnionMetadata\10.0.19041.0\Windows.winmd False MSBuild:Compile Designer MSBuild:Compile Designer App.xaml Code MainWindow.xaml Code Code True True Resources.resx True Settings.settings True ResXFileCodeGenerator Resources.Designer.cs SettingsSingleFileGenerator Settings.Designer.cs False Microsoft .NET Framework 4.7.2 %28x86 and x64%29 true False .NET Framework 3.5 SP1 false This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. copy "$(ProjectDir)..\..\bin\win-dll\*" "$(TargetDir)" copy "$(ProjectDir)..\..\app-config.json" "$(TargetDir)config.json" rd /s /q "$(TargetDir)pack" md "$(TargetDir)pack" xcopy /E /I /Y "$(ProjectDir)..\..\webapp\pack" "$(TargetDir)pack\" ================================================ FILE: win-wpf/Focalboard/MainWindow.xaml ================================================ ================================================ FILE: win-wpf/Focalboard/MainWindow.xaml.cs ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. using System; using System.Diagnostics; using System.IO; using System.Windows; using System.Windows.Input; using Microsoft.Web.WebView2.Core; namespace Focalboard { /// /// Interaction logic for MainWindow.xaml /// public partial class MainWindow : Window { private int port { get { return ((App)Application.Current).port; } } private string sessionToken { get { return ((App)Application.Current).sessionToken; } } public MainWindow() { Debug.WriteLine($"MainWindow()"); InitializeComponent(); RestoreWindowsState(); this.Loaded += MainWindow_Loaded; this.Closing += MainWindow_Closing; InitializeWebView(); } private void MainWindow_Loaded(object sender, RoutedEventArgs e) { Activate(); } private void PromptToInstallWebview2() { var dialogResult = MessageBox.Show( "Focalboard requires the WebView2 runtime to be downloaded and installed. Install now?", "Focalboard", MessageBoxButton.YesNo, MessageBoxImage.Information, MessageBoxResult.OK, MessageBoxOptions.DefaultDesktopOnly); if (dialogResult == MessageBoxResult.Yes) { installingLabel.Visibility = Visibility.Visible; webView.Visibility = Visibility.Collapsed; var installer = new Webview2Installer(); installer.InstallProgress += Installer_InstallProgress; installer.InstallCompleted += Installer_InstallCompleted; installer.DownloadAndInstall(); } } private void Installer_InstallProgress(Webview2Installer sender, EventArgs e) { Application.Current.Dispatcher.Invoke(() => { if (sender.downloadProgress < 100) { installingLabel.Content = $"Downloading Webview2: {sender.downloadProgress}%"; } else { installingLabel.Content = "Installing Webview2..."; } }); } private void Installer_InstallCompleted(Webview2Installer sender, EventArgs e) { Application.Current.Dispatcher.Invoke(() => { installingLabel.Content = "Webview2 install completed"; Activate(); if (sender.exitCode != 0) { var message = $"Webview2 install FAILED with code {sender.exitCode}. Try again."; MessageBox.Show(message, "Install failed"); } // Reopen window var window = new MainWindow(); window.Show(); Close(); }); } private void SaveWindowState() { try { Properties.Settings.Default.WindowPosition = new System.Drawing.Point( Convert.ToInt32(RestoreBounds.Location.X), Convert.ToInt32(RestoreBounds.Location.Y)); Properties.Settings.Default.WindowSize = new System.Drawing.Size( Convert.ToInt32(RestoreBounds.Size.Width), Convert.ToInt32(RestoreBounds.Size.Height)); Properties.Settings.Default.WindowMaximized = (WindowState == WindowState.Maximized); Properties.Settings.Default.Save(); } catch { // Ignore errors, e.g. overflow } } private void RestoreWindowsState() { this.Left = Properties.Settings.Default.WindowPosition.X; this.Top = Properties.Settings.Default.WindowPosition.Y; this.Width = Math.Max(300, Properties.Settings.Default.WindowSize.Width); this.Height = Math.Max(200, Properties.Settings.Default.WindowSize.Height); if (Properties.Settings.Default.WindowMaximized) { WindowState = WindowState.Maximized; } } private void MainWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e) { SaveWindowState(); } async void InitializeWebView() { string version = GetWebView2Version(); var isAltKeyPressed = (Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.RightAlt)); if (version == "" || isAltKeyPressed) { PromptToInstallWebview2(); return; } this.Title = $"Focalboard (port {port} WebView {version})"; // must create a data folder if running out of a secured folder that can't write like Program Files var env = await CoreWebView2Environment.CreateAsync( userDataFolder: Path.Combine(Path.GetTempPath(), "Focalboard") ); await webView.EnsureCoreWebView2Async(env); webView.ContentLoading += WebView_ContentLoading; var url = String.Format("http://localhost:{0}", port); webView.Source = new Uri(url); } private static string GetWebView2Version() { try { return CoreWebView2Environment.GetAvailableBrowserVersionString(); } catch (Exception) { return ""; } } private void WebView_ContentLoading(object sender, CoreWebView2ContentLoadingEventArgs e) { // Set focalboardSessionId string script = $"localStorage.setItem('focalboardSessionId', '{sessionToken}');"; webView.ExecuteScriptAsync(script); } } } ================================================ FILE: win-wpf/Focalboard/Properties/AssemblyInfo.cs ================================================ using System.Reflection; using System.Resources; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Windows; // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. [assembly: AssemblyTitle("Focalboard")] [assembly: AssemblyDescription("Focalboard Windows App")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("Focalboard")] [assembly: AssemblyCopyright("Copyright © Mattermost, Inc. 2021")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from // COM, set the ComVisible attribute to true on that type. [assembly: ComVisible(false)] //In order to begin building localizable applications, set //CultureYouAreCodingWith in your .csproj file //inside a . For example, if you are using US english //in your source files, set the to en-US. Then uncomment //the NeutralResourceLanguage attribute below. Update the "en-US" in //the line below to match the UICulture setting in the project file. //[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] [assembly: ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located //(used if a resource is not found in the page, // or application resource dictionaries) ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located //(used if a resource is not found in the page, // app, or any theme specific resource dictionaries) )] // Version information for an assembly consists of the following four values: // // Major Version // Minor Version // Build Number // Revision // // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.0.0")] ================================================ FILE: win-wpf/Focalboard/Properties/Resources.Designer.cs ================================================ //------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. // //------------------------------------------------------------------------------ namespace Focalboard.Properties { using System; /// /// A strongly-typed resource class, for looking up localized strings, etc. /// // This class was auto-generated by the StronglyTypedResourceBuilder // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { private static global::System.Resources.ResourceManager resourceMan; private static global::System.Globalization.CultureInfo resourceCulture; [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal Resources() { } /// /// Returns the cached ResourceManager instance used by this class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] internal static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Focalboard.Properties.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; } } /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] internal static global::System.Globalization.CultureInfo Culture { get { return resourceCulture; } set { resourceCulture = value; } } } } ================================================ FILE: win-wpf/Focalboard/Properties/Resources.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 ================================================ FILE: win-wpf/Focalboard/Properties/Settings.Designer.cs ================================================ //------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. // //------------------------------------------------------------------------------ namespace Focalboard.Properties { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.8.1.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); public static Settings Default { get { return defaultInstance; } } [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("50, 20")] public global::System.Drawing.Point WindowPosition { get { return ((global::System.Drawing.Point)(this["WindowPosition"])); } set { this["WindowPosition"] = value; } } [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("1024, 800")] public global::System.Drawing.Size WindowSize { get { return ((global::System.Drawing.Size)(this["WindowSize"])); } set { this["WindowSize"] = value; } } [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] public bool WindowMaximized { get { return ((bool)(this["WindowMaximized"])); } set { this["WindowMaximized"] = value; } } } } ================================================ FILE: win-wpf/Focalboard/Properties/Settings.settings ================================================  50, 20 1024, 800 False ================================================ FILE: win-wpf/Focalboard/Utils.cs ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. using System.IO; namespace Focalboard { static class Utils { public static string GetAppFolder() { string appFolder; try { appFolder = Windows.Application­Model.Package.Current.Installed­Location.Path; } catch { // Not a UWP app string appPath = System.Reflection.Assembly.GetExecutingAssembly().Location; appFolder = Path.GetDirectoryName(appPath); } return appFolder; } } } ================================================ FILE: win-wpf/Focalboard/Webview2Installer.cs ================================================ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. using System; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Net; using System.Windows; using Windows.Storage; namespace Focalboard { class Webview2Installer { public int exitCode = -1; public int downloadProgress = 0; public delegate void InstallerHandler(Webview2Installer sender, EventArgs e); public event InstallerHandler InstallProgress; public event InstallerHandler InstallCompleted; private string filePath; public Webview2Installer() { var filename = $"{Guid.NewGuid().ToString()} MicrosoftEdgeWebview2Setup.exe"; filePath = Path.Combine(System.IO.Path.GetTempPath(), filename); Debug.WriteLine($"Webview2Installer.filePath: {filePath}"); } public void DownloadAndInstall() { const string url = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; var uri = new Uri(url); try { if (File.Exists(filePath)) { File.Delete(filePath); } WebClient wc = new WebClient(); wc.DownloadFileAsync(uri, filePath); wc.DownloadProgressChanged += new DownloadProgressChangedEventHandler(wc_DownloadProgressChanged); wc.DownloadFileCompleted += new AsyncCompletedEventHandler(wc_DownloadFileCompleted); } catch (Exception ex) { MessageBox.Show($"Webview2 download ERROR: {ex.Message}", "Download error"); } } private void wc_DownloadProgressChanged(object sender, DownloadProgressChangedEventArgs e) { downloadProgress = e.ProgressPercentage; InstallProgress?.Invoke(this, e); } private void wc_DownloadFileCompleted(object sender, AsyncCompletedEventArgs e) { if (e.Error == null) { var proc = Process.Start(filePath); proc.EnableRaisingEvents = true; proc.Exited += Proc_Exited; } else { MessageBox.Show($"Unable to download webview2 installer, please check your Internet connection. ERROR: {e.Error.Message}", "Download failed"); } } private void Proc_Exited(object sender, EventArgs e) { // Delete downloaded installer try { if (File.Exists(filePath)) { File.Delete(filePath); } } catch (Exception ex) { Debug.WriteLine($"Delete file failed. Error: {ex.Message}, filePath: {filePath}"); } var proc = (Process)sender; exitCode = proc.ExitCode; InstallCompleted?.Invoke(this, e); } } } ================================================ FILE: win-wpf/Focalboard/packages.config ================================================  ================================================ FILE: win-wpf/Focalboard.sln ================================================  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.30907.101 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Focalboard", "Focalboard\Focalboard.csproj", "{7B3F5C74-96AC-4521-9268-28BF2D91FCF4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Debug|x64.ActiveCfg = Debug|x64 {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Debug|x64.Build.0 = Debug|x64 {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Release|Any CPU.Build.0 = Release|Any CPU {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Release|x64.ActiveCfg = Release|x64 {7B3F5C74-96AC-4521-9268-28BF2D91FCF4}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8583DA40-AB6E-4AC4-89EC-F2419CE044D1} EndGlobalSection EndGlobal ================================================ FILE: win-wpf/README.md ================================================ # Focalboard Windows Personal Desktop This folder contains the code for the Windows Personal Desktop. It packages a lightweight C# Windows App with the Windows build of the server, and the webapp. The server is run in a single-user mode. ## Debugging in Visual Studio Open `Focalboard.sln` in Visual Studio to debug it. ### Testing the single-user server You can also run the server in single-user mode and connect to it via a browser: 1. Run `FOCALBOARD_SINGLE_USER_TOKEN=testtest make watch-single-user` * This runs the server with the `-single-user` flag * Alternatively, select `Go: Launch Single-user Server` from VSCode's run and debug options 2. Open a browser to `http://localhost:8000` 3. Open the browser developer tools to Application \ Local Storage \ localhost:8000 4. Set `focalboardSessionId` to `testtest` 5. Navigate to `http://localhost:8000` ================================================ FILE: win-wpf/build.bat ================================================ @echo off WHERE msbuild.exe > nul 2>&1 IF %ERRORLEVEL% NEQ 0 set PATH=%PATH%;C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin WHERE msbuild.exe > nul 2>&1 IF %ERRORLEVEL% NEQ 0 set PATH=%PATH%;C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin WHERE msbuild.exe > nul IF %ERRORLEVEL% NEQ 0 echo msbuild.exe not found; exit /b 1 echo Building... msbuild.exe Focalboard.sln /t:Rebuild /p:Configuration=Release /p:Platform="x64" /p:DebugSymbols=false /p:DebugType=None ================================================ FILE: win-wpf/package-zip.bat ================================================ @echo off if exist dist\focalboard-win.zip del /q dist\focalboard-win.zip if not exist dist mkdir dist if exist temp del /s /f /q temp rmdir /s /q temp if not exist temp mkdir temp xcopy /e /i /y Focalboard\bin\x64\Release temp copy ..\build\MIT-COMPILED-LICENSE.md temp copy ..\NOTICE.txt temp copy ..\webapp\NOTICE.txt temp\webapp-NOTICE.txt echo --- Contents of temp --- dir /s /b temp echo --- powershell Compress-Archive -Path temp\* -DestinationPath dist\focalboard-win.zip ================================================ FILE: win-wpf/package.bat ================================================ @echo off WHERE makeappx.exe > nul 2>&1 IF %ERRORLEVEL% NEQ 0 set PATH=%PATH%;C:\Program Files (x86)\Windows Kits\10\App Certification Kit WHERE makeappx.exe > nul IF %ERRORLEVEL% NEQ 0 echo makeappx.exe not found; exit /b 1 echo Packaging... rd /s /q msix mkdir msix xcopy /e /i /y Focalboard\bin\x64\Release msix mkdir msix\Assets copy art\StoreLogo.png msix\Assets copy art\icon150.png msix\Assets copy art\icon44.png msix\Assets copy AppxManifest.xml msix makeappx.exe pack /o /v /d msix /p Focalboard.msix