Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e4c0539af
|
||
|
|
b8fcef2fd4
|
||
|
|
4e2e6adc73 | ||
|
|
091541a960 | ||
|
|
967b8482d3 | ||
|
|
cb43837c32 | ||
|
|
899617bd4d | ||
|
|
2757bfb365 | ||
|
|
1ed942bb8a | ||
|
|
179157ccd2 | ||
|
|
3e36b931a6 | ||
|
|
c9e60847d0 | ||
|
|
4d83dc0c05 | ||
|
|
995965d157 | ||
|
|
15fbb314b0 | ||
|
|
68e0032c6d | ||
|
|
cffca535b5 | ||
|
|
b2d2632e3b | ||
|
|
119379d4b4 | ||
|
|
c434f83dfc | ||
|
|
1527f364b1 | ||
|
|
5779d94d78 | ||
|
|
096a3dc46b | ||
|
|
c883f31d35 | ||
|
|
1fa9714d4e | ||
|
|
53b64ee222 | ||
|
|
e4b8396a3b | ||
|
|
8cf7e83493 | ||
|
|
68220f944b | ||
|
|
fc4d84574e | ||
|
|
2989e1d5b1 | ||
|
|
5844e20d55 | ||
|
|
2376237b6e | ||
|
|
bcf55bdc3a | ||
|
|
ac44706461 | ||
|
|
928db8d675 | ||
|
|
5c9407f729 | ||
|
|
93f50a7fb3 | ||
|
|
39d84f8fe8 | ||
|
|
d369a3614a | ||
|
|
078ab4125e | ||
|
|
3f0dc8b7be | ||
|
|
c020e09aff | ||
|
|
b0c007d44f | ||
|
|
b777ee2a12 | ||
|
|
b328cae552 | ||
|
|
5357bb47c3 | ||
|
|
447877733b | ||
|
|
feb491f8d4 | ||
|
|
8f59d76a4e | ||
|
|
3a52565ab4 | ||
|
|
08ae61161c | ||
|
|
1835245943 | ||
|
|
83de473e4e | ||
|
|
a532ee7f14 | ||
|
|
f00b265b34 | ||
|
|
2931cc990f | ||
|
|
25a4672d6d | ||
|
|
969a4016ee | ||
|
|
5c2d7079ce | ||
|
|
d1c71d0526 | ||
|
|
1fa2ab0146 | ||
|
|
26a03f4845 | ||
|
|
58bfad3adf | ||
|
|
f1c2f1f40f | ||
|
|
7d3e8eb89f | ||
|
|
3fe8f602c8 | ||
|
|
d87eed36a0 | ||
|
|
9cc0660410 | ||
|
|
58ff88890d | ||
|
|
77d60b21a1 | ||
|
|
9df2db0314 | ||
|
|
91839cf3ef | ||
|
|
74f2352387 | ||
|
|
f299ca1285 | ||
|
|
f47e67b5b4 | ||
|
|
fe16b94c1e | ||
|
|
b77b62bfa2 | ||
|
|
43d8bf1b92 | ||
|
|
dca9427bfb | ||
|
|
7f17616b75 | ||
|
|
4a9dcdd0ef | ||
|
|
a1a4176ffb | ||
|
|
a86a087915 | ||
|
|
f9ace4cde9 | ||
|
|
4677324425 | ||
|
|
7e5852c62c | ||
|
|
e01f99eb52 | ||
|
|
8ff9a41f2d | ||
|
|
075e86b00e | ||
|
|
f20080a603 | ||
|
|
3594d75b5d | ||
|
|
72767ad9a7 | ||
|
|
7db386fda3 | ||
|
|
39f3c89b0a | ||
|
|
15a598525f | ||
|
|
d30803276d | ||
|
|
be63a357dd | ||
|
|
c71a97cba2 | ||
|
|
357de728c0 | ||
|
|
db3c0c18f9 | ||
|
|
45706292e7 | ||
|
|
29b3f52e50 | ||
|
|
f81a0d1537 | ||
|
|
e613e8763b | ||
|
|
3d17810293 | ||
|
|
1871873da0 | ||
|
|
15b3213a4d | ||
|
|
4265288417 | ||
|
|
a36d6f9326 | ||
|
|
3c50f90b51 | ||
|
|
24a4b8e5ee | ||
|
|
e676f96905 | ||
|
|
a903154ab2 | ||
|
|
beedf2fefa | ||
|
|
dee5711000 | ||
|
|
e93c049e8c | ||
|
|
fc10ce4259 | ||
|
|
2573034149 | ||
|
|
a1fc2a91dc | ||
|
|
e86c37e636 | ||
|
|
8b71386b90 | ||
|
|
2edef5b4ab | ||
|
|
d2a25ae9a6 | ||
|
|
6f021aa387 | ||
|
|
d80afc7834 | ||
|
|
d8975d21be | ||
|
|
b0918686a2 | ||
|
|
71c9e1b3ee | ||
|
|
10759d777b | ||
|
|
c956ab5a4e | ||
|
|
f9b790668d | ||
|
|
0e5fef6f31 | ||
|
|
832265e091 | ||
|
|
8940a10397 | ||
|
|
1e1d9586d7 | ||
|
|
305864f0c7 | ||
|
|
d475214224 | ||
|
|
73204d4e0a | ||
|
|
533187d328 | ||
|
|
70db04a93f | ||
|
|
e8eae6a1fc | ||
|
|
8ea5a6a097 | ||
|
|
e6a2314a1e | ||
|
|
7836ce27e7 | ||
|
|
a5fc4a4893 | ||
|
|
358d893c33 | ||
|
|
ae211b5adf | ||
|
|
d0b2b5201e | ||
|
|
0130858584 | ||
|
|
6b923eb4c2 | ||
|
|
d033dad20f | ||
|
|
f882a14ee0 | ||
|
|
990d06b8e1 | ||
|
|
b6564c81f1 | ||
|
|
8df46d8512 | ||
|
|
5072861687 | ||
|
|
919bd9e29b | ||
|
|
c5e42607af | ||
|
|
e64e2c6d53 | ||
|
|
36274f35f4 | ||
|
|
d5e717e9ce | ||
|
|
13f78f6207 | ||
|
|
e7aedd9f6b | ||
|
|
fc76d0e576 | ||
|
|
a1b7194261 | ||
|
|
66d15f6ab0 | ||
|
|
561e62a05c | ||
|
|
b4b81684e0 | ||
|
|
898f625f1f | ||
|
|
105199991f | ||
|
|
89be5c1f6e | ||
|
|
ae671bc70a | ||
|
|
78e34b5522 | ||
|
|
42d79b3dcf | ||
|
|
2d4178a281 | ||
|
|
ac0e11e548 | ||
|
|
0543cc42de | ||
|
|
06c803388b | ||
|
|
58b00162d9 | ||
|
|
08084d96e8 | ||
|
|
8b63e84cba | ||
|
|
a2e76f2cf0 | ||
|
|
72f22d1235 | ||
|
|
76945ee5b5 | ||
|
|
d8b7d6e5f4 | ||
|
|
1369316401 | ||
|
|
29b0cc1f86 | ||
|
|
74f422b523 | ||
|
|
0fefdeec4c | ||
|
|
b99e7979db | ||
|
|
64b43a42dd | ||
|
|
9d35870863 | ||
|
|
526580cbb6 | ||
|
|
d3366ca7cd | ||
|
|
048a90f876 | ||
|
|
abc24cf381 | ||
|
|
9d21f19ab8 | ||
|
|
07a0072b87 | ||
|
|
dbe5705b47 | ||
|
|
0ba3255382 | ||
|
|
2e34b2d004 | ||
|
|
fc92b342ce | ||
|
|
45c4afd22b | ||
|
|
d9f1ecb834 | ||
|
|
a37190e0fc | ||
|
|
cd37c6c980 | ||
|
|
316567f219 | ||
|
|
e5c61b32e3 | ||
|
|
a41d5ff2c0 | ||
|
|
769a139e70 | ||
|
|
7729d9f33e | ||
|
|
8e13f2bce9 | ||
|
|
4e28ffef9c | ||
|
|
d06a7a2c76 | ||
|
|
886f3bb840 | ||
|
|
b501d7af33 | ||
|
|
c3ea57986f | ||
|
|
048947ea7e | ||
|
|
7834a660a5 | ||
|
|
eee7c49439 | ||
|
|
dbab502be4 | ||
|
|
f4d5279a09 | ||
|
|
c8430a8c14 | ||
|
|
63efa5d0f0 | ||
|
|
8cda80752f | ||
|
|
abeb2d2477 | ||
|
|
a7c74e8d8b | ||
|
|
4b7ed35300 | ||
|
|
6463a1ef87 | ||
|
|
f85dc3fa76 | ||
|
|
fb05b1a847 | ||
|
|
5b751217d5 | ||
|
|
07e6f663db | ||
|
|
23ed41bddf | ||
|
|
3e33c766d6 | ||
|
|
83078736f5 | ||
|
|
7ccb50e4f5 | ||
|
|
1032c9d9cf | ||
|
|
ecbecc9d86 | ||
|
|
797426335a | ||
|
|
e7f2068105 | ||
|
|
afc51165f4 | ||
|
|
87a6354e42 | ||
|
|
edc8c17e35 | ||
|
|
2a700f5690 | ||
|
|
a6ce798862 | ||
|
|
30fd008310 | ||
|
|
3608d4b657 | ||
|
|
7c1ee7f161 | ||
|
|
d978d626ae | ||
|
|
1059164255 | ||
|
|
741668a831 | ||
|
|
5f06ebfaec | ||
|
|
b5e79b37ed | ||
|
|
7076ce5df3 | ||
|
|
3e00b2ccc2 | ||
|
|
5681592572 | ||
|
|
87dcfdbff6 |
No files matched your search
+2
-1
@@ -1,3 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
.gitea/CODEOWNERS linguist-detectable=false
|
||||
@@ -0,0 +1,3 @@
|
||||
# Pull requests request review from the eligible staff team(s).
|
||||
# One approval from either an Admin or Developer satisfies branch protection.
|
||||
* @trueog/Admins @trueog/Developers
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: playwright-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Meteor unit tests
|
||||
@@ -23,13 +26,33 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-unit-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-unit-
|
||||
|
||||
- name: Install Meteor
|
||||
run: curl https://install.meteor.com/ | sh
|
||||
run: |
|
||||
if [ ! -x ~/.meteor/meteor ]; then
|
||||
curl https://install.meteor.com/ | sh
|
||||
fi
|
||||
echo "$HOME/.meteor" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Meteor unit tests
|
||||
# First Meteor build is slow; allow time before the test driver starts.
|
||||
timeout-minutes: 45
|
||||
run: meteor test --once --driver-package meteortesting:mocha
|
||||
env:
|
||||
TEST_WATCH: '0'
|
||||
@@ -63,8 +86,26 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-e2e-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-e2e-
|
||||
|
||||
- name: Install Meteor
|
||||
run: curl https://install.meteor.com/ | sh
|
||||
run: |
|
||||
if [ ! -x ~/.meteor/meteor ]; then
|
||||
curl https://install.meteor.com/ | sh
|
||||
fi
|
||||
echo "$HOME/.meteor" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install app npm dependencies
|
||||
run: npm ci
|
||||
@@ -82,13 +123,22 @@ jobs:
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_test \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run --settings settings.json &
|
||||
# Wait up to 120 s for WeKan to become available
|
||||
for i in $(seq 1 24); do
|
||||
curl -sf http://localhost:3000 > /dev/null && break
|
||||
echo "Waiting for WeKan... attempt $i"
|
||||
sleep 5
|
||||
meteor run --settings settings.json > wekan-run.log 2>&1 &
|
||||
# The first Meteor build is slow, so wait up to 20 minutes for the
|
||||
# server to start responding before giving up.
|
||||
for i in $(seq 1 120); do
|
||||
if curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan is up after $((i * 10))s"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for WeKan... attempt $i/120"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 200 log lines:"
|
||||
tail -n 200 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
@@ -142,8 +192,26 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-regression-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-regression-
|
||||
|
||||
- name: Install Meteor
|
||||
run: curl https://install.meteor.com/ | sh
|
||||
run: |
|
||||
if [ ! -x ~/.meteor/meteor ]; then
|
||||
curl https://install.meteor.com/ | sh
|
||||
fi
|
||||
echo "$HOME/.meteor" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -156,11 +224,21 @@ jobs:
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_regression \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run &
|
||||
for i in $(seq 1 24); do
|
||||
curl -sf http://localhost:3000 > /dev/null && break
|
||||
sleep 5
|
||||
meteor run > wekan-run.log 2>&1 &
|
||||
# The first Meteor build is slow, so wait up to 20 minutes.
|
||||
for i in $(seq 1 120); do
|
||||
if curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan is up after $((i * 10))s"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for WeKan... attempt $i/120"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 200 log lines:"
|
||||
tail -n 200 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
|
||||
+398
-252
@@ -1,40 +1,115 @@
|
||||
name: Release All Platforms
|
||||
|
||||
# Triggered manually via GitHub CLI or Actions UI.
|
||||
# Triggered manually via GitHub CLI or the Actions UI.
|
||||
# Usage:
|
||||
# gh workflow run release-all.yml -f old_version=8.42 -f new_version=8.43
|
||||
# gh workflow run release-all.yml -f old_version=9.35 -f new_version=9.36
|
||||
#
|
||||
# Or use the convenience script (commits pending changes, pushes, and triggers):
|
||||
# ./releases/release-all.sh 8.42 8.43
|
||||
# Or use the convenience script (bumps versions, commits, pushes, then triggers):
|
||||
# ./releases/release-all.sh 9.35 9.36
|
||||
#
|
||||
# Builds only the platforms MongoDB ships server binaries for (amd64, arm64,
|
||||
# win64, mac); MongoDB 7.x is the default database. s390x/ppc64le are not built
|
||||
# because MongoDB has no server binaries for them.
|
||||
#
|
||||
# This workflow does EVERYTHING for a release remotely (nothing is built or
|
||||
# pushed on the maintainer's machine), running jobs in parallel where possible:
|
||||
# - bump: runs releases/version.sh + releases/rebuild-docs.sh on the runner
|
||||
# (bumps Node/MongoDB/WeKan versions, rebuilds API docs), then
|
||||
# commits and pushes the bump to wekan/wekan main
|
||||
# - prepare: tags the bumped main as v<new_version> and extracts release notes
|
||||
# - website: updates the wekan/wekan.fi repo (install page + API docs) and pushes
|
||||
# - charts: updates and publishes the wekan/charts Helm chart and pushes
|
||||
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
|
||||
# modules for arm64, win64, mac-arm64 from that amd64 base
|
||||
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
|
||||
# uploads every wekan-<version>-<platform>.zip bundle
|
||||
# - docker: builds and pushes the multi-arch image to Docker Hub, Quay.io, GHCR
|
||||
# - snap: builds the snap on Launchpad (amd64 + arm64), pushes it to the Snap
|
||||
# Store and attaches the .snap files to the GitHub Release
|
||||
#
|
||||
# Required GitHub Secrets:
|
||||
# GHCR_AUTH - base64(username:token) for ghcr.io
|
||||
# DOCKERHUB_AUTH - base64(username:token) for Docker Hub
|
||||
# QUAY_AUTH - base64(username:token) for Quay.io
|
||||
# SNAP_AUTH - Snapcraft store credentials
|
||||
# GHCR_AUTH - base64("username:token") for ghcr.io
|
||||
# DOCKERHUB_AUTH - base64("username:token") for Docker Hub
|
||||
# QUAY_AUTH - base64("username:token") for Quay.io
|
||||
# SNAP_AUTH - Snap Store credentials (output of `snapcraft export-login`)
|
||||
# LP_CREDENTIALS - base64 of the Launchpad credentials file used by
|
||||
# `snapcraft remote-build` (see releases/snap-build.sh notes)
|
||||
# WEKAN_REPO_TOKEN - Personal Access Token with write access to wekan/wekan.fi
|
||||
# and wekan/charts, used by the website + charts jobs to push
|
||||
# to those repos (the built-in GITHUB_TOKEN only covers
|
||||
# wekan/wekan). See releases/create-github-secrets.sh.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
old_version:
|
||||
description: 'Previous version number (e.g. 8.42, without v prefix)'
|
||||
description: 'Previous version number (e.g. 9.35, without v prefix)'
|
||||
required: true
|
||||
type: string
|
||||
new_version:
|
||||
description: 'New version number (e.g. 8.43, without v prefix)'
|
||||
description: 'New version number (e.g. 9.36, without v prefix)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Node.js major version that Meteor 3.5-beta bundles. Used for native module
|
||||
# rebuilds on every platform — it must match the amd64 build's Node ABI.
|
||||
env:
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
|
||||
# ─── 1. Extract version and release notes ───────────────────────────────────
|
||||
# ─── 0. Bump versions + rebuild API docs, commit to main ────────────────────
|
||||
# Runs releases/version.sh (probes upstream for the newest Node.js/MongoDB,
|
||||
# bumps every version reference) and releases/rebuild-docs.sh (regenerates
|
||||
# public/api/wekan.yml + wekan.html), then commits and pushes to main so the
|
||||
# tag, builds, website and charts jobs all see the bumped sources.
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install version-bump and docs build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip curl wget
|
||||
|
||||
- name: Bump versions and rebuild API docs
|
||||
env:
|
||||
# Builds fetch dependencies from upstream themselves, so skip the slow
|
||||
# pre-download of Node/MongoDB tarballs that the local flow does.
|
||||
RELEASE_SKIP_DEP_DOWNLOAD: '1'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
./releases/version.sh "${{ inputs.old_version }}" "${{ inputs.new_version }}"
|
||||
./releases/rebuild-docs.sh "${{ inputs.new_version }}"
|
||||
|
||||
- name: Commit and push version bump
|
||||
run: |
|
||||
git add --all
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes to commit."
|
||||
else
|
||||
git commit -m "Bump versions for v${{ inputs.new_version }}"
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
# ─── 1. Tag the release and extract release notes ───────────────────────────
|
||||
prepare:
|
||||
needs: bump
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
@@ -42,29 +117,42 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set version from input
|
||||
- name: Normalise version from input
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
VERSION="${{ inputs.new_version }}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push tag v${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag v$VERSION already exists, leaving it as is."
|
||||
else
|
||||
git tag -a "v$VERSION" -m "v$VERSION"
|
||||
git push origin "v$VERSION"
|
||||
fi
|
||||
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
# Try to find the version-specific section first, then fall back to Upcoming
|
||||
# Prefer a version-specific "# v<version> ..." section, otherwise fall
|
||||
# back to the topmost "# Upcoming/Upcomig WeKan ® release" section.
|
||||
NOTES=$(python3 - <<PYEOF
|
||||
import re, sys
|
||||
version = "$VERSION"
|
||||
with open("CHANGELOG.md", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Pattern: section heading through (but not including) the next heading
|
||||
patterns = [
|
||||
rf"(# v{re.escape(version)} .*?)(?=\n# v[0-9]|\Z)",
|
||||
r"(# Upcoming WeKan.*?)(?=\n# v[0-9]|\Z)",
|
||||
r"(# Upcom\w* WeKan.*?)(?=\n# v[0-9]|\Z)",
|
||||
]
|
||||
for pat in patterns:
|
||||
m = re.search(pat, content, re.DOTALL)
|
||||
@@ -74,28 +162,98 @@ jobs:
|
||||
print(f"WeKan v{version}")
|
||||
PYEOF
|
||||
)
|
||||
echo "content<<GHEOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "GHEOF" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "content<<GHEOF"
|
||||
echo "$NOTES"
|
||||
echo "GHEOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ─── 1b. Update the wekan.fi website repo (parallel) ────────────────────────
|
||||
# Bumps the version/Meteor/Node/MongoDB strings on the install page and copies
|
||||
# the freshly rebuilt API docs into wekan.fi/api/v<new>, then commits + pushes
|
||||
# to wekan/wekan.fi. Runs as soon as `bump` has pushed the bumped sources.
|
||||
website:
|
||||
needs: bump
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (bumped main, with rebuilt API docs)
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan.fi website repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: wekan/wekan.fi
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
path: wekan.fi
|
||||
|
||||
- name: Update and push website
|
||||
env:
|
||||
WEBDIR: ${{ github.workspace }}/wekan.fi
|
||||
WEKANREPODIR: ${{ github.workspace }}/wekan
|
||||
RELEASE_PUSH_WEBSITE: '1'
|
||||
run: |
|
||||
cd wekan.fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
cd ..
|
||||
./wekan/releases/release-website.sh "${{ inputs.old_version }}" "${{ inputs.new_version }}"
|
||||
|
||||
# ─── 1c. Update and publish the Helm chart repo (parallel) ──────────────────
|
||||
# Bumps the WeKan version in wekan/charts, packages the chart and publishes it
|
||||
# to the gh-pages Helm index, then pushes both branches. Runs in parallel with
|
||||
# the platform builds; packaging the chart does not need the built artifacts.
|
||||
charts:
|
||||
needs: bump
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (for releases/release-charts.sh + version refs)
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan/charts repo (main + gh-pages)
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: wekan/charts
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
fetch-depth: 0
|
||||
path: charts
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
- name: Fetch gh-pages branch (release-charts.sh switches to it)
|
||||
run: |
|
||||
cd charts
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
git branch --track gh-pages origin/gh-pages 2>/dev/null || true
|
||||
|
||||
- name: Release and publish chart
|
||||
env:
|
||||
CHARTS_DIR: ${{ github.workspace }}/charts
|
||||
run: |
|
||||
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
|
||||
|
||||
# ─── 2. Build amd64 bundle (full Meteor 3 build) ────────────────────────────
|
||||
# Meteor 3.5-beta.7 installer supports only: Linux x86_64, Linux aarch64, macOS, Windows.
|
||||
# It explicitly rejects armhf, s390x, and ppc64le at install time.
|
||||
# Therefore the full Meteor build always runs here on x86_64.
|
||||
# All other Linux architectures then rebuild only the native npm modules
|
||||
# from this bundle (much faster than a full Meteor build).
|
||||
# Node.js version must match what Meteor 3.5-beta.7 bundles (detected automatically).
|
||||
# Meteor 3.5-beta installer supports only Linux x86_64/aarch64, macOS, Windows.
|
||||
# The full Meteor build always runs here on x86_64; every other platform then
|
||||
# only rebuilds the native npm modules from this bundle.
|
||||
build-amd64:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/v${{ inputs.new_version }}
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
@@ -115,11 +273,9 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
|
||||
|
||||
# Pin to detected latest Node.js 24.x — must match Meteor's bundled version.
|
||||
# Using the same version for build and npm rebuild ensures consistent native module ABI.
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ needs.prepare.outputs.version }}'
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Meteor
|
||||
run: |
|
||||
@@ -134,6 +290,11 @@ jobs:
|
||||
(meteor update --npm 2>/dev/null || true) && meteor npm install
|
||||
meteor build .build --directory
|
||||
|
||||
- name: Install server npm modules
|
||||
run: |
|
||||
cd .build/bundle/programs/server
|
||||
npm install
|
||||
|
||||
- name: Create amd64 zip
|
||||
run: |
|
||||
cd .build
|
||||
@@ -146,30 +307,18 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
# ─── 3. Build arm64 bundle (rebuild native modules natively) ─────────────────
|
||||
# Meteor 3.5-beta.7 does support Linux aarch64, but running a full Meteor build a
|
||||
# second time would double the build time for the same result. npm rebuild on
|
||||
# the native arm64 GitHub Actions runner is much faster and produces the same
|
||||
# platform-specific native modules.
|
||||
build-arm64:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION_ARM64: ''
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl unzip zip
|
||||
|
||||
|
||||
- name: Set Node.js version for arm64
|
||||
id: node_arm64
|
||||
run: |
|
||||
echo "NODE_VERSION_ARM64=24" >> $GITHUB_ENV
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -192,14 +341,13 @@ jobs:
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
# Use the latest available Node.js 24.x slim image for arm64
|
||||
docker run --rm --platform linux/arm64 \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:24-slim \
|
||||
node:${NODE_VERSION}-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Clean up and create arm64 zip
|
||||
- name: Create arm64 zip
|
||||
run: |
|
||||
zip -r wekan-${VERSION}-arm64.zip bundle
|
||||
|
||||
@@ -209,195 +357,6 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 4. Build s390x bundle (rebuild native modules via QEMU container) ───────
|
||||
# Meteor 3.5-beta.7 does NOT support s390x (installer exits with error on that arch).
|
||||
# GitHub Actions has no native s390x runners.
|
||||
# Solution: QEMU emulation + node:<version>-slim (s390x variant) for npm rebuild.
|
||||
build-s390x:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION_S390X: ''
|
||||
steps:
|
||||
- name: Set up QEMU for s390x
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: s390x
|
||||
|
||||
- name: Install unzip/zip
|
||||
run: sudo apt-get update && sudo apt-get install -y unzip zip
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Set Node.js version for s390x
|
||||
id: node_s390x
|
||||
run: |
|
||||
echo "NODE_VERSION_S390X=24" >> $GITHUB_ENV
|
||||
|
||||
- name: Rebuild native modules for s390x in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
rm -rf bundle/programs/server/node_modules
|
||||
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
|
||||
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
if [ -d "$BCRYPT_DIR" ]; then
|
||||
rm -rf "$BCRYPT_DIR"
|
||||
TMP=$(mktemp -d)
|
||||
pushd "$TMP" > /dev/null
|
||||
npm pack bcrypt@6.0.0
|
||||
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
cp -a bcrypt "$BCRYPT_DIR"
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
# Use the latest available Node.js 24.x slim image for s390x
|
||||
docker run --rm --platform linux/s390x \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:24-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Clean up and create s390x zip
|
||||
run: |
|
||||
zip -r wekan-${VERSION}-s390x.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bundle-s390x
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-s390x.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 4b. Build ppc64le bundle (rebuild native modules via QEMU container) ────
|
||||
# Meteor 3.5-beta.7 does NOT support ppc64le (installer exits with error on that arch).
|
||||
# GitHub Actions has no native ppc64le runners.
|
||||
# Solution: QEMU emulation + node:<version>-slim (ppc64le variant) for npm rebuild.
|
||||
build-ppc64le:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION_PPC64LE: ''
|
||||
steps:
|
||||
- name: Set up QEMU for ppc64le
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: ppc64le
|
||||
|
||||
- name: Install unzip/zip
|
||||
run: sudo apt-get update && sudo apt-get install -y unzip zip
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Set Node.js version for ppc64le
|
||||
id: node_ppc64le
|
||||
run: |
|
||||
echo "NODE_VERSION_PPC64LE=24" >> $GITHUB_ENV
|
||||
|
||||
- name: Rebuild native modules for ppc64le in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
rm -rf bundle/programs/server/node_modules
|
||||
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
|
||||
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
if [ -d "$BCRYPT_DIR" ]; then
|
||||
rm -rf "$BCRYPT_DIR"
|
||||
TMP=$(mktemp -d)
|
||||
pushd "$TMP" > /dev/null
|
||||
npm pack bcrypt@6.0.0
|
||||
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
cp -a bcrypt "$BCRYPT_DIR"
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
# Use the latest available Node.js 24.x slim image for ppc64le
|
||||
docker run --rm --platform linux/ppc64le \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:24-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Clean up and create ppc64le zip
|
||||
run: |
|
||||
zip -r wekan-${VERSION}-ppc64le.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bundle-ppc64le
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-ppc64le.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 4c. Build armhf bundle — COMMENTED OUT ─────────────────────────────────
|
||||
# No official Node.js binaries exist for armhf (arm/v7).
|
||||
#build-armhf:
|
||||
# needs: [prepare, build-amd64]
|
||||
# runs-on: ubuntu-latest
|
||||
# timeout-minutes: 120
|
||||
# env:
|
||||
# VERSION: ${{ needs.prepare.outputs.version }}
|
||||
# steps:
|
||||
# - name: Set up QEMU for arm/v7
|
||||
# uses: docker/setup-qemu-action@v4
|
||||
# with:
|
||||
# platforms: arm
|
||||
#
|
||||
# - name: Install unzip/zip
|
||||
# run: sudo apt-get update && sudo apt-get install -y unzip zip
|
||||
#
|
||||
# - name: Download amd64 bundle
|
||||
# uses: actions/download-artifact@v8
|
||||
# with:
|
||||
# name: bundle-amd64
|
||||
#
|
||||
# - name: Rebuild native modules for armhf in container
|
||||
# run: |
|
||||
# unzip wekan-${VERSION}-amd64.zip
|
||||
# rm -rf bundle/programs/server/node_modules
|
||||
# # Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
# # which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
|
||||
# BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
# if [ -d "$BCRYPT_DIR" ]; then
|
||||
# rm -rf "$BCRYPT_DIR"
|
||||
# TMP=$(mktemp -d)
|
||||
# pushd "$TMP" > /dev/null
|
||||
# npm pack bcrypt@6.0.0
|
||||
# mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
# cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
# cp -a bcrypt "$BCRYPT_DIR"
|
||||
# popd > /dev/null
|
||||
# rm -rf "$TMP"
|
||||
# fi
|
||||
# # node:<version>-slim arm/v7 — same Node.js version as Meteor bundled
|
||||
# docker run --rm --platform linux/arm/v7 \
|
||||
# -v "$(pwd)/bundle:/bundle" \
|
||||
# -w /bundle/programs/server \
|
||||
# node:${NODE_VERSION}-slim \
|
||||
# sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
#
|
||||
# - name: Clean up and create armhf zip
|
||||
# run: |
|
||||
# zip -r wekan-${VERSION}-armhf.zip bundle
|
||||
#
|
||||
# - uses: actions/upload-artifact@v7
|
||||
# with:
|
||||
# name: bundle-armhf
|
||||
# path: wekan-${{ needs.prepare.outputs.version }}-armhf.zip
|
||||
# retention-days: 1
|
||||
|
||||
# ─── 5. Build win64 bundle (rebuild native modules on Windows) ───────────────
|
||||
build-win64:
|
||||
needs: [prepare, build-amd64]
|
||||
@@ -406,14 +365,17 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
# Checked out into src/ so start-wekan.bat and snapcraft.yaml (which pins
|
||||
# the MongoDB tools/mongosh versions) are available; the downloaded bundle
|
||||
# is extracted alongside it in the workspace root.
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
path: src
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ needs.prepare.outputs.version }}'
|
||||
|
||||
## With Node 24 these are already preinstalled at GitHub Windows runners
|
||||
#- name: Install node-gyp and build tools
|
||||
# run: |
|
||||
# npm install -g windows-build-tools --vs2019 2>$null; exit 0
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install 7-zip
|
||||
run: choco install -y 7zip
|
||||
@@ -443,12 +405,45 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Rebuild native modules for Windows
|
||||
shell: cmd
|
||||
shell: bash
|
||||
run: |
|
||||
cd bundle\programs\server
|
||||
rmdir /s /q node_modules
|
||||
cd bundle/programs/server
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
cd ..\..\..
|
||||
|
||||
- name: Add MongoDB tools, mongosh and start-wekan.bat to the bundle
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Use the same MongoDB tools/mongosh versions as the snap, which
|
||||
# releases/version.sh keeps at the newest release for each bundle.
|
||||
TOOLS_VER=$(grep -oE 'mongodb-database-tools-ubuntu2204-\$\{TOOLS_ARCH\}-[0-9]+\.[0-9]+\.[0-9]+' src/snapcraft.yaml \
|
||||
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
MONGOSH_VER=$(grep -oE 'mongosh-[0-9]+\.[0-9]+\.[0-9]+-linux' src/snapcraft.yaml \
|
||||
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
echo "MongoDB Database Tools: $TOOLS_VER mongosh: $MONGOSH_VER"
|
||||
|
||||
# MongoDB Database Tools (Windows): bsondump, mongodump, mongoexport,
|
||||
# mongofiles, mongoimport, mongorestore, mongostat, mongotop.
|
||||
TOOLS_DIR="mongodb-database-tools-windows-x86_64-${TOOLS_VER}"
|
||||
curl -fL -o tools.zip \
|
||||
"https://fastdl.mongodb.org/tools/db/${TOOLS_DIR}.zip"
|
||||
7z x -y tools.zip > /dev/null
|
||||
cp "${TOOLS_DIR}/bin/"*.exe bundle/
|
||||
|
||||
# mongosh (Windows): mongosh.exe + mongosh_crypt_v1.dll.
|
||||
MONGOSH_DIR="mongosh-${MONGOSH_VER}-win32-x64"
|
||||
curl -fL -o mongosh.zip \
|
||||
"https://downloads.mongodb.com/compass/${MONGOSH_DIR}.zip"
|
||||
7z x -y mongosh.zip > /dev/null
|
||||
cp "${MONGOSH_DIR}/bin/mongosh.exe" bundle/
|
||||
cp "${MONGOSH_DIR}/bin/mongosh_crypt_v1.dll" bundle/
|
||||
|
||||
# Windows launcher from the repo root.
|
||||
cp src/start-wekan.bat bundle/
|
||||
|
||||
echo "=== bundle contents ==="
|
||||
ls -1 bundle/
|
||||
|
||||
- name: Create win64 zip
|
||||
shell: cmd
|
||||
@@ -460,9 +455,62 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 6. Create GitHub Release and upload all bundles ────────────────────────
|
||||
# ─── 6. Build mac-arm64 bundle (rebuild native modules on Apple Silicon) ─────
|
||||
build-mac-arm64:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: macos-14 # macos-14 runners are Apple Silicon (arm64)
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Rebuild native modules for macOS arm64
|
||||
run: |
|
||||
unzip -q wekan-${VERSION}-amd64.zip
|
||||
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
|
||||
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
if [ -d "$BCRYPT_DIR" ]; then
|
||||
rm -rf "$BCRYPT_DIR"
|
||||
TMP=$(mktemp -d)
|
||||
pushd "$TMP" > /dev/null
|
||||
npm pack bcrypt@6.0.0
|
||||
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
cp -a bcrypt "$BCRYPT_DIR"
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
cd bundle/programs/server
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
cd ../../..
|
||||
|
||||
- name: Create mac-arm64 zip
|
||||
run: zip -r wekan-${VERSION}-mac-arm64.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bundle-mac-arm64
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
|
||||
release:
|
||||
needs: [prepare, build-amd64, build-arm64, build-s390x, build-ppc64le, build-win64]
|
||||
needs:
|
||||
- prepare
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
- build-win64
|
||||
- build-mac-arm64
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -482,6 +530,104 @@ jobs:
|
||||
files: |
|
||||
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-s390x.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-ppc64le.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
|
||||
|
||||
# ─── 8. Build and push the multi-arch Docker image to all registries ────────
|
||||
# Mirrors releases/docker-build.sh step 3 (multi-arch buildx --push).
|
||||
# Needs `release` because the Dockerfile downloads wekan-<version>-<arch>.zip
|
||||
# from the GitHub Release; building before those assets exist 404s and (without
|
||||
# set -e in the Dockerfile) shipped a broken image missing /build/main.js.
|
||||
docker:
|
||||
needs: [prepare, release]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub, Quay.io and GHCR
|
||||
env:
|
||||
DOCKERHUB_AUTH: ${{ secrets.DOCKERHUB_AUTH }}
|
||||
QUAY_AUTH: ${{ secrets.QUAY_AUTH }}
|
||||
GHCR_AUTH: ${{ secrets.GHCR_AUTH }}
|
||||
run: |
|
||||
login() {
|
||||
# $1 = registry host, $2 = base64("user:token")
|
||||
local host="$1" creds user token
|
||||
creds="$(printf '%s' "$2" | base64 -d)"
|
||||
user="${creds%%:*}"
|
||||
token="${creds#*:}"
|
||||
printf '%s' "$token" | docker login "$host" -u "$user" --password-stdin
|
||||
}
|
||||
login docker.io "$DOCKERHUB_AUTH"
|
||||
login quay.io "$QUAY_AUTH"
|
||||
login ghcr.io "$GHCR_AUTH"
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t wekanteam/wekan:v${VERSION} \
|
||||
-t wekanteam/wekan:latest \
|
||||
-t quay.io/wekan/wekan:v${VERSION} \
|
||||
-t quay.io/wekan/wekan:latest \
|
||||
-t ghcr.io/wekan/wekan:v${VERSION} \
|
||||
-t ghcr.io/wekan/wekan:latest \
|
||||
--push \
|
||||
.
|
||||
|
||||
# ─── 9. Build the snap on Launchpad, push to the store, attach to release ────
|
||||
# The snap's `wekan` part downloads wekan-<version>-<arch>.zip from the GitHub
|
||||
# Release, so this job runs only after `release` has published those assets.
|
||||
# `snapcraft remote-build` builds every platform listed in snapcraft.yaml
|
||||
# (amd64 + arm64) on Launchpad.
|
||||
snap:
|
||||
needs: [prepare, release]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
# remote-build (Launchpad) rejects shallow clones, so fetch full history
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
- name: Provide Launchpad credentials for remote-build
|
||||
env:
|
||||
LP_CREDENTIALS: ${{ secrets.LP_CREDENTIALS }}
|
||||
run: |
|
||||
mkdir -p ~/.local/share/snapcraft
|
||||
printf '%s' "$LP_CREDENTIALS" | base64 -d > ~/.local/share/snapcraft/launchpad-credentials
|
||||
chmod 600 ~/.local/share/snapcraft/launchpad-credentials
|
||||
|
||||
- name: Build snaps on Launchpad (amd64 + arm64)
|
||||
run: |
|
||||
snapcraft remote-build --launchpad-accept-public-upload
|
||||
|
||||
- name: Push snaps to the Snap Store (edge + beta channels)
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
|
||||
run: |
|
||||
for f in wekan_${VERSION}_*.snap; do
|
||||
echo "Uploading $f to the Snap Store (edge,beta)..."
|
||||
snapcraft upload --release=edge,beta "$f"
|
||||
done
|
||||
|
||||
- name: Attach snaps to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "v${VERSION}" wekan_${VERSION}_*.snap --clobber
|
||||
@@ -1,4 +1,5 @@
|
||||
*~
|
||||
/*.log
|
||||
*.sw*
|
||||
.meteor-spk
|
||||
*.sublime-workspace
|
||||
|
||||
+7
-6
@@ -16,10 +16,11 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.4.0-beta350.10
|
||||
mongo@2.4.0-rc350.1
|
||||
|
||||
# Account system
|
||||
accounts-password@3.3.0-beta350.10
|
||||
accounts-password@3.3.0-rc350.1
|
||||
accounts-express@1.0.0-rc350.1
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
communitypackages:flow-routing
|
||||
@@ -36,7 +37,7 @@ wekan-accounts-oidc
|
||||
check@1.5.0
|
||||
random@1.2.2
|
||||
reactive-dict@1.3.2
|
||||
session@1.2.3-beta350.10
|
||||
session@1.2.3-rc350.1
|
||||
tracker@1.3.4
|
||||
audit-argument-checks@1.0.8
|
||||
|
||||
@@ -44,7 +45,7 @@ audit-argument-checks@1.0.8
|
||||
ostrio:i18n
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.0
|
||||
email@3.2.0-beta350.10
|
||||
email@3.2.0-rc350.1
|
||||
dynamic-import@0.7.4
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
@@ -54,8 +55,8 @@ ostrio:files
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration@1.3.5
|
||||
minifier-css@2.0.2-beta350.10
|
||||
ejson@1.2.0-beta350.10
|
||||
minifier-css@2.0.2-rc350.1
|
||||
ejson@1.2.0-rc350.1
|
||||
logging@1.3.6
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.5-beta.10
|
||||
METEOR@3.5-rc.1
|
||||
+28
-27
@@ -1,7 +1,8 @@
|
||||
accounts-base@3.3.0-beta350.10
|
||||
accounts-oauth@1.4.7-beta350.10
|
||||
accounts-password@3.3.0-beta350.10
|
||||
aldeed:collection2@4.1.5
|
||||
accounts-base@3.3.0-rc350.1
|
||||
accounts-express@1.0.0-rc350.1
|
||||
accounts-oauth@1.4.7-rc350.1
|
||||
accounts-password@3.3.0-rc350.1
|
||||
aldeed:collection2@4.2.0
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.0
|
||||
@@ -9,35 +10,35 @@ audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.1
|
||||
babel-compiler@7.14.0
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.14-beta350.10
|
||||
binary-heap@1.0.13-beta350.10
|
||||
base64@1.0.14-rc350.1
|
||||
binary-heap@1.0.13-rc350.1
|
||||
blaze@3.0.3
|
||||
blaze-tools@2.0.1
|
||||
boilerplate-generator@2.1.0
|
||||
caching-compiler@2.0.1
|
||||
caching-html-compiler@2.0.1
|
||||
callback-hook@1.8.0-beta350.10
|
||||
callback-hook@1.8.0-rc350.1
|
||||
check@1.5.0
|
||||
communitypackages:core@2.0.3
|
||||
communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.2
|
||||
ddp-client@3.3.0-beta350.10
|
||||
ddp-common@1.4.5-beta350.10
|
||||
ddp-rate-limiter@1.3.0-beta350.10
|
||||
ddp-server@3.3.0-beta350.10
|
||||
ddp-client@3.3.0-rc350.1
|
||||
ddp-common@1.4.5-rc350.1
|
||||
ddp-rate-limiter@1.3.0-rc350.1
|
||||
ddp-server@3.3.0-rc350.1
|
||||
diff-sequence@1.1.3
|
||||
dynamic-import@0.7.4
|
||||
ecmascript@0.18.0
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.2.0-beta350.10
|
||||
email@3.2.0-beta350.10
|
||||
ejson@1.2.0-rc350.1
|
||||
email@3.2.0-rc350.1
|
||||
es5-shim@4.8.1
|
||||
facts-base@1.0.2
|
||||
fetch@0.2.0-beta350.10
|
||||
fetch@0.2.0-rc350.1
|
||||
geojson-utils@1.0.12
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.1
|
||||
@@ -52,36 +53,36 @@ lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.6
|
||||
matb33:collection-hooks@2.1.0
|
||||
meteor@2.3.1-beta350.10
|
||||
meteor@2.3.1-rc350.1
|
||||
meteor-base@1.5.2
|
||||
meteortesting:browser-tests@1.7.0
|
||||
meteortesting:mocha@3.2.0
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.3.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2-beta350.10
|
||||
minifier-css@2.0.2-rc350.1
|
||||
minifier-js@3.2.0
|
||||
minimongo@2.2.0-beta350.10
|
||||
minimongo@2.2.0-rc350.1
|
||||
modern-browsers@0.2.3
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.4.0-beta350.10
|
||||
mongo@2.4.0-rc350.1
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.9
|
||||
npm-mongo@6.16.1
|
||||
oauth@3.0.4-beta.10
|
||||
oauth@3.0.4-rc350.1
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.1
|
||||
ordered-dict@1.2.0
|
||||
ostrio:cookies@2.9.2
|
||||
ostrio:cstorage@4.0.1
|
||||
ostrio:files@3.0.1
|
||||
ostrio:flow-router-extra@3.14.0
|
||||
ostrio:flow-router-extra@3.15.3
|
||||
ostrio:i18n@3.2.1
|
||||
promise@1.0.0
|
||||
quave:synced-cron@2.4.0
|
||||
raix:eventemitter@1.0.0
|
||||
random@1.2.2
|
||||
rate-limit@1.2.0-beta350.10
|
||||
rate-limit@1.2.0-rc350.1
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.2
|
||||
reactive-var@1.0.13
|
||||
@@ -91,10 +92,10 @@ reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.2
|
||||
rspack@1.1.0
|
||||
service-configuration@1.3.5
|
||||
session@1.2.3-beta350.10
|
||||
session@1.2.3-rc350.1
|
||||
sha@1.0.10
|
||||
shell-server@0.7.0
|
||||
socket-stream-client@0.7.0-beta350.10
|
||||
socket-stream-client@0.7.0-rc350.1
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.2.0
|
||||
@@ -105,8 +106,8 @@ templating-tools@2.0.1
|
||||
tools-core@1.1.0
|
||||
tracker@1.3.4
|
||||
typescript@5.10.0
|
||||
url@1.3.6-beta350.10
|
||||
webapp@2.2.0-beta350.10
|
||||
url@1.3.6-rc350.1
|
||||
webapp@2.2.0-rc350.1
|
||||
webapp-hashing@1.1.2
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
|
||||
+1366
-9
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -3,9 +3,14 @@
|
||||
For all code at WeKan GitHub Organization https://github.com/wekan
|
||||
|
||||
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become MIT license.
|
||||
- All code xet7 add is MIT license.
|
||||
- For any dependencies, permissive licenses like https://copyfree.org are preferred
|
||||
- For anything currently that is non-permissive (like GPL, AGPL, SSPL), those will be replaced with permissive-licensed alternatives
|
||||
- Any intelligence allowed: Human, AI or any other. No discrimination, please have world peace. Mentioning participants optional.
|
||||
- All code is checked and validated by xet7 and many different security AI. If code is real working fix or feature, it usually will be merged.
|
||||
- All known vulnerabilities have been fixed at newest WeKan, please upgrade https://wekan.fi/upgrade/
|
||||
- There is no known backdoors. If WeKan is installed by someone else, first registered user is Admin and has Admin Panel access.
|
||||
- Maintainer of WeKan has no access to someone elses installed WeKan.
|
||||
- For someone elses installed WeKan, see [Forgot Password](https://github.com/wekan/wekan/blob/main/docs/Login/Forgot-Password.md)
|
||||
|
||||
# Reporting about violations or something else
|
||||
|
||||
|
||||
+10
-4
@@ -34,6 +34,16 @@ and click send PR.
|
||||
|
||||
## Contributing code
|
||||
|
||||
Use rebuild-wekan.sh to run all tests to verify you don't break any features, before sending PR.
|
||||
|
||||
Only send one feature or fix at one PR. Do not add many unrelated changes.
|
||||
|
||||
Only send those changed code lines, that are related to your one feature or fix.
|
||||
|
||||
Do not format unrelated code. Do not use code formatting or linting like eslist or prettier.
|
||||
|
||||
Send your code ASAP, because there are daily releases of WeKan with major features and fixes added.
|
||||
|
||||
[Building WeKan and sending PR](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md).
|
||||
|
||||
WeKan code contributors Hall of Fame is at ChangeLog, where
|
||||
@@ -49,10 +59,6 @@ Also see other docs:
|
||||
|
||||
https://github.com/wekan/wekan/tree/main/docs/DeveloperDocs
|
||||
|
||||
Do not use code formatting or linting like eslist or prettier.
|
||||
|
||||
Only send minimal changed code lines, that are related to feature or fix.
|
||||
|
||||
WeKan code has MIT license.
|
||||
|
||||
About 300 persons have contributed to WeKan, stats at:
|
||||
|
||||
+15
-7
@@ -1,21 +1,21 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:26.04
|
||||
LABEL maintainer="wekan"
|
||||
LABEL org.opencontainers.image.ref.name="ubuntu"
|
||||
LABEL org.opencontainers.image.version="24.04"
|
||||
LABEL org.opencontainers.image.version="26.04"
|
||||
LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=9.23
|
||||
ARG VERSION=9.42
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v24.15.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.10 \
|
||||
NODE_VERSION=v24.16.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.12 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=11.11.0 \
|
||||
SRC_PATH=./ \
|
||||
@@ -160,9 +160,14 @@ ENV \
|
||||
|
||||
RUN <<EOR
|
||||
set -o xtrace
|
||||
# Fail hard on any error so a missing release zip / failed download can never
|
||||
# produce a "successful" image with an empty /build (Cannot find /build/main.js).
|
||||
set -eo pipefail
|
||||
|
||||
# Create Wekan user
|
||||
useradd --user-group --system --home-dir /home/wekan wekan
|
||||
# Create Wekan user. --create-home is required because --system users do not
|
||||
# get a home directory by default; without it /home/wekan never exists and the
|
||||
# later `chown ... /home/wekan/` aborts the build (now that set -e is active).
|
||||
useradd --user-group --system --create-home --home-dir /home/wekan wekan
|
||||
|
||||
# OS Updates
|
||||
apt-get update --assume-yes
|
||||
@@ -210,6 +215,9 @@ mv /home/wekan/app/bundle /build
|
||||
mv $(which tar)~ $(which tar)
|
||||
|
||||
# Cleanup
|
||||
# Remove unused Go-based pebble binary shipped by base image to reduce CVE surface.
|
||||
apt-get remove --purge --assume-yes pebble || true
|
||||
rm -f /usr/bin/pebble
|
||||
apt-get remove --purge --assume-yes ${BUILD_DEPS}
|
||||
apt-get autoremove --assume-yes
|
||||
apt-get clean --assume-yes
|
||||
|
||||
+29
-31
@@ -7,9 +7,11 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
|
||||
### 1. Security Researcher:
|
||||
|
||||
- **Report**: Please email details regarding security vulnerabilities to **security@wekan.fi**.
|
||||
- **Report**: Click "New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
More info at [GitHub Docs about reporting privately](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately).
|
||||
- **Proof of Concept (PoC)**: If possible, include a fix or a reproduction script/code.
|
||||
- **Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
|
||||
- **If GitHub does not work**: Please send report to **security@wekan.fi**
|
||||
- **Optional Email Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
|
||||
|
||||
### 2. WeKan Security Team:
|
||||
|
||||
@@ -17,17 +19,14 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
Fixes are announced at the top of the [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- **Recognition**: We will acknowledge your contribution by adding you to our
|
||||
[Hall of Fame](https://wekan.fi/hall-of-fame/).
|
||||
- **CVE Policy**: WeKan Security does not facilitate **CVE Coordination** due to
|
||||
our rapid release cycle (new versions are released almost daily).
|
||||
- **CVE Policy**: WeKan Security requests CVE at GitHub when releasing Security Advisory.
|
||||
|
||||
### 3. Post-Release and Public Disclosure:
|
||||
|
||||
- **Independent CVE Assignment**: After the fix is public, you are welcome to coordinate
|
||||
**CVE assignment** independently. Please send the assigned CVE IDs to **security@wekan.fi**,
|
||||
and we will update your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/).
|
||||
- Publicity: If you publish blog posts, give talks, or write articles about the
|
||||
reported vulnerabilities, please send the links to us. We will link them
|
||||
to your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/) for additional context.
|
||||
- Advisories reported at GitHub are listed at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
|
||||
## How Should Reports Be Formatted?
|
||||
|
||||
@@ -51,34 +50,34 @@ added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
|
||||
|
||||
## Which domains are in scope?
|
||||
|
||||
No public domains, because all those are donated to Wekan Open Source project,
|
||||
No public domains, because all those are donated to WeKan Open Source project,
|
||||
and we don't have any permissions to do security scans on those donated servers.
|
||||
|
||||
Please don't perform research that could impact other users. Second, please keep
|
||||
the reports short and succinct. If we fail to understand the logic of your bug, we will tell you.
|
||||
|
||||
You can [Install Wekan](https://wekan.fi/install/) on your own computer
|
||||
You can [Install WeKan](https://wekan.fi/install/) on your own computer
|
||||
and scan it's vulnerabilities there.
|
||||
|
||||
## About Wekan Versions
|
||||
## About WeKan Versions
|
||||
|
||||
There are 2 versions of Wekan: Standalone Wekan, and Sandstorm Wekan.
|
||||
There are 2 versions of WeKan: Standalone WeKan, and Sandstorm WeKan.
|
||||
|
||||
## Meteor Security
|
||||
|
||||
https://guide.meteor.com/security
|
||||
|
||||
### Standalone Wekan Security
|
||||
### Standalone WeKan Security
|
||||
|
||||
Standalone Wekan includes all non-Sandstorm platforms. Some Standalone Wekan platforms
|
||||
Standalone WeKan includes all non-Sandstorm platforms. Some Standalone WeKan platforms
|
||||
like Snap and Docker have their own specific sandboxing etc features.
|
||||
|
||||
Standalone Wekan by default does not load any files from Internet, like fonts, CSS, etc.
|
||||
This also means all Standalone Wekan functionality works in offline local networks.
|
||||
Standalone WeKan by default does not load any files from Internet, like fonts, CSS, etc.
|
||||
This also means all Standalone WeKan functionality works in offline local networks.
|
||||
WeKan is used in most countries of the world https://snapcraft.io/wekan
|
||||
and by companies that have 30k users.
|
||||
|
||||
- Wekan private board attachments are not accessible without logging in.
|
||||
- WeKan private board attachments are not accessible without logging in.
|
||||
- There is feature to set board public, so that board is visible without logging
|
||||
in readonly mode, with realtime updates.
|
||||
- Admin Panel has feature to disable all public boards, so all boards are private.
|
||||
@@ -92,16 +91,16 @@ and by companies that have 30k users.
|
||||
|
||||
## XSS
|
||||
|
||||
- DOMpurify https://www.npmjs.com/package/dompurify
|
||||
- WeKan uses DOMpurify npm package to filter for XSS at fields like cards, as you can see from
|
||||
- DOMPurify https://www.npmjs.com/package/dompurify
|
||||
- WeKan uses DOMPurify npm package to filter for XSS at fields like cards, as you can see from
|
||||
[package.json](https://github.com/wekan/wekan/blob/main/package.json). Other used versions can be seen from
|
||||
[Meteor versions file](https://github.com/wekan/wekan/blob/main/.meteor/versions).
|
||||
- Forms can include markdown links, html, image tags etc like you see at https://wekan.github.io .
|
||||
- Forms can include markdown links, html, image tags etc like you see at https://wekan.fi .
|
||||
- It's possible to add attachments to cards, and markdown/html links to files.
|
||||
- Dompurify cleans up viewed code, so JavaScript in input fields does not execute
|
||||
- https://wekan.github.io/hall-of-fame/fieldbleed/
|
||||
- DOMPurify cleans up viewed code, so JavaScript in input fields does not execute
|
||||
- https://wekan.fi/hall-of-fame/fieldbleed/
|
||||
- Reaction in comment is now checked, that it does not have extra added code
|
||||
- https://wekan.github.io/hall-of-fame/reactionbleed/
|
||||
- https://wekan.fi/hall-of-fame/reactionbleed/
|
||||
- https://github.com/wekan/wekan/blob/main/packages/markdown/src/template-integration.js#L76
|
||||
|
||||
## QA About PubSub
|
||||
@@ -131,7 +130,7 @@ A:
|
||||
PubSub/websocket data (for example with Burp Suite Community Edition), etc.
|
||||
- If you have a REST API, also check that only those who have a login token and permission can view or edit text.
|
||||
- You should not include any data user is not allowed to see. Not to webpage text, not to websockets/PubSub, etc.
|
||||
- Minimongo should not have password hashes PubSub https://wekan.github.io/hall-of-fame/userbleed/
|
||||
- Minimongo should not have password hashes PubSub https://wekan.fi/hall-of-fame/userbleed/
|
||||
- PubSub uses WebSockets, so you need those to be enabled on the webserver like Caddy/Nginx/Apache etc, examples of settings
|
||||
at right menu of https://github.com/wekan/wekan/wiki
|
||||
- Clientside https://github.com/wekan/wekan/tree/main/client/components subscribes to
|
||||
@@ -155,7 +154,7 @@ A:
|
||||
|
||||
- With PubSub, there is checking, that someone modifying Websockets content, like permission isAdmin, can not change to Admin.
|
||||
- https://github.com/wekan/wekan/commit/cbad4cf5943d47b916f64b4582f8ca76a9dfd743
|
||||
- https://wekan.github.io/hall-of-fame/adminbleed/
|
||||
- https://wekan.fi/hall-of-fame/adminbleed/
|
||||
|
||||
## Permissions and Roles
|
||||
|
||||
@@ -191,12 +190,12 @@ Meteor.startup(() => {
|
||||
|
||||
- Someone reported that it was a problem that the content of HTML comments in edit mode was not visible in view mode, so this change makes HTML comments visible.
|
||||
- https://github.com/wekan/wekan/commit/167863d95711249e69bb3511175d73b34acbbdb3
|
||||
- https://wekan.github.io/hall-of-fame/invisiblebleed/
|
||||
- https://wekan.fi/hall-of-fame/invisiblebleed/
|
||||
|
||||
## Attachments: XSS in filename is sanitized
|
||||
|
||||
- https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- https://wekan.github.io/hall-of-fame/filebleed/
|
||||
- https://wekan.fi/hall-of-fame/filebleed/
|
||||
|
||||
### Attachments: Forced download to prevent stored XSS
|
||||
|
||||
@@ -254,7 +253,7 @@ Meteor.startup(() => {
|
||||
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d
|
||||
- https://github.com/wekan/wekan/tree/main/packages/wekan-accounts-lockout
|
||||
|
||||
### Sandstorm Wekan Security
|
||||
### Sandstorm WeKan Security
|
||||
|
||||
On the Sandstorm platform, Standalone WeKan features like the Admin Panel are turned off using environment variables, because Sandstorm platform provides SSO for all apps running on Sandstorm.
|
||||
|
||||
@@ -267,7 +266,7 @@ Also read [Sandstorm Security Practices](https://docs.sandstorm.io/en/latest/usi
|
||||
[Sandstorm Security non-events](https://docs.sandstorm.io/en/latest/using/security-non-events/).
|
||||
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
|
||||
|
||||
## What Wekan bugs are eligible?
|
||||
## What WeKan bugs are eligible?
|
||||
|
||||
Any typical web security bugs. If any of the previously mentioned is somehow problematic and
|
||||
a security issue, we'd like to know about it, and also how to fix it:
|
||||
@@ -279,7 +278,7 @@ a security issue, we'd like to know about it, and also how to fix it:
|
||||
- Authentication bypass
|
||||
- Server-side code execution
|
||||
|
||||
## What Wekan bugs are NOT eligible?
|
||||
## What WeKan bugs are NOT eligible?
|
||||
|
||||
Typical already-known or 'no impact' bugs such as:
|
||||
|
||||
@@ -287,5 +286,4 @@ Typical already-known or 'no impact' bugs such as:
|
||||
- Denial of service
|
||||
- SSL BEAST/CRIME/etc. WeKan does not have SSL built-in; it uses Caddy/Nginx/Apache on the front end.
|
||||
|
||||
Wekan is Open Source with MIT license, and free to use also for commercial use.
|
||||
We welcome all fixes to improve security by email to security@wekan.fi
|
||||
WeKan is Open Source with MIT license, and free to use also for commercial use.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v9.23.0"
|
||||
appVersion: "v9.42.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -25,19 +25,44 @@ syntax = """=== Wekan API Python CLI: Shows IDs for addcard ===
|
||||
# AUTHORID is USERID that writes card or custom field.
|
||||
If *nix: chmod +x api.py => ./api.py users
|
||||
Syntax:
|
||||
User API:
|
||||
User API:
|
||||
python3 api.py user # Current user and list of current user boards
|
||||
python3 api.py users # All users
|
||||
python3 api.py boards # All Public Boards
|
||||
python3 api.py boards USERID # Boards of USERID
|
||||
python3 api.py board BOARDID # Info of BOARDID
|
||||
|
||||
Board/List/Card API:
|
||||
python3 api.py swimlanes BOARDID # Swimlanes of BOARDID
|
||||
python3 api.py lists BOARDID # Lists of BOARDID
|
||||
python3 api.py list BOARDID LISTID # Info of LISTID
|
||||
python3 api.py createlist BOARDID LISTTITLE # Create list
|
||||
python3 api.py addcard AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION
|
||||
python3 api.py editcard BOARDID LISTID CARDID NEWCARDTITLE NEWCARDDESCRIPTION
|
||||
python3 api.py getcard BOARDID LISTID CARDID # Get card info
|
||||
python3 api.py cardsbyswimlane BOARDID SWIMLANEID # Retrieve cards list on a swimlane
|
||||
python3 api.py deleteallcards BOARDID SWIMLANEID ( * Be careful will delete ALL CARDS INSIDE the swimlanes automatically in every list * ) # Delete all cards on a swimlane
|
||||
python3 api.py get_list_cards_count BOARDID LISTID # Retrieve how many cards in a list
|
||||
python3 api.py get_board_cards_count BOARDID # Retrieve how many cards in a board
|
||||
python3 api.py editboardtitle BOARDID NEWBOARDTITLE # Edit board title
|
||||
python3 api.py copyboard BOARDID NEWBOARDTITLE # Copy a board
|
||||
|
||||
Custom Fields / Labels API:
|
||||
python3 api.py customfields BOARDID # Custom Fields of BOARDID
|
||||
python3 api.py customfield BOARDID CUSTOMFIELDID # Info of CUSTOMFIELDID
|
||||
python3 api.py addcustomfieldtoboard AUTHORID BOARDID NAME TYPE SETTINGS SHOWONCARD AUTOMATICALLYONCARD SHOWLABELONMINICARD SHOWSUMATTOPOFLIST # Add Custom Field to Board
|
||||
python3 api.py editcustomfield BOARDID LISTID CARDID CUSTOMFIELDID NEWCUSTOMFIELDVALUE # Edit Custom Field
|
||||
python3 api.py createlabel BOARDID LABELCOLOR LABELNAME (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Create a new label
|
||||
python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card
|
||||
python3 api.py addcardwithlabel AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION LABELIDS # Add a card and a label
|
||||
python3 api.py editcardcolor BOARDID LISTID CARDID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit card color
|
||||
|
||||
Checklist API:
|
||||
python3 api.py addchecklist BOARDID CARDID TITLE ITEM1 ITEM2 ITEM3 ITEM4 (You can add multiple items or just one, or also without any item, just TITLE works as well. * If items or Title contains spaces, you should add ' between them.) # Add checklist + item on a card
|
||||
python3 api.py checklistid BOARDID CARDID # Retrieve Checklist ID attached to a card
|
||||
python3 api.py checklistinfo BOARDID CARDID CHECKLISTID # Get checklist info
|
||||
|
||||
Attachment API:
|
||||
python3 api.py listattachments BOARDID # List attachments
|
||||
python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND] # Upload attachment to card
|
||||
python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH # Download attachment to local file
|
||||
@@ -45,25 +70,54 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID # List attachments for specific card
|
||||
python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move] # Copy or move attachment
|
||||
python3 api.py deleteattachment ATTACHMENTID # Delete attachment
|
||||
python3 api.py cardsbyswimlane SWIMLANEID LISTID # Retrieve cards list on a swimlane
|
||||
python3 api.py getcard BOARDID LISTID CARDID # Get card info
|
||||
python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card
|
||||
python3 api.py addcardwithlabel AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION LABELIDS # Add a card and a label
|
||||
python3 api.py editboardtitle BOARDID NEWBOARDTITLE # Edit board title
|
||||
python3 api.py copyboard BOARDID NEWBOARDTITLE # Copy a board
|
||||
python3 api.py createlabel BOARDID LABELCOLOR LABELNAME (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Create a new label
|
||||
python3 api.py editcardcolor BOARDID LISTID CARDID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit card color
|
||||
python3 api.py addchecklist BOARDID CARDID TITLE ITEM1 ITEM2 ITEM3 ITEM4 (You can add multiple items or just one, or also without any item, just TITLE works as well. * If items or Title contains spaces, you should add ' between them.) # Add checklist + item on a card
|
||||
python3 api.py deleteallcards BOARDID SWIMLANEID ( * Be careful will delete ALL CARDS INSIDE the swimlanes automatically in every list * ) # Delete all cards on a swimlane
|
||||
python3 api.py checklistid BOARDID CARDID # Retrieve Checklist ID attached to a card
|
||||
python3 api.py checklistinfo BOARDID CARDID CHECKLISTID # Get checklist info
|
||||
python3 api.py get_list_cards_count BOARDID LISTID # Retrieve how many cards in a list
|
||||
python3 api.py get_board_cards_count BOARDID # Retrieve how many cards in a board
|
||||
|
||||
|
||||
Board Member API (Issue #5998):
|
||||
python3 api.py addboardmember BOARDID USERID ROLE # Add/activate board member. ROLE: admin, normal, comment, readonly, worker, normalassignedonly, commentassignedonly, readassignedonly, nocomments
|
||||
python3 api.py removeboardmember BOARDID USERID # Remove (deactivate) board member
|
||||
python3 api.py setboardmemberrole BOARDID USERID ROLE # Change permission/role of existing board member
|
||||
|
||||
Card Member / Assignee API (board member -> card member/assignee):
|
||||
python3 api.py setcardmembers BOARDID LISTID CARDID MEMBERIDS # Set card members. MEMBERIDS = comma-separated userIds, or '' to clear
|
||||
python3 api.py setcardassignees BOARDID LISTID CARDID ASSIGNEEIDS # Set card assignees. ASSIGNEEIDS = comma-separated userIds, or '' to clear
|
||||
|
||||
Card Dates API (Issue #5846):
|
||||
python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE [DATEVALUE] # DATETYPE: received, start, due, end. DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z. Omit DATEVALUE (or pass '') to clear the date.
|
||||
|
||||
Card Labels API (Issue #5819):
|
||||
python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS # Set (replace) card labels. LABELIDS = comma-separated labelIds, or '' to clear
|
||||
|
||||
Card Copy/Move API:
|
||||
python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID # Move card to another board/swimlane/list
|
||||
|
||||
Bulk Card API (Issue #4743, #5819):
|
||||
python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE # Create many cards in one request. CARDSJSONFILE = JSON file with a list of titles, or a list of card objects {title,description,members,assignees,...}
|
||||
python3 api.py bulkdeletecards BOARDID CARDIDS # Delete many cards in one request. CARDIDS = comma-separated cardIds
|
||||
python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS # Merge-add/remove labels across many cards. CARDIDS/ADDLABELIDS/REMOVELABELIDS = comma-separated, or '' for none
|
||||
|
||||
Linked Card API (Issue #5897):
|
||||
python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID # Create a linked card in BOARDID/LISTID that references LINKEDCARDID (may be on another board)
|
||||
|
||||
Card Member/Assignee Merge API (Issue #5998):
|
||||
python3 api.py addcardmember BOARDID LISTID CARDID MEMBERID # Add one board member to a card (validated)
|
||||
python3 api.py removecardmember BOARDID LISTID CARDID MEMBERID # Remove one member from a card
|
||||
python3 api.py addcardassignee BOARDID LISTID CARDID ASSIGNEEID # Add one assignee to a card (validated)
|
||||
python3 api.py removecardassignee BOARDID LISTID CARDID ASSIGNEEID # Remove one assignee from a card
|
||||
|
||||
Copy/Move Swimlane/List/Card API (position = 0-based index from top-left):
|
||||
python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION] # Copy a card (deep copy)
|
||||
python3 api.py copyswimlane BOARDID SWIMLANEID [TOBOARDID] [POSITION] # Copy a swimlane (deep copy)
|
||||
python3 api.py moveswimlane BOARDID SWIMLANEID [TOBOARDID] [POSITION] # Move a swimlane
|
||||
python3 api.py copylist BOARDID LISTID TOSWIMLANEID [TOBOARDID] [POSITION] # Copy a list (deep copy)
|
||||
python3 api.py movelist BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION] # Move a list
|
||||
|
||||
My Cards / Due Cards API (Issue #4815):
|
||||
python3 api.py mycards [due] [FROM] [TO] # Current user's cards. 'due' = only cards with a due date; FROM/TO = ISO 8601 due-date range
|
||||
|
||||
Card Settings API (Issue #3062):
|
||||
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles)
|
||||
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true
|
||||
|
||||
Admin API:
|
||||
python3 api.py users # All users
|
||||
python3 api.py boards # All Public Boards
|
||||
python3 api.py newuser USERNAME EMAIL PASSWORD
|
||||
"""
|
||||
|
||||
@@ -723,9 +777,9 @@ if arguments == 2:
|
||||
print(f"Error: {e}")
|
||||
# ------- GET BOARD CARDS COUNT END -----------
|
||||
|
||||
if arguments == 1:
|
||||
if arguments >= 1:
|
||||
|
||||
if sys.argv[1] == 'users':
|
||||
if arguments == 1 and sys.argv[1] == 'users':
|
||||
|
||||
# ------- LIST OF USERS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -736,7 +790,7 @@ if arguments == 1:
|
||||
print(data2)
|
||||
# ------- LIST OF USERS END -----------
|
||||
|
||||
if sys.argv[1] == 'user':
|
||||
if arguments == 1 and sys.argv[1] == 'user':
|
||||
# ------- LIST OF ALL USERS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print(user)
|
||||
@@ -746,7 +800,7 @@ if arguments == 1:
|
||||
print(data2)
|
||||
# ------- LIST OF ALL USERS END -----------
|
||||
|
||||
if sys.argv[1] == 'boards':
|
||||
if arguments == 1 and sys.argv[1] == 'boards':
|
||||
|
||||
# ------- LIST OF PUBLIC BOARDS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -761,7 +815,7 @@ if arguments == 1:
|
||||
|
||||
if sys.argv[1] == 'uploadattachment':
|
||||
# ------- UPLOAD ATTACHMENT START -----------
|
||||
if arguments < 5:
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]")
|
||||
print("Storage backends: fs, gridfs, s3")
|
||||
exit(1)
|
||||
@@ -1026,3 +1080,411 @@ if arguments == 1:
|
||||
# ------- DELETE ATTACHMENT END -----------
|
||||
|
||||
# ------- NEW ATTACHMENT API ENDPOINTS END -----------
|
||||
|
||||
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS START -----------
|
||||
# These commands wrap REST endpoints that already exist in Wekan.
|
||||
# See CHANGELOG.md "Upcoming" section for related issues and design notes.
|
||||
|
||||
# Map a human readable ROLE to the board member permission boolean flags
|
||||
# used by /api/boards/:boardId/members/:userId/add and .../members/:memberId
|
||||
def board_role_flags(role):
|
||||
flags = {
|
||||
'isAdmin': 'false',
|
||||
'isNoComments': 'false',
|
||||
'isCommentOnly': 'false',
|
||||
'isWorker': 'false',
|
||||
'isNormalAssignedOnly': 'false',
|
||||
'isCommentAssignedOnly': 'false',
|
||||
'isReadOnly': 'false',
|
||||
'isReadAssignedOnly': 'false',
|
||||
}
|
||||
role = (role or 'normal').lower()
|
||||
rolemap = {
|
||||
'admin': 'isAdmin',
|
||||
'nocomments': 'isNoComments',
|
||||
'comment': 'isCommentOnly',
|
||||
'commentonly': 'isCommentOnly',
|
||||
'worker': 'isWorker',
|
||||
'normalassignedonly': 'isNormalAssignedOnly',
|
||||
'commentassignedonly': 'isCommentAssignedOnly',
|
||||
'readonly': 'isReadOnly',
|
||||
'readassignedonly': 'isReadAssignedOnly',
|
||||
}
|
||||
if role in ('normal', 'member'):
|
||||
return flags # all false == Normal board member
|
||||
if role in rolemap:
|
||||
flags[rolemap[role]] = 'true'
|
||||
return flags
|
||||
print("Invalid ROLE. Valid: admin, normal, comment, readonly, worker, "
|
||||
"normalassignedonly, commentassignedonly, readassignedonly, nocomments")
|
||||
sys.exit(1)
|
||||
|
||||
if sys.argv[1] == 'addboardmember':
|
||||
# ------- ADD BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboardmember BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4] if arguments > 3 else 'normal'
|
||||
add_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'add'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = {'action': 'add'}
|
||||
post_data.update(board_role_flags(role))
|
||||
body = requests.post(add_member_url, data=post_data, headers=headers)
|
||||
print("=== ADD BOARD MEMBER ===\n")
|
||||
print(body.text)
|
||||
# ------- ADD BOARD MEMBER END -----------
|
||||
|
||||
if sys.argv[1] == 'removeboardmember':
|
||||
# ------- REMOVE BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboardmember BOARDID USERID")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
remove_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'remove'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = {'action': 'remove'}
|
||||
body = requests.post(remove_member_url, data=post_data, headers=headers)
|
||||
print("=== REMOVE BOARD MEMBER ===\n")
|
||||
print(body.text)
|
||||
# ------- REMOVE BOARD MEMBER END -----------
|
||||
|
||||
if sys.argv[1] == 'setboardmemberrole':
|
||||
# ------- SET BOARD MEMBER ROLE START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setboardmemberrole BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4]
|
||||
set_role_url = wekanurl + apiboards + boardid + s + 'members' + s + userid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = board_role_flags(role)
|
||||
body = requests.post(set_role_url, data=post_data, headers=headers)
|
||||
print("=== SET BOARD MEMBER ROLE ===\n")
|
||||
print(body.text)
|
||||
# ------- SET BOARD MEMBER ROLE END -----------
|
||||
|
||||
if sys.argv[1] in ('setcardmembers', 'setcardassignees'):
|
||||
# ------- SET CARD MEMBERS / ASSIGNEES START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID IDS".format(sys.argv[1]))
|
||||
print("IDS = comma-separated userIds, or '' to clear")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
ids_raw = sys.argv[5] if arguments > 4 else ''
|
||||
ids = [x for x in ids_raw.split(',') if x] if ids_raw else []
|
||||
field = 'members' if sys.argv[1] == 'setcardmembers' else 'assignees'
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
# Empty array clears the field; send as JSON so the array type is preserved.
|
||||
put_data = {field: ids if ids else []}
|
||||
body = requests.put(edcard, json=put_data, headers=headers)
|
||||
print("=== SET CARD {} ===\n".format(field.upper()))
|
||||
print(body.text)
|
||||
# ------- SET CARD MEMBERS / ASSIGNEES END -----------
|
||||
|
||||
if sys.argv[1] == 'setcarddate':
|
||||
# ------- SET CARD DATE START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE DATEVALUE")
|
||||
print("DATETYPE: received, start, due, end")
|
||||
print("DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
datetype = sys.argv[5].lower()
|
||||
datevalue = sys.argv[6] if arguments > 5 else ''
|
||||
datemap = {
|
||||
'received': 'receivedAt',
|
||||
'start': 'startAt',
|
||||
'due': 'dueAt',
|
||||
'end': 'endAt',
|
||||
}
|
||||
if datetype not in datemap:
|
||||
print("Invalid DATETYPE. Valid: received, start, due, end")
|
||||
sys.exit(1)
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {datemap[datetype]: datevalue}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== SET CARD DATE {} ===\n".format(datetype.upper()))
|
||||
print(body.text)
|
||||
# ------- SET CARD DATE END -----------
|
||||
|
||||
if sys.argv[1] == 'setcardlabels':
|
||||
# ------- SET CARD LABELS START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS")
|
||||
print("LABELIDS = comma-separated labelIds, or '' to clear")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
labelids_raw = sys.argv[5] if arguments > 4 else ''
|
||||
labelids = [x for x in labelids_raw.split(',') if x] if labelids_raw else []
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
# NOTE: this REPLACES the card's labels. Bulk add/remove (merge) is not yet
|
||||
# implemented server-side, see Issue #5819 in CHANGELOG.md "Upcoming".
|
||||
put_data = {'labelIds': labelids if labelids else []}
|
||||
body = requests.put(edcard, json=put_data, headers=headers)
|
||||
print("=== SET CARD LABELS ===\n")
|
||||
print(body.text)
|
||||
# ------- SET CARD LABELS END -----------
|
||||
|
||||
if sys.argv[1] == 'movecard':
|
||||
# ------- MOVE CARD START -----------
|
||||
if arguments < 7:
|
||||
print("Usage: python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
newboardid = sys.argv[5]
|
||||
newswimlaneid = sys.argv[6]
|
||||
newlistid = sys.argv[7]
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {
|
||||
'newBoardId': newboardid,
|
||||
'newSwimlaneId': newswimlaneid,
|
||||
'newListId': newlistid,
|
||||
}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== MOVE CARD ===\n")
|
||||
print(body.text)
|
||||
# ------- MOVE CARD END -----------
|
||||
|
||||
if sys.argv[1] == 'bulkaddcards':
|
||||
# ------- BULK ADD CARDS START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE")
|
||||
print("CARDSJSONFILE = JSON file with a list of titles, e.g. [\"Card A\", \"Card B\"],")
|
||||
print(" or a list of card objects [{\"title\": \"Card A\", \"description\": \"...\"}, ...]")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
authorid = sys.argv[4]
|
||||
swimlaneid = sys.argv[5]
|
||||
cardsjsonfile = sys.argv[6] if arguments > 5 else None
|
||||
if not cardsjsonfile:
|
||||
print("CARDSJSONFILE is required")
|
||||
exit(1)
|
||||
try:
|
||||
with open(cardsjsonfile, 'r') as f:
|
||||
raw_cards = json.load(f)
|
||||
except Exception as e:
|
||||
print("Error reading CARDSJSONFILE: {}".format(e))
|
||||
exit(1)
|
||||
if not isinstance(raw_cards, list) or len(raw_cards) == 0:
|
||||
print("CARDSJSONFILE must contain a non-empty JSON list")
|
||||
exit(1)
|
||||
cards = []
|
||||
for item in raw_cards:
|
||||
if isinstance(item, str):
|
||||
cards.append({'title': item})
|
||||
elif isinstance(item, dict):
|
||||
cards.append(item)
|
||||
else:
|
||||
print("Each card must be a string (title) or an object")
|
||||
exit(1)
|
||||
bulk_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + 'bulk'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'cards': cards}
|
||||
body = requests.post(bulk_url, json=post_data, headers=headers)
|
||||
print("=== BULK ADD CARDS ===\n")
|
||||
print(body.text)
|
||||
# ------- BULK ADD CARDS END -----------
|
||||
|
||||
if sys.argv[1] == 'bulkdeletecards':
|
||||
# ------- BULK DELETE CARDS START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py bulkdeletecards BOARDID CARDIDS")
|
||||
print("CARDIDS = comma-separated cardIds")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids_raw = sys.argv[3]
|
||||
cardids = [x for x in cardids_raw.split(',') if x]
|
||||
bulk_url = wekanurl + apiboards + boardid + s + cs + s + 'bulk'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
# requests sends a body with DELETE via the json= parameter.
|
||||
body = requests.delete(bulk_url, json={'cardIds': cardids}, headers=headers)
|
||||
print("=== BULK DELETE CARDS ===\n")
|
||||
print(body.text)
|
||||
# ------- BULK DELETE CARDS END -----------
|
||||
|
||||
if sys.argv[1] == 'bulkcardlabels':
|
||||
# ------- BULK CARD LABELS START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS")
|
||||
print("CARDIDS / ADDLABELIDS / REMOVELABELIDS = comma-separated, or '' for none")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids = [x for x in sys.argv[3].split(',') if x]
|
||||
add_labelids = [x for x in sys.argv[4].split(',') if x]
|
||||
remove_labelids = [x for x in sys.argv[5].split(',') if x]
|
||||
bulk_url = wekanurl + apiboards + boardid + s + cs + s + 'labels'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'cardIds': cardids, 'addLabelIds': add_labelids, 'removeLabelIds': remove_labelids}
|
||||
body = requests.post(bulk_url, json=post_data, headers=headers)
|
||||
print("=== BULK CARD LABELS ===\n")
|
||||
print(body.text)
|
||||
# ------- BULK CARD LABELS END -----------
|
||||
|
||||
if sys.argv[1] == 'linkcard':
|
||||
# ------- CREATE LINKED CARD START -----------
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
swimlaneid = sys.argv[4]
|
||||
authorid = sys.argv[5]
|
||||
linkedcardid = sys.argv[6]
|
||||
link_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'linkedId': linkedcardid}
|
||||
body = requests.post(link_url, data=post_data, headers=headers)
|
||||
print("=== CREATE LINKED CARD ===\n")
|
||||
print(body.text)
|
||||
# ------- CREATE LINKED CARD END -----------
|
||||
|
||||
if sys.argv[1] in ('addcardmember', 'removecardmember', 'addcardassignee', 'removecardassignee'):
|
||||
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID USERID".format(sys.argv[1]))
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
userid = sys.argv[5]
|
||||
field = 'members' if 'member' in sys.argv[1] else 'assignees'
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + field + s + userid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
if sys.argv[1].startswith('add'):
|
||||
body = requests.post(url, headers=headers)
|
||||
else:
|
||||
body = requests.delete(url, headers=headers)
|
||||
print("=== {} ===\n".format(sys.argv[1].upper()))
|
||||
print(body.text)
|
||||
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE END -----------
|
||||
|
||||
if sys.argv[1] == 'copycard':
|
||||
# ------- COPY CARD START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION]")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
toswimlaneid = sys.argv[5] if arguments > 4 else None
|
||||
if not toswimlaneid:
|
||||
print("TOSWIMLANEID is required")
|
||||
exit(1)
|
||||
toboardid = sys.argv[6] if arguments > 5 else boardid
|
||||
tolistid = sys.argv[7] if arguments > 6 else listid
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + 'copy'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'toBoardId': toboardid, 'toSwimlaneId': toswimlaneid, 'toListId': tolistid}
|
||||
if arguments > 7:
|
||||
post_data['position'] = int(sys.argv[8])
|
||||
body = requests.post(url, json=post_data, headers=headers)
|
||||
print("=== COPY CARD ===\n")
|
||||
print(body.text)
|
||||
# ------- COPY CARD END -----------
|
||||
|
||||
if sys.argv[1] in ('copyswimlane', 'moveswimlane'):
|
||||
# ------- COPY/MOVE SWIMLANE START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID SWIMLANEID [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
toboardid = sys.argv[4] if arguments > 3 else boardid
|
||||
action = 'copy' if sys.argv[1] == 'copyswimlane' else 'move'
|
||||
url = wekanurl + apiboards + boardid + s + sws + s + swimlaneid + s + action
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'toBoardId': toboardid}
|
||||
if arguments > 4:
|
||||
post_data['position'] = int(sys.argv[5])
|
||||
body = requests.post(url, json=post_data, headers=headers)
|
||||
print("=== {} ===\n".format(sys.argv[1].upper()))
|
||||
print(body.text)
|
||||
# ------- COPY/MOVE SWIMLANE END -----------
|
||||
|
||||
if sys.argv[1] in ('copylist', 'movelist'):
|
||||
# ------- COPY/MOVE LIST START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
toswimlaneid = sys.argv[4] if arguments > 3 else None
|
||||
toboardid = sys.argv[5] if arguments > 4 else boardid
|
||||
action = 'copy' if sys.argv[1] == 'copylist' else 'move'
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + action
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'toBoardId': toboardid}
|
||||
if toswimlaneid:
|
||||
post_data['toSwimlaneId'] = toswimlaneid
|
||||
if arguments > 5:
|
||||
post_data['position'] = int(sys.argv[6])
|
||||
body = requests.post(url, json=post_data, headers=headers)
|
||||
print("=== {} ===\n".format(sys.argv[1].upper()))
|
||||
print(body.text)
|
||||
# ------- COPY/MOVE LIST END -----------
|
||||
|
||||
if sys.argv[1] == 'mycards':
|
||||
# ------- MY CARDS / DUE CARDS START -----------
|
||||
params = {}
|
||||
if arguments >= 2 and sys.argv[2].lower() == 'due':
|
||||
params['due'] = 'true'
|
||||
if arguments >= 3:
|
||||
params['from'] = sys.argv[3]
|
||||
if arguments >= 4:
|
||||
params['to'] = sys.argv[4]
|
||||
url = wekanurl + 'api/user/cards'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers, params=params)
|
||||
print("=== MY CARDS ===\n")
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
print(data2)
|
||||
# ------- MY CARDS / DUE CARDS END -----------
|
||||
|
||||
if sys.argv[1] == 'getcardsettings':
|
||||
# ------- GET CARD SETTINGS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py getcardsettings BOARDID")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'cardSettings'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print("=== CARD SETTINGS ===\n")
|
||||
data2 = body.text.replace(',', ",\n")
|
||||
print(data2)
|
||||
# ------- GET CARD SETTINGS END -----------
|
||||
|
||||
if sys.argv[1] == 'setcardsetting':
|
||||
# ------- SET CARD SETTING START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardsetting BOARDID KEY VALUE")
|
||||
print("e.g. python3 api.py setcardsetting BOARDID allowsDueDate true")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
key = sys.argv[3]
|
||||
value = sys.argv[4]
|
||||
url = wekanurl + apiboards + boardid + s + 'cardSettings'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json={key: value}, headers=headers)
|
||||
print("=== SET CARD SETTING ===\n")
|
||||
print(body.text)
|
||||
# ------- SET CARD SETTING END -----------
|
||||
|
||||
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS END -----------
|
||||
@@ -60,6 +60,22 @@ import { Utils } from '/client/lib/utils';
|
||||
// Token already lives in localStorage; cookie adds same-origin send-on-request semantics
|
||||
Meteor.startup(() => {
|
||||
const COOKIE_NAME = 'meteor_login_token';
|
||||
const USER_ID_COOKIE = 'meteor_user_id';
|
||||
const TOKEN_EXPIRES_COOKIE = 'meteor_login_token_expires';
|
||||
|
||||
const getCookie = (name) => {
|
||||
try {
|
||||
const parts = document.cookie ? document.cookie.split(';') : [];
|
||||
for (const part of parts) {
|
||||
const [k, ...rest] = part.trim().split('=');
|
||||
if (decodeURIComponent(k) === name) {
|
||||
return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
};
|
||||
|
||||
const cookieAttrs = () => {
|
||||
const attrs = ['Path=/', 'SameSite=Lax'];
|
||||
try {
|
||||
@@ -87,6 +103,37 @@ Meteor.startup(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrapTokenFromCookie = () => {
|
||||
try {
|
||||
const currentToken = Accounts && typeof Accounts._storedLoginToken === 'function'
|
||||
? Accounts._storedLoginToken()
|
||||
: null;
|
||||
if (currentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getCookie(COOKIE_NAME);
|
||||
const userId = getCookie(USER_ID_COOKIE);
|
||||
const tokenExpiresRaw = getCookie(TOKEN_EXPIRES_COOKIE);
|
||||
const tokenExpires = tokenExpiresRaw ? new Date(tokenExpiresRaw) : null;
|
||||
|
||||
if (!token || !userId || !tokenExpires || Number.isNaN(tokenExpires.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Accounts && typeof Accounts._storeLoginToken === 'function') {
|
||||
Accounts._storeLoginToken(userId, token, tokenExpires);
|
||||
if (Meteor.status && !Meteor.status().connected && typeof Meteor.reconnect === 'function') {
|
||||
Meteor.reconnect();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
bootstrapTokenFromCookie();
|
||||
|
||||
// Initial sync on startup
|
||||
syncCookie();
|
||||
|
||||
|
||||
@@ -258,14 +258,18 @@ Template.activity.helpers({
|
||||
|
||||
attachmentLink() {
|
||||
const attachment = this.activity.attachment();
|
||||
const attachmentUrl = attachment && typeof attachment.link === 'function'
|
||||
? attachment.link()
|
||||
: '';
|
||||
// trying to display url before file is stored generates js errors
|
||||
return (
|
||||
(attachment &&
|
||||
attachment.path &&
|
||||
attachmentUrl &&
|
||||
Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: `${attachment.link()}?download=true`,
|
||||
href: `${attachmentUrl}?download=true`,
|
||||
target: '_blank',
|
||||
},
|
||||
sanitizeText(attachment.name),
|
||||
|
||||
@@ -8,10 +8,10 @@ template(name="archivedBoards")
|
||||
each archivedBoards
|
||||
li.archived-lists-item
|
||||
div.board-header-btns
|
||||
button.board-header-btn.js-delete-board
|
||||
button.primary.board-header-btn.js-delete-board
|
||||
i.fa.fa-trash
|
||||
| {{_ 'delete-board'}}
|
||||
button.board-header-btn.js-restore-board
|
||||
button.primary.board-header-btn.js-restore-board
|
||||
i.fa.fa-undo
|
||||
| {{_ 'restore-board'}}
|
||||
= title
|
||||
|
||||
@@ -64,7 +64,7 @@ template(name="boardBody")
|
||||
|
||||
template(name="calendarView")
|
||||
if isViewCalendar
|
||||
.calendar-view.swimlane
|
||||
.calendar-view.swimlane.dragscroll
|
||||
if currentCard
|
||||
+cardDetails(currentCard)
|
||||
+fullcalendar(calendarOptions)
|
||||
@@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import { boardConverter } from '/client/lib/boardConverter';
|
||||
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
@@ -19,23 +20,26 @@ let BoardBody = null;
|
||||
|
||||
Template.board.onCreated(function () {
|
||||
this.isBoardReady = new ReactiveVar(false);
|
||||
// Kept for the template/jade which still references it; conversion is a no-op.
|
||||
this.isConverting = new ReactiveVar(false);
|
||||
this._swimlaneCreated = new Set(); // Track boards where we've created swimlanes
|
||||
this._boardProcessed = false; // Track if board has been processed
|
||||
this._lastProcessedBoardId = null; // Track last processed board ID
|
||||
this._swimlaneCreated = new Set(); // boards where a default swimlane was ensured
|
||||
|
||||
// Ensure a board has at least one swimlane. Guarded to run once per board and
|
||||
// fire-and-forget so it never blocks rendering. The board is marked
|
||||
// immediately to avoid duplicate concurrent calls, and the mark is rolled
|
||||
// back on failure so a later attempt can retry.
|
||||
this.ensureDefaultSwimlane = async (boardId) => {
|
||||
// Only create swimlane once per board
|
||||
if (this._swimlaneCreated.has(boardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._swimlaneCreated.add(boardId);
|
||||
try {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (!board) return;
|
||||
|
||||
const swimlanes = board.swimlanes();
|
||||
if (swimlanes.length === 0) {
|
||||
if (!board) {
|
||||
this._swimlaneCreated.delete(boardId);
|
||||
return;
|
||||
}
|
||||
if (board.swimlanes().length === 0) {
|
||||
const swimlaneId = await Meteor.callAsync('ensureDefaultSwimlane', boardId);
|
||||
if (process.env.DEBUG === 'true' && swimlaneId) {
|
||||
console.log(
|
||||
@@ -43,67 +47,41 @@ Template.board.onCreated(function () {
|
||||
);
|
||||
}
|
||||
}
|
||||
this._swimlaneCreated.add(boardId);
|
||||
} catch (error) {
|
||||
this._swimlaneCreated.delete(boardId); // allow a later retry
|
||||
console.error('Error creating default swimlane:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.checkAndConvertBoard = async (boardId) => {
|
||||
try {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
this.isBoardReady.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isBoardReady.set(true);
|
||||
} catch (error) {
|
||||
console.error('Error during board conversion check:', error);
|
||||
this.isConverting.set(false);
|
||||
this.isBoardReady.set(true); // Show board even if conversion check failed
|
||||
}
|
||||
};
|
||||
|
||||
// The pattern we use to manually handle data loading is described here:
|
||||
// https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management/using-subs-manager
|
||||
// XXX The boardId should be readed from some sort the component "props",
|
||||
// unfortunatly, Blaze doesn't have this notion.
|
||||
// Single reactive computation that subscribes to the current board and tracks
|
||||
// its ready state in one place. Previously a Tracker.autorun was created
|
||||
// *inside* this autorun on every re-run without being stopped, leaking
|
||||
// computations that raced on shared ready-state flags. When navigating
|
||||
// between boards — e.g. opening a freshly created board — that race could
|
||||
// leave swimlanes/lists unrendered until the board was re-selected. A single
|
||||
// self-managed autorun (auto-stopped by Blaze on destroy) avoids both the
|
||||
// leak and the race.
|
||||
// Pattern: https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management/using-subs-manager
|
||||
this.autorun(() => {
|
||||
const currentBoardId = Session.get('currentBoard');
|
||||
if (!currentBoardId) return;
|
||||
if (!currentBoardId) {
|
||||
this.isBoardReady.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = Meteor.subscribe('board', currentBoardId, false);
|
||||
const ready = handle.ready();
|
||||
this.isBoardReady.set(ready);
|
||||
|
||||
// Use a separate autorun for subscription ready state to avoid reactive loops
|
||||
this.subscriptionReadyAutorun = Tracker.autorun(() => {
|
||||
if (handle.ready()) {
|
||||
if (
|
||||
!this._boardProcessed ||
|
||||
this._lastProcessedBoardId !== currentBoardId
|
||||
) {
|
||||
this._boardProcessed = true;
|
||||
this._lastProcessedBoardId = currentBoardId;
|
||||
|
||||
// Ensure default swimlane exists (only once per board)
|
||||
this.ensureDefaultSwimlane(currentBoardId);
|
||||
// Check if board needs conversion
|
||||
this.checkAndConvertBoard(currentBoardId);
|
||||
}
|
||||
} else {
|
||||
this.isBoardReady.set(false);
|
||||
}
|
||||
});
|
||||
if (ready) {
|
||||
// Run outside the computation so this autorun depends only on the board
|
||||
// id and the subscription's ready state — not on the swimlane data that
|
||||
// ensureDefaultSwimlane reads — which would otherwise cause extra re-runs.
|
||||
Tracker.nonreactive(() => this.ensureDefaultSwimlane(currentBoardId));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.board.onDestroyed(function () {
|
||||
// Clean up the subscription ready autorun to prevent memory leaks
|
||||
if (this.subscriptionReadyAutorun) {
|
||||
this.subscriptionReadyAutorun.stop();
|
||||
}
|
||||
});
|
||||
|
||||
Template.board.helpers({
|
||||
onlyShowCurrentCard() {
|
||||
const isMiniScreen = Utils.isMiniScreen();
|
||||
@@ -142,6 +120,7 @@ Template.board.helpers({
|
||||
|
||||
Template.boardBody.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
Meteor.subscribe('inviteToBoardRolesSettings');
|
||||
this.showOverlay = new ReactiveVar(false);
|
||||
this.draggingActive = new ReactiveVar(false);
|
||||
this._isDragging = false;
|
||||
|
||||
@@ -961,6 +961,22 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
font-size: inherit !important; /* Inherit the 2x scaling */
|
||||
}
|
||||
|
||||
/* Stack the two-column All Boards layout on small screens. The fixed
|
||||
"260px 1fr" grid had no mobile collapse, so on narrow non-iPhone browsers
|
||||
(Fairphone Firefox ESR / Ubuntu Touch Morph, narrow desktop) it overflowed
|
||||
and the left menu (Starred / Templates / Remaining / Workspaces / + Add
|
||||
board) was clipped. Width-based, so it applies to every browser. */
|
||||
.boards-layout {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-right: none !important;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-right: 0 !important;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.wrapper .fa, .wrapper .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons */
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ template(name="boardList")
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
ul.board-list.clearfix.js-boards(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
|
||||
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
|
||||
li.js-add-board
|
||||
if isSelectedMenu 'templates'
|
||||
a.board-list-item.label(title="{{_ 'add-template-container'}}")
|
||||
@@ -130,7 +130,7 @@ template(name="boardList")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
span.board-handle(title="{{boardWorkspaceDragHint}}")
|
||||
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
@@ -158,7 +158,7 @@ template(name="boardList")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
span.board-handle(title="{{boardWorkspaceDragHint}}")
|
||||
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import getSlug from 'limax';
|
||||
import dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
@@ -197,6 +199,13 @@ Template.boardList.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.boardList.onRendered(function () {
|
||||
// Enable drag-to-scroll on the All Boards list (.board-list.dragscroll), the
|
||||
// same way the board canvas/swimlanes do. This makes mouse drag-scrolling
|
||||
// work on desktop; finger drag-scrolling is handled by dragscrollTouch.js.
|
||||
// The .board-list element is stable across menu/workspace switches (only its
|
||||
// li children are re-rendered), so a single reset() binding is enough.
|
||||
dragscroll.reset();
|
||||
|
||||
// jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
|
||||
// The old sortable code has been removed to prevent conflicts
|
||||
/* OLD SORTABLE CODE - DISABLED
|
||||
|
||||
@@ -125,23 +125,6 @@ template(name="attachmentActionsPopup")
|
||||
else
|
||||
| {{_ 'add-background-image'}}
|
||||
|
||||
if $neq versions.original.storage "fs"
|
||||
a.js-move-storage-fs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-fs'}}
|
||||
|
||||
if $neq versions.original.storage "gridfs"
|
||||
if versions.original.storage
|
||||
a.js-move-storage-gridfs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-gridfs'}}
|
||||
|
||||
if $neq versions.original.storage "s3"
|
||||
if versions.original.storage
|
||||
a.js-move-storage-s3
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-s3'}}
|
||||
|
||||
template(name="attachmentRenamePopup")
|
||||
input.js-edit-attachment-name(type='text' autofocus value="{{getNameWithoutExtension}}" dir="auto")
|
||||
.edit-controls.clearfix
|
||||
|
||||
@@ -89,6 +89,10 @@ function attachmentCanBeOpened(attachment) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAttachmentUrl(attachment) {
|
||||
return attachment && typeof attachment.link === 'function' ? attachment.link() : '';
|
||||
}
|
||||
|
||||
function openAttachmentViewer(attachmentId) {
|
||||
const attachment = ReactiveCache.getAttachment(attachmentId);
|
||||
|
||||
@@ -105,18 +109,18 @@ function openAttachmentViewer(attachmentId) {
|
||||
*/
|
||||
switch(true){
|
||||
case (attachment.isImage):
|
||||
$("#image-viewer").attr("src", attachment.link());
|
||||
$("#image-viewer").attr("src", getAttachmentUrl(attachment));
|
||||
$("#image-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isPDF):
|
||||
$("#pdf-viewer").attr("data", attachment.link());
|
||||
$("#pdf-viewer").attr("data", getAttachmentUrl(attachment));
|
||||
$("#pdf-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isVideo):
|
||||
// We have to create a new <source> DOM element and append it to the video
|
||||
// element, otherwise the video won't load
|
||||
let videoSource = document.createElement('source');
|
||||
videoSource.setAttribute('src', attachment.link());
|
||||
videoSource.setAttribute('src', getAttachmentUrl(attachment));
|
||||
$("#video-viewer").append(videoSource);
|
||||
|
||||
$("#video-viewer").removeClass("hidden");
|
||||
@@ -125,14 +129,14 @@ function openAttachmentViewer(attachmentId) {
|
||||
// We have to create a new <source> DOM element and append it to the audio
|
||||
// element, otherwise the audio won't load
|
||||
let audioSource = document.createElement('source');
|
||||
audioSource.setAttribute('src', attachment.link());
|
||||
audioSource.setAttribute('src', getAttachmentUrl(attachment));
|
||||
$("#audio-viewer").append(audioSource);
|
||||
|
||||
$("#audio-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isText):
|
||||
case (attachment.isJSON):
|
||||
$("#txt-viewer").attr("data", attachment.link());
|
||||
$("#txt-viewer").attr("data", getAttachmentUrl(attachment));
|
||||
$("#txt-viewer").removeClass("hidden");
|
||||
break;
|
||||
}
|
||||
@@ -584,23 +588,11 @@ Template.attachmentActionsPopup.events({
|
||||
Utils.reload();
|
||||
event.preventDefault();
|
||||
},
|
||||
'click .js-move-storage-fs'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "fs");
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-move-storage-gridfs'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "gridfs");
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-move-storage-s3'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "s3");
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.attachmentRenamePopup.helpers({
|
||||
getNameWithoutExtension() {
|
||||
const ret = this.name.replace(new RegExp("\." + this.extension + "$"), "");
|
||||
const ret = this.name.replace(new RegExp("\\." + this.extension + "$"), "");
|
||||
return ret;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,11 +55,10 @@ template(name="cardCustomField-number")
|
||||
template(name="cardCustomField-checkbox")
|
||||
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
|
||||
template(name="cardCustomField-currency")
|
||||
if canModifyCard
|
||||
|
||||
@@ -106,9 +106,6 @@ Template['cardCustomField-checkbox'].onCreated(function () {
|
||||
});
|
||||
|
||||
Template['cardCustomField-checkbox'].events({
|
||||
'click .js-checklist-item .check-box-unicode'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
'click .js-checklist-item .check-box-container'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
|
||||
@@ -890,3 +890,167 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
transform: scale(1.01);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sticker picker + card-detail stickers + due-complete toggle */
|
||||
.card-stickers-picker {
|
||||
max-width: 320px;
|
||||
}
|
||||
.card-sticker-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 2px;
|
||||
border-radius: 3px;
|
||||
font-size: 18px;
|
||||
color: #4d4d4d;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.card-sticker-select:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.card-sticker-select.active {
|
||||
background: #d6ecd2;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-sticker-select .card-sticker-check {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: 2px;
|
||||
font-size: 10px;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-stickers .card-sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 8px 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.card-sticker .card-sticker-remove {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-sticker .card-sticker-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
.card-due-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the card title */
|
||||
.card-details-complete-toggle {
|
||||
float: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Title is 1.35em with 1.3 line-height (~28px box) and margin-top 7px; push
|
||||
the 13px checkbox down so it lands at the vertical middle of the title. */
|
||||
margin: 14px 10px 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Card locations: multiple locations, each editable with a remove button */
|
||||
.card-details-item-location .card-locations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-details-item-location .card-location {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.card-details-item-location .card-location-info {
|
||||
flex: 1;
|
||||
}
|
||||
.card-details-item-location .js-edit-location {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-details-item-location .card-location-address {
|
||||
color: #4d4d4d;
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-left: 8px;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.card-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* "Open map links at" provider selector in the location popup */
|
||||
.js-card-location-form .map-provider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.js-card-location-form .map-provider-row .js-map-provider {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Opened card (card details popup) docks to the very top of the window and
|
||||
overlays the global + board header bars instead of opening below them. The
|
||||
base .pop-over is position:absolute with a small margin/top offset, which left
|
||||
the card's top behind the fixed header bars; fix it to the viewport top with a
|
||||
z-index above every header (headers top out around z-index 1000-2000). */
|
||||
.pop-over[data-popup="cardDetailsPopup"] {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
/* Sticker highlight styles inside the sticker picker (mascot = underline,
|
||||
computer = round), matching how they render on the card. */
|
||||
.card-sticker-select i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker-select i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -61,6 +61,9 @@ template(name="cardDetails")
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
if canModifyCard
|
||||
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
h2.card-details-title.js-card-title(
|
||||
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}")
|
||||
+viewer
|
||||
@@ -126,6 +129,47 @@ template(name="cardDetails")
|
||||
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-stickers
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-sticky-note-o
|
||||
| {{_ 'stickers'}}
|
||||
.card-details-item-content.card-stickers
|
||||
each stickers
|
||||
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if canModifyCard
|
||||
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-location
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-map-marker
|
||||
| {{_ 'location'}}
|
||||
.card-details-item-content.card-locations
|
||||
each getLocations
|
||||
.card-location
|
||||
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
|
||||
if name
|
||||
.card-location-name= name
|
||||
if address
|
||||
.card-location-address= address
|
||||
if hasCoordinates
|
||||
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
|
||||
i.fa.fa-map-o
|
||||
= ' '
|
||||
| {{_ 'location-open-map'}}
|
||||
if canModifyCard
|
||||
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
if currentBoard.hasAnyAllowsDate
|
||||
hr
|
||||
|
||||
@@ -1196,3 +1240,39 @@ template(name="cardStartPlanningPokerPopup")
|
||||
if getPokerQuestion
|
||||
if currentUser.isBoardAdmin
|
||||
button.js-remove-poker.negate.wide.right {{_ 'delete'}}
|
||||
|
||||
template(name="cardLocationsPopup")
|
||||
form.js-card-location-form
|
||||
label
|
||||
| {{_ 'location-detect-from-map'}}
|
||||
input.js-location-map-link(type="text" placeholder="https://..." autofocus)
|
||||
button.primary.js-detect-location(type="button") {{_ 'location-detect'}}
|
||||
if detectMessage
|
||||
p.quiet.js-detect-message {{detectMessage}}
|
||||
hr
|
||||
label
|
||||
| {{_ 'location-name'}}
|
||||
input.js-location-name(type="text" value="{{location.name}}")
|
||||
label
|
||||
| {{_ 'location-address'}}
|
||||
input.js-location-address(type="text" value="{{location.address}}")
|
||||
label
|
||||
| {{_ 'location-latitude'}}
|
||||
input.js-location-latitude(type="number" step="any" value="{{location.latitude}}")
|
||||
label
|
||||
| {{_ 'location-longitude'}}
|
||||
input.js-location-longitude(type="number" step="any" value="{{location.longitude}}")
|
||||
button.primary.confirm.js-submit-location(type="submit") {{_ 'save'}}
|
||||
if location._id
|
||||
button.negate.wide.right.js-delete-location(type="button") {{_ 'delete'}}
|
||||
hr
|
||||
.map-provider-row
|
||||
span.map-provider-label {{_ 'location-open-map-at'}}
|
||||
select.js-map-provider
|
||||
option(value="openstreetmap" selected="{{#if isMapProvider 'openstreetmap'}}selected{{/if}}") OpenStreetMap
|
||||
option(value="google" selected="{{#if isMapProvider 'google'}}selected{{/if}}") Google Maps
|
||||
option(value="bing" selected="{{#if isMapProvider 'bing'}}selected{{/if}}") Bing Maps
|
||||
option(value="apple" selected="{{#if isMapProvider 'apple'}}selected{{/if}}") Apple Maps
|
||||
button.primary.js-save-map-provider(type="button") {{_ 'save'}}
|
||||
if mapSavedMessage
|
||||
p.quiet.js-map-saved {{mapSavedMessage}}
|
||||
@@ -51,8 +51,100 @@ import { UnsavedEdits } from '/client/lib/unsavedEdits';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// Id of the location currently being edited in the cardLocationsPopup; null
|
||||
// when adding a new location.
|
||||
const editingLocationId = new ReactiveVar(null);
|
||||
|
||||
// Parse coordinates (and, when present, a place name / address) out of a map
|
||||
// link from common providers: Google Maps, OpenStreetMap, Bing Maps, Apple Maps
|
||||
// and generic `?q=lat,lon` / `?ll=lat,lon` links. Returns whatever it can find
|
||||
// as { latitude, longitude, name, address }.
|
||||
function parseMapLink(url) {
|
||||
const result = {};
|
||||
if (!url) return result;
|
||||
const s = String(url).trim();
|
||||
|
||||
let lat;
|
||||
let lon;
|
||||
const setCoords = (la, lo) => {
|
||||
if (lat !== undefined) return;
|
||||
const a = parseFloat(la);
|
||||
const o = parseFloat(lo);
|
||||
if (!isNaN(a) && !isNaN(o) && Math.abs(a) <= 90 && Math.abs(o) <= 180) {
|
||||
lat = a;
|
||||
lon = o;
|
||||
}
|
||||
};
|
||||
|
||||
const N = '(-?\\d+(?:\\.\\d+)?)';
|
||||
let m;
|
||||
// Google Maps place marker in the data part: !3d<lat>!4d<lon> (most precise).
|
||||
if ((m = s.match(new RegExp(`!3d${N}!4d${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps view/place: @<lat>,<lon>
|
||||
if ((m = s.match(new RegExp(`@${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// OpenStreetMap marker: ?mlat=..&mlon=..
|
||||
if ((m = s.match(new RegExp(`[?&#]mlat=${N}`)))) {
|
||||
const mlon = s.match(new RegExp(`[?&#]mlon=${N}`));
|
||||
if (mlon) setCoords(m[1], mlon[1]);
|
||||
}
|
||||
// OpenStreetMap map hash: #map=z/lat/lon
|
||||
if ((m = s.match(new RegExp(`[#&]map=\\d+(?:\\.\\d+)?/${N}/${N}`)))) setCoords(m[1], m[2]);
|
||||
// Bing Maps: cp=lat~lon
|
||||
if ((m = s.match(new RegExp(`[?&]cp=${N}~${N}`)))) setCoords(m[1], m[2]);
|
||||
// Apple Maps and generic ll=lat,lon
|
||||
if ((m = s.match(new RegExp(`[?&]ll=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic q=lat,lon (Google/Apple query form)
|
||||
if ((m = s.match(new RegExp(`[?&]q=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic center=lat,lon
|
||||
if ((m = s.match(new RegExp(`[?&]center=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
|
||||
if (lat !== undefined) {
|
||||
result.latitude = lat;
|
||||
result.longitude = lon;
|
||||
}
|
||||
|
||||
const decode = (raw) => {
|
||||
try {
|
||||
return decodeURIComponent(raw.replace(/\+/g, ' ')).trim();
|
||||
} catch (e) {
|
||||
return raw.replace(/\+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
const isCoordText = (t) => /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(t);
|
||||
|
||||
// Place name from Google Maps /place/<Name>/
|
||||
if ((m = s.match(/\/place\/([^/@?]+)/))) {
|
||||
const name = decode(m[1]);
|
||||
if (name && !isCoordText(name)) result.name = name;
|
||||
}
|
||||
// Address from a textual q=/query=/destination= parameter.
|
||||
if ((m = s.match(/[?&](?:q|query|destination)=([^&]+)/))) {
|
||||
const q = decode(m[1]);
|
||||
if (q && !isCoordText(q)) result.address = q;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build an "open in map" link for the given provider and coordinates. Mirrors
|
||||
// the providers offered in the location popup's "Open map links at" selector.
|
||||
function mapLinkFor(provider, lat, lon) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
|
||||
case 'bing':
|
||||
return `https://www.bing.com/maps?cp=${lat}~${lon}&lvl=16`;
|
||||
case 'apple':
|
||||
return `https://maps.apple.com/?ll=${lat},${lon}`;
|
||||
case 'openstreetmap':
|
||||
default:
|
||||
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||
}
|
||||
}
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
const { calculateIndexData } = Utils;
|
||||
|
||||
@@ -121,9 +213,24 @@ Template.cardDetails.onCreated(function () {
|
||||
};
|
||||
|
||||
Meteor.subscribe('unsaved-edits');
|
||||
|
||||
// Surface legacy CollectionFS attachments for the current board so they show
|
||||
// up in the card's attachment gallery and can be read in place (without
|
||||
// migrating them to Meteor-Files first).
|
||||
this.autorun(() => {
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (board && board._id) {
|
||||
Meteor.subscribe('legacyBoardAttachments', board._id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.cardDetails.onRendered(function () {
|
||||
// A reactive re-render (e.g. a card moving between the inline swimlane render
|
||||
// and the draggable openCards popup) can create and then remove this instance
|
||||
// within the same flush. If our DOM range is already gone, calling this.$()
|
||||
// below throws "Can't select in removed DomRange", so bail out early.
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
this.calculateNextPeak();
|
||||
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
|
||||
// Send Webhook but not create Activities records ---
|
||||
@@ -263,6 +370,23 @@ Template.cardDetails.helpers({
|
||||
return card.findWatcher(Meteor.userId());
|
||||
},
|
||||
|
||||
// Returns the card's locations (multiple supported), each enriched with the
|
||||
// coordinate flag and OpenStreetMap link used by the template.
|
||||
getLocations() {
|
||||
const card = Template.currentData();
|
||||
if (!card || !card.getLocations) return [];
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const provider = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return card.getLocations().map(loc => {
|
||||
const hasCoordinates =
|
||||
typeof loc.latitude === 'number' && typeof loc.longitude === 'number';
|
||||
const mapUrl = hasCoordinates
|
||||
? mapLinkFor(provider, loc.latitude, loc.longitude)
|
||||
: '';
|
||||
return { ...loc, hasCoordinates, mapUrl };
|
||||
});
|
||||
},
|
||||
|
||||
customFieldsGrid() {
|
||||
return ReactiveCache.getCurrentUser().hasCustomFieldsGrid();
|
||||
},
|
||||
@@ -600,9 +724,57 @@ Template.cardDetails.events({
|
||||
'click .js-assignee': Popup.open('cardAssignee'),
|
||||
'click .js-add-assignees': Popup.open('cardAssignees'),
|
||||
'click .js-add-labels': Popup.open('cardLabels'),
|
||||
'click .js-add-stickers': Popup.open('cardStickers'),
|
||||
'click .js-remove-sticker'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const index = parseInt(event.currentTarget.dataset.index, 10);
|
||||
const card = Template.currentData();
|
||||
if (card && !Number.isNaN(index)) {
|
||||
card.removeStickerAt(index);
|
||||
}
|
||||
},
|
||||
'click .js-add-location'(event) {
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-edit-location'(event) {
|
||||
// Let the "Open in map" link work without also opening the edit popup.
|
||||
if ($(event.target).closest('a.card-location-map').length) return;
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(event.currentTarget.dataset.locationId || null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-remove-location'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const locationId = event.currentTarget.dataset.locationId;
|
||||
const card = Template.currentData();
|
||||
if (card && locationId) {
|
||||
card.removeLocation(locationId);
|
||||
}
|
||||
},
|
||||
'click .js-received-date': Popup.open('editCardReceivedDate'),
|
||||
'click .js-start-date': Popup.open('editCardStartDate'),
|
||||
'click .js-due-date': Popup.open('editCardDueDate'),
|
||||
'click .js-toggle-due-complete'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
card.setDueComplete(!card.getDueComplete());
|
||||
},
|
||||
'click .js-end-date': Popup.open('editCardEndDate'),
|
||||
'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
|
||||
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
|
||||
@@ -1134,6 +1306,109 @@ Template.cardMembersPopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// Popup that adds or edits a single card location (name, address, latitude,
|
||||
// longitude). Cards can hold multiple locations, like members.
|
||||
Template.cardLocationsPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
this.detectMsg = new ReactiveVar('');
|
||||
this.mapSavedMsg = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.helpers({
|
||||
location() {
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
const found = card.getLocations().find(loc => loc._id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
detectMessage() {
|
||||
return Template.instance().detectMsg.get();
|
||||
},
|
||||
isMapProvider(provider) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const current = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return current === provider;
|
||||
},
|
||||
mapSavedMessage() {
|
||||
return Template.instance().mapSavedMsg.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.events({
|
||||
'click .js-detect-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const linkInput = tpl.find('.js-location-map-link');
|
||||
const parsed = parseMapLink(linkInput ? linkInput.value : '');
|
||||
let filled = false;
|
||||
if (typeof parsed.latitude === 'number') {
|
||||
tpl.find('.js-location-latitude').value = parsed.latitude;
|
||||
tpl.find('.js-location-longitude').value = parsed.longitude;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.name) {
|
||||
tpl.find('.js-location-name').value = parsed.name;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.address) {
|
||||
tpl.find('.js-location-address').value = parsed.address;
|
||||
filled = true;
|
||||
}
|
||||
tpl.detectMsg.set(
|
||||
TAPi18n.__(filled ? 'location-detect-done' : 'location-detect-none'),
|
||||
);
|
||||
},
|
||||
'submit .js-card-location-form'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
if (!card) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const name = tpl.find('.js-location-name').value.trim();
|
||||
const address = tpl.find('.js-location-address').value.trim();
|
||||
const latRaw = tpl.find('.js-location-latitude').value.trim();
|
||||
const lonRaw = tpl.find('.js-location-longitude').value.trim();
|
||||
const latitude = latRaw === '' ? undefined : parseFloat(latRaw);
|
||||
const longitude = lonRaw === '' ? undefined : parseFloat(lonRaw);
|
||||
const data = { name, address, latitude, longitude };
|
||||
const id = editingLocationId.get();
|
||||
if (id) {
|
||||
card.updateLocation(id, data);
|
||||
} else {
|
||||
card.addLocation(data);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-delete-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
card.removeLocation(id);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-save-map-provider'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const select = tpl.find('.js-map-provider');
|
||||
const provider = select ? select.value : 'openstreetmap';
|
||||
Meteor.call('setMapProvider', provider, err => {
|
||||
tpl.mapSavedMsg.set(
|
||||
TAPi18n.__(err ? 'server-error' : 'map-provider-saved'),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filterMembers = (filterTerm) => {
|
||||
let currBoard = Utils.getCurrentBoard();
|
||||
let members = currBoard.activeMembers();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
template(name="cardStickersPopup")
|
||||
.card-stickers-picker.clearfix
|
||||
each stickerIcons
|
||||
a.card-sticker-select.js-select-sticker(class="{{#if isSelected}}active{{/if}}" title="{{stickerTitle}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if isSelected
|
||||
i.fa.fa-check.card-sticker-check
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { STICKER_PICKER } from '/models/metadata/stickers';
|
||||
|
||||
// Picker popup that lets a user add/remove stickers on a card. Each entry is
|
||||
// { icon, highlight?, name? }: the plain Font Awesome icons plus the mascot
|
||||
// (underlined) and computer (ringed) highlighted stickers. Opened from the card
|
||||
// details stickers section with the card as the data context.
|
||||
|
||||
Template.cardStickersPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
});
|
||||
|
||||
function pickerCard(tpl) {
|
||||
return ReactiveCache.getCard(tpl.cardId);
|
||||
}
|
||||
|
||||
Template.cardStickersPopup.helpers({
|
||||
stickerIcons() {
|
||||
return STICKER_PICKER;
|
||||
},
|
||||
isSelected() {
|
||||
const card = pickerCard(Template.instance());
|
||||
return !!(card && card.hasSticker(this.icon, this.highlight));
|
||||
},
|
||||
stickerTitle() {
|
||||
return this.name || this.icon;
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardStickersPopup.events({
|
||||
'click .js-select-sticker'(event) {
|
||||
event.preventDefault();
|
||||
const card = pickerCard(Template.instance());
|
||||
if (card) {
|
||||
card.toggleSticker(this.icon, this.highlight, this.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -28,6 +28,18 @@
|
||||
.minicard-wrapper .multi-selection-checkbox + .minicard {
|
||||
margin-left: 1vw;
|
||||
}
|
||||
/* Enlarge the clickable area of the small selection checkbox so it can be
|
||||
reliably clicked at the left side of the minicard. The visible 13px box (and
|
||||
its rotated is-checked checkmark) keep their size; an invisible overlay only
|
||||
extends the hit target. */
|
||||
.minicard-wrapper .multi-selection-checkbox::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
@media only screen {
|
||||
.minicard {
|
||||
padding: 8px 1vw 3px;
|
||||
@@ -822,3 +834,35 @@
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the minicard title */
|
||||
.minicard-title .minicard-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
/* Nudge down so the checkmark sits at the vertical middle of the title text
|
||||
(the checked materialCheckBox is shifted up 4px by its own positioning). */
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.minicard-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.minicard-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ template(name="minicard")
|
||||
+minicardStartDate
|
||||
if showDue
|
||||
if getDue
|
||||
.date
|
||||
.date(class="{{#if getDueComplete}}due-complete{{/if}}")
|
||||
+minicardDueDate
|
||||
if showEnd
|
||||
if getEnd
|
||||
@@ -54,6 +54,9 @@ template(name="minicard")
|
||||
span {{_ 'upload-completed'}}
|
||||
|
||||
.minicard-title
|
||||
if canModifyCard
|
||||
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
|
||||
.parent-prefix
|
||||
| {{ parentString ' > ' }}
|
||||
@@ -152,6 +155,11 @@ template(name="minicard")
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
.badges
|
||||
if stickers.length
|
||||
.badge.badge-state-image-only.minicard-stickers
|
||||
each stickers
|
||||
span.badge-icon.minicard-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if comments.length
|
||||
.badge(title="{{_ 'card-comments-title' comments.length }}")
|
||||
span.badge-icon.badge-comment.badge-text
|
||||
|
||||
@@ -113,10 +113,11 @@ Template.minicard.helpers({
|
||||
if (!this.coverId) return null;
|
||||
const attachment = ReactiveCache.getAttachment(this.coverId);
|
||||
if (!attachment) return null;
|
||||
const coverId = this.coverId;
|
||||
const coverLink = typeof attachment.link === 'function' ? attachment.link() : '';
|
||||
if (!coverLink) return null;
|
||||
return {
|
||||
link() {
|
||||
return `/cdn/storage/attachments/${coverId}`;
|
||||
return coverLink;
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -177,6 +178,16 @@ Template.minicard.events({
|
||||
else if (this.isLinkedBoard())
|
||||
Utils.goBoardId(this.linkedId);
|
||||
},
|
||||
'click .js-toggle-card-complete'(event) {
|
||||
// Trello-style "mark complete" toggle (left of the title). Don't let the
|
||||
// click open the card.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) {
|
||||
return;
|
||||
}
|
||||
this.setDueComplete(!this.getDueComplete());
|
||||
},
|
||||
'click .js-toggle-minicard-label-text'() {
|
||||
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
|
||||
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.minicard-title .minicard-complete-toggle {
|
||||
display: none;
|
||||
}
|
||||
@@ -139,6 +139,9 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-item:hover {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
.subtasks-item .check-box-container {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.subtasks-item .check-box {
|
||||
margin: 0.1em 0 0 0;
|
||||
}
|
||||
|
||||
@@ -74,14 +74,13 @@ template(name="subtasksItems")
|
||||
template(name='subtaskItemDetail')
|
||||
.js-subtasks-item.subtasks-item
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
|
||||
@@ -119,7 +119,7 @@ Template.subtasks.helpers({
|
||||
});
|
||||
|
||||
Template.subtaskItemDetail.events({
|
||||
async 'click .js-subtasks-item .check-box-unicode'() {
|
||||
async 'click .js-subtasks-item .check-box-container'() {
|
||||
const item = Template.currentData().item;
|
||||
if (item && item._id) {
|
||||
await item.toggleItem();
|
||||
|
||||
Vendored
+116
@@ -49,3 +49,119 @@ a.show-mapping {
|
||||
font-size: 90%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Trello API import: file rows, workspace tree, job progress and errors */
|
||||
/* The shared page spinners are centered with a large top margin (e.g. bounce
|
||||
uses margin:100px auto), which leaves a big empty gap above the spinner in
|
||||
this compact panel. Make spinners here small and inline instead. */
|
||||
.trello-api-import .sk-spinner {
|
||||
margin: 0 8px 0 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
}
|
||||
.trello-api-import .import-file-row label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
.trello-api-import .import-file-row input,
|
||||
.trello-api-import .import-file-row select {
|
||||
width: 100%;
|
||||
}
|
||||
.trello-workspaces {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.trello-workspace {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-workspace .trello-boards {
|
||||
margin-left: 22px;
|
||||
}
|
||||
.trello-workspace-title,
|
||||
.trello-board {
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.trello-workspace-title .materialCheckBox,
|
||||
.trello-board .materialCheckBox {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.trello-select-controls button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.trello-job-controls button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
/* Import progress meter, styled like Admin Panel / Attachments / Move Attachments */
|
||||
.trello-progress-label {
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.trello-progress-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 8px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trello-progress-fill {
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
.trello-progress-fill.is-running::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
animation: trelloProgressShimmer 2s infinite;
|
||||
}
|
||||
@keyframes trelloProgressShimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
.trello-progress-percent {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-import-result.is-ok {
|
||||
color: #3cb500;
|
||||
}
|
||||
.trello-import-result.is-error {
|
||||
color: #eb4646;
|
||||
}
|
||||
.js-trello-errors-text {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Trello API credential Save/Delete buttons */
|
||||
.trello-api-creds-buttons button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.trello-creds-saved {
|
||||
color: #3cb500;
|
||||
}
|
||||
|
||||
/* Simplified per-field hint text under the Trello file upload labels */
|
||||
.import-file-row .import-file-hint {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ template(name="import")
|
||||
.wrapper
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if zipImporting
|
||||
.warning {{_ 'import-trello-zip-progress'}}
|
||||
+Template.dynamic(template=currentTemplate)
|
||||
|
||||
template(name="importTextarea")
|
||||
@@ -15,7 +17,115 @@ template(name="importTextarea")
|
||||
p: label(for='import-textarea') {{_ instruction}} {{_ 'import-board-instruction-about-errors'}}
|
||||
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
|
||||
| {{jsonText}}
|
||||
if isTrelloImport
|
||||
p.import-file-row
|
||||
label(for='import-json-file') {{_ 'import-trello-json-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-json-file-hint'}}
|
||||
input.js-import-json-file(id='import-json-file' type="file" accept=".json,application/json")
|
||||
p.import-file-row
|
||||
label(for='import-zip-file') {{_ 'import-trello-zip-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-zip-file-hint'}}
|
||||
input.js-import-zip-file(id='import-zip-file' type="file" accept=".zip,application/zip")
|
||||
p.import-file-row
|
||||
label(for='import-workspace-name') {{_ 'import-trello-workspace'}}
|
||||
input.js-import-workspace-name(id='import-workspace-name' type="text" placeholder="{{_ 'import-trello-workspace-placeholder'}}")
|
||||
input.primary.wide(type="submit" value="{{_ 'import'}}")
|
||||
if isTrelloImport
|
||||
hr
|
||||
+importTrelloApi
|
||||
|
||||
template(name="importTrelloApi")
|
||||
.trello-api-import
|
||||
h2 {{_ 'trello-api-import'}}
|
||||
p.quiet {{_ 'trello-api-import-desc'}}
|
||||
p.import-file-row
|
||||
label(for='trello-api-key') {{_ 'trello-api-key'}}
|
||||
input.js-trello-key(id='trello-api-key' type="text" autocomplete="off")
|
||||
p.import-file-row
|
||||
label(for='trello-api-token') {{_ 'trello-api-token'}}
|
||||
input.js-trello-token(id='trello-api-token' type="password" autocomplete="off")
|
||||
p.trello-api-creds-buttons
|
||||
button.primary.js-trello-save-creds(type="button") {{_ 'save'}}
|
||||
button.negate.js-trello-delete-creds(type="button") {{_ 'delete'}}
|
||||
if credsSaved
|
||||
p.quiet.trello-creds-saved
|
||||
i.fa.fa-check
|
||||
span {{_ 'trello-api-credentials-saved'}}
|
||||
p
|
||||
button.primary.js-trello-list-workspaces(type="button") {{_ 'trello-list-workspaces'}}
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if loading.get
|
||||
+spinner
|
||||
if hasWorkspaces
|
||||
p.import-file-row
|
||||
label(for='trello-parent-workspace') {{_ 'import-trello-parent-workspace'}}
|
||||
select.js-trello-parent-workspace(id='trello-parent-workspace')
|
||||
option(value="") {{_ 'trello-parent-workspace-top'}}
|
||||
each flatWorkspaceNodes
|
||||
option(value=id)= label
|
||||
p.trello-select-controls
|
||||
button.primary.js-trello-select-all(type="button") {{_ 'select-all'}}
|
||||
button.primary.js-trello-unselect-all(type="button") {{_ 'unselect-all'}}
|
||||
.trello-workspaces
|
||||
each workspaceList
|
||||
.trello-workspace
|
||||
a.flex.trello-workspace-title.js-toggle-workspace
|
||||
.materialCheckBox(class="{{#if workspaceSelected}}is-checked{{/if}}")
|
||||
b {{name}}
|
||||
.trello-boards
|
||||
each boards
|
||||
a.flex.trello-board.js-toggle-board(data-board-id="{{id}}")
|
||||
.materialCheckBox(class="{{#if boardSelected}}is-checked{{/if}}")
|
||||
span {{name}}
|
||||
if closed
|
||||
span.quiet ({{_ 'archived'}})
|
||||
if canStartImport
|
||||
p
|
||||
button.primary.wide.js-trello-import-selected(type="button") {{_ 'trello-import-selected'}}
|
||||
if currentJob
|
||||
hr
|
||||
.trello-import-job
|
||||
h3 {{_ 'trello-import-progress'}}
|
||||
with currentJob
|
||||
p.trello-progress-label
|
||||
b {{_ 'status'}}:
|
||||
span {{_ status}} ({{jobProgressText}})
|
||||
.trello-progress-bar
|
||||
.trello-progress-fill(class="{{#if $eq status 'running'}}is-running{{/if}}" style="width: {{jobProgressPercent}}%")
|
||||
.trello-progress-percent {{jobProgressPercent}}%
|
||||
if lastError
|
||||
.warning {{lastError}}
|
||||
p.trello-job-controls
|
||||
if jobIsRunning
|
||||
button.primary.js-trello-cancel(type="button") {{_ 'trello-cancel'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobCanResume
|
||||
button.primary.js-trello-resume(type="button") {{_ 'trello-resume'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobIsFinished
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-delete-imported'}}
|
||||
button.primary.js-trello-clear(type="button") {{_ 'trello-clear-job'}}
|
||||
|
||||
if jobResults.length
|
||||
.trello-import-results
|
||||
each jobResults
|
||||
p.trello-import-result(class="{{#if success}}is-ok{{else}}is-error{{/if}}")
|
||||
if success
|
||||
i.fa.fa-check
|
||||
span {{title}} — {{attachmentsImported}} {{_ 'attachments'}}
|
||||
else
|
||||
i.fa.fa-warning
|
||||
span {{trelloBoardId}}: {{this.error}}
|
||||
|
||||
if jobHasErrors
|
||||
.trello-import-errors
|
||||
p
|
||||
b {{_ 'trello-import-errors'}}
|
||||
button.primary.js-trello-copy-errors(type="button") {{_ 'copy-to-clipboard'}}
|
||||
if copied.get
|
||||
span.quiet {{_ 'copied'}}
|
||||
textarea.js-trello-errors-text(readonly rows="8" wrap="off")= jobErrorText
|
||||
|
||||
template(name="importMapMembers")
|
||||
h2 {{_ 'import-map-members'}}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { csvGetMembersToMap } from './csvMembersMapper';
|
||||
import getSlug from 'limax';
|
||||
import { UserSearchIndex } from '/models/users';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import TrelloImportJobs from '/models/trelloImportJobs';
|
||||
|
||||
const Papa = require('papaparse');
|
||||
|
||||
@@ -44,6 +46,92 @@ function _prepareAdditionalData(dataObject) {
|
||||
return membersToMap;
|
||||
}
|
||||
|
||||
// All Trello file/text imports are run on the server over HTTP instead of the
|
||||
// DDP `importBoard` method. This matters for correctness, not just size: Meteor
|
||||
// automatically re-sends an unacknowledged method call on every reconnect, so if
|
||||
// an import is heavy (or the connection hiccups) the WebSocket can enter an
|
||||
// endless drop/retry loop (the "Invalid frame header" flicker). An HTTP request
|
||||
// is never auto-retried, and a .zip's attachment bytes never touch the realtime
|
||||
// connection at all. The body is either a .zip File (application/zip) or a JSON
|
||||
// string { board, membersMapping } (application/json).
|
||||
async function postTrelloImport(body, contentType) {
|
||||
const token =
|
||||
(window.localStorage && window.localStorage.getItem('Meteor.loginToken')) || '';
|
||||
const resp = await fetch('/import-trello', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
let result = {};
|
||||
try {
|
||||
result = await resp.json();
|
||||
} catch (e) {
|
||||
result = {};
|
||||
}
|
||||
if (!resp.ok || result.error) {
|
||||
throw new Error(result.error || 'import-trello-failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// A safe URL slug for an imported board. Trello exports name the board `name`,
|
||||
// WeKan exports use `title`; getSlug (limax) throws on undefined, so guard it.
|
||||
function boardSlug(data) {
|
||||
const raw = (data && (data.title || data.name)) || '';
|
||||
return (raw && getSlug(raw)) || 'imported-board';
|
||||
}
|
||||
|
||||
// Navigate to a freshly server-imported board. The HTTP import (unlike a DDP
|
||||
// method) does not push the new board's documents to this client, so we first
|
||||
// subscribe to the board and wait until its lists/swimlanes/cards are loaded
|
||||
// into Minimongo — otherwise the board opens with an empty Swimlanes view until
|
||||
// the page is reloaded. The subscription is left running so the data stays
|
||||
// available when the board is reopened from All Boards in the same session. A
|
||||
// timeout is the safety net in case the subscription never signals ready.
|
||||
function goToImportedBoard(boardId, slug) {
|
||||
let navigated = false;
|
||||
const go = () => {
|
||||
if (navigated) return;
|
||||
navigated = true;
|
||||
FlowRouter.go('board', { id: boardId, slug });
|
||||
};
|
||||
Meteor.subscribe('board', boardId, false, { onReady: go });
|
||||
Meteor.setTimeout(go, 5000);
|
||||
}
|
||||
|
||||
// Find a workspace node by name anywhere in the user's personal workspace tree.
|
||||
function findWorkspaceByName(nodes, name) {
|
||||
for (const node of nodes || []) {
|
||||
if (node.name === name) return node;
|
||||
if (node.children) {
|
||||
const found = findWorkspaceByName(node.children, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign an imported board to a personal workspace named `wsName`, creating the
|
||||
// workspace (under an optional parent) only if one with that name doesn't exist.
|
||||
function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||||
const existing = findWorkspaceByName(tree, wsName);
|
||||
if (existing) {
|
||||
Meteor.call('assignBoardToWorkspace', boardId, existing.id);
|
||||
return;
|
||||
}
|
||||
Meteor.call('createWorkspace', { parentId, name: wsName }, (err, node) => {
|
||||
if (!err && node) {
|
||||
Meteor.call('assignBoardToWorkspace', boardId, node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Template.import.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.steps = ['importTextarea', 'importMapMembers'];
|
||||
@@ -51,6 +139,8 @@ Template.import.onCreated(function () {
|
||||
this.importedData = new ReactiveVar();
|
||||
this.membersToMap = new ReactiveVar([]);
|
||||
this.importSource = Session.get('importSource');
|
||||
// True while a Trello .zip package is being uploaded/imported server-side.
|
||||
this.zipImporting = new ReactiveVar(false);
|
||||
|
||||
this.nextStep = () => {
|
||||
const nextStepIndex = this._currentStepIndex.get() + 1;
|
||||
@@ -65,10 +155,10 @@ Template.import.onCreated(function () {
|
||||
this.error.set(error);
|
||||
};
|
||||
|
||||
this.importData = (evt, dataSource) => {
|
||||
this.importData = async (evt, dataSource) => {
|
||||
evt.preventDefault();
|
||||
const input = this.find('.js-import-json').value;
|
||||
if (dataSource === 'csv') {
|
||||
const input = this.find('.js-import-json').value;
|
||||
const csv = input.indexOf('\t') > 0 ? input.replace(/(\t)/g, ',') : input;
|
||||
const ret = Papa.parse(csv);
|
||||
if (ret && ret.data && ret.data.length) this.importedData.set(ret.data);
|
||||
@@ -76,52 +166,136 @@ Template.import.onCreated(function () {
|
||||
const membersToMap = _prepareAdditionalData(ret.data);
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} else {
|
||||
try {
|
||||
const dataObject = JSON.parse(input);
|
||||
this.setError('');
|
||||
this.importedData.set(dataObject);
|
||||
const membersToMap = _prepareAdditionalData(dataObject);
|
||||
// store members data and mapping in Session
|
||||
// (we go deep and 2-way, so storing in data context is not a viable option)
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
return;
|
||||
}
|
||||
// Trello: a .zip package (one or more board .json files plus per-board
|
||||
// attachment subdirectories from the Trello Attachments Downloader) imports
|
||||
// all of its boards at once, through a separate field from the single .json.
|
||||
if (dataSource === 'trello') {
|
||||
const zipEl = this.find('.js-import-zip-file');
|
||||
if (zipEl && zipEl.files && zipEl.files[0]) {
|
||||
await this.importTrelloZip(zipEl.files[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// A single board: JSON may come from an uploaded .json file (large Trello
|
||||
// exports are awkward to paste) or from the textarea.
|
||||
let input = this.find('.js-import-json').value;
|
||||
const jsonFileEl = this.find('.js-import-json-file');
|
||||
if (jsonFileEl && jsonFileEl.files && jsonFileEl.files[0]) {
|
||||
input = await jsonFileEl.files[0].text();
|
||||
}
|
||||
const dataObject = JSON.parse(input);
|
||||
this.setError('');
|
||||
|
||||
// Trello: remember the target personal-workspace name for finishImport.
|
||||
this.workspaceName = '';
|
||||
if (dataSource === 'trello') {
|
||||
const wsEl = this.find('.js-import-workspace-name');
|
||||
this.workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
|
||||
}
|
||||
|
||||
this.importedData.set(dataObject);
|
||||
const membersToMap = _prepareAdditionalData(dataObject);
|
||||
// store members data and mapping in Session
|
||||
// (we go deep and 2-way, so storing in data context is not a viable option)
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
}
|
||||
};
|
||||
|
||||
this.finishImport = () => {
|
||||
const additionalData = {};
|
||||
// Upload a Trello .zip package to the server, which extracts it (with
|
||||
// zip-bomb / path-traversal guards), imports every board and streams each
|
||||
// attachment to the Default storage, then go to All Boards.
|
||||
this.importTrelloZip = async (zipFile) => {
|
||||
this.setError('');
|
||||
const wsEl = this.find('.js-import-workspace-name');
|
||||
const workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
|
||||
|
||||
this.zipImporting.set(true);
|
||||
let result;
|
||||
try {
|
||||
result = await postTrelloImport(zipFile, 'application/zip');
|
||||
} catch (e) {
|
||||
this.zipImporting.set(false);
|
||||
this.setError((e && e.message) || 'import-trello-failed');
|
||||
return;
|
||||
}
|
||||
this.zipImporting.set(false);
|
||||
|
||||
(result.boardIds || []).forEach(boardId => {
|
||||
if (workspaceName) {
|
||||
assignBoardToNamedWorkspace(boardId, workspaceName);
|
||||
}
|
||||
});
|
||||
Session.set('fromBoard', null);
|
||||
// Go to All Boards, where the newly imported boards appear.
|
||||
FlowRouter.go('home');
|
||||
};
|
||||
|
||||
this.finishImport = async () => {
|
||||
const membersMapping = this.membersToMap.get();
|
||||
const mappingById = {};
|
||||
if (membersMapping) {
|
||||
const mappingById = {};
|
||||
membersMapping.forEach(member => {
|
||||
if (member.wekanId) {
|
||||
mappingById[member.id] = member.wekanId;
|
||||
}
|
||||
});
|
||||
additionalData.membersMapping = mappingById;
|
||||
}
|
||||
const importedData = this.importedData.get();
|
||||
|
||||
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
|
||||
// connection is never used for the board payload and can't enter the
|
||||
// drop/retry "Invalid frame header" flicker loop. Do NOT mutate the still-
|
||||
// mounted map-members template (e.g. clearing membersToMap) before
|
||||
// navigating away — that forces an empty re-render mid-teardown and can
|
||||
// throw "Can't select in removed DomRange". We navigate away, which
|
||||
// destroys the import templates.
|
||||
if (this.importSource === 'trello') {
|
||||
let result;
|
||||
try {
|
||||
result = await postTrelloImport(
|
||||
JSON.stringify({ board: importedData, membersMapping: mappingById }),
|
||||
'application/json',
|
||||
);
|
||||
} catch (e) {
|
||||
this.setError((e && e.message) || 'import-trello-failed');
|
||||
return;
|
||||
}
|
||||
const boardId = (result.boardIds || [])[0];
|
||||
if (!boardId) {
|
||||
this.setError('import-trello-failed');
|
||||
return;
|
||||
}
|
||||
Session.set('fromBoard', null);
|
||||
if (this.workspaceName) {
|
||||
assignBoardToNamedWorkspace(boardId, this.workspaceName);
|
||||
}
|
||||
goToImportedBoard(boardId, boardSlug(importedData));
|
||||
return;
|
||||
}
|
||||
|
||||
// wekan / csv: unchanged DDP import.
|
||||
this.membersToMap.set([]);
|
||||
Meteor.call(
|
||||
'importBoard',
|
||||
this.importedData.get(),
|
||||
additionalData,
|
||||
importedData,
|
||||
{ membersMapping: mappingById },
|
||||
this.importSource,
|
||||
Session.get('fromBoard'),
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
this.setError(err.error);
|
||||
} else {
|
||||
let title = getSlug(this.importedData.get().title) || 'imported-board';
|
||||
Session.set('fromBoard', null);
|
||||
FlowRouter.go('board', {
|
||||
id: res,
|
||||
slug: title,
|
||||
slug: boardSlug(importedData),
|
||||
});
|
||||
//Utils.goBoardId(res);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -135,6 +309,9 @@ Template.import.helpers({
|
||||
currentTemplate() {
|
||||
return Template.instance().steps[Template.instance()._currentStepIndex.get()];
|
||||
},
|
||||
zipImporting() {
|
||||
return Template.instance().zipImporting.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.helpers({
|
||||
@@ -149,6 +326,9 @@ Template.importTextarea.helpers({
|
||||
return 'import-json-placeholder';
|
||||
}
|
||||
},
|
||||
isTrelloImport() {
|
||||
return Session.get('importSource') === 'trello';
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.events({
|
||||
@@ -320,7 +500,11 @@ Template.importMapMembersAddPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.importMapMembersAddPopup.onRendered(function () {
|
||||
this.find('.js-search-member-input').focus();
|
||||
// Guard against the DOM range being gone (e.g. the popup was closed during a
|
||||
// re-render) — calling find/$ then throws "Can't select in removed DomRange".
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
const input = this.find('.js-search-member-input');
|
||||
if (input) input.focus();
|
||||
});
|
||||
|
||||
Template.importMapMembersAddPopup.onDestroyed(function () {
|
||||
@@ -380,3 +564,310 @@ Template.importMapMembersAddPopup.helpers({
|
||||
return importMemberPopupState.noResults;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Trello API import: key/token -> list workspaces & boards -> import
|
||||
// selected boards (with attachments) server-side, placing each under a
|
||||
// personal workspace named after its Trello workspace.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function flattenWorkspaceTree(nodes, depth = 0, acc = []) {
|
||||
(nodes || []).forEach(node => {
|
||||
acc.push({ id: node.id, label: `${'— '.repeat(depth)}${node.name}` });
|
||||
if (node.children && node.children.length) {
|
||||
flattenWorkspaceTree(node.children, depth + 1, acc);
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Build the copy-paste-friendly error text for a job: the error log plus a
|
||||
// summary line per failed board.
|
||||
function jobErrorText(job) {
|
||||
if (!job) return '';
|
||||
const lines = [];
|
||||
(job.errorLog || []).forEach(line => lines.push(line));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
Template.importTrelloApi.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
this.workspaces = new ReactiveVar([]);
|
||||
this.copied = new ReactiveVar(false);
|
||||
// Board selection state: map of trelloBoardId -> true. Tracked here (rather
|
||||
// than via DOM checkboxes) because the UI uses animated .materialCheckBox
|
||||
// elements, not native inputs.
|
||||
this.selectedBoards = new ReactiveVar({});
|
||||
// The import itself runs server-side as a persisted job; watch it reactively
|
||||
// so progress survives navigating away and back.
|
||||
this.subscribe('trelloImportJobs');
|
||||
});
|
||||
|
||||
// Collect every board id across all listed workspaces.
|
||||
function allBoardIds(workspaces) {
|
||||
const ids = {};
|
||||
(workspaces || []).forEach(ws => {
|
||||
(ws.boards || []).forEach(b => {
|
||||
ids[b.id] = true;
|
||||
});
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
Template.importTrelloApi.helpers({
|
||||
error() {
|
||||
return Template.instance().error;
|
||||
},
|
||||
loading() {
|
||||
return Template.instance().loading;
|
||||
},
|
||||
copied() {
|
||||
return Template.instance().copied;
|
||||
},
|
||||
hasWorkspaces() {
|
||||
return Template.instance().workspaces.get().length > 0;
|
||||
},
|
||||
workspaceList() {
|
||||
return Template.instance().workspaces.get();
|
||||
},
|
||||
boardSelected() {
|
||||
return !!Template.instance().selectedBoards.get()[this.id];
|
||||
},
|
||||
workspaceSelected() {
|
||||
const sel = Template.instance().selectedBoards.get();
|
||||
const boards = this.boards || [];
|
||||
return boards.length > 0 && boards.every(b => sel[b.id]);
|
||||
},
|
||||
flatWorkspaceNodes() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||||
return flattenWorkspaceTree(tree);
|
||||
},
|
||||
credsSaved() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return !!(user && user.profile && user.profile.trelloApiSaved);
|
||||
},
|
||||
|
||||
// --- current background job ---
|
||||
currentJob() {
|
||||
return TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
},
|
||||
jobIsRunning() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && job.status === 'running';
|
||||
},
|
||||
jobCanResume() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && (job.status === 'paused' || job.status === 'error');
|
||||
},
|
||||
jobIsFinished() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && (job.status === 'done' || job.status === 'cancelled');
|
||||
},
|
||||
canStartImport() {
|
||||
// Don't start a second import while one is active (running/paused/error),
|
||||
// which would create a hidden concurrent job.
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return !job || job.status === 'done' || job.status === 'cancelled';
|
||||
},
|
||||
jobProgressText() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return '';
|
||||
return `${job.currentIndex} / ${job.total}`;
|
||||
},
|
||||
jobProgressPercent() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job || !job.total) return 0;
|
||||
return Math.round((job.currentIndex / job.total) * 100);
|
||||
},
|
||||
jobResults() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return (job && job.results) || [];
|
||||
},
|
||||
jobHasErrors() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return !!(job && job.errorLog && job.errorLog.length);
|
||||
},
|
||||
jobErrorText() {
|
||||
return jobErrorText(TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTrelloApi.events({
|
||||
'click .js-trello-save-creds'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
if (!key || !token) {
|
||||
tpl.error.set('trello-api-credentials-required');
|
||||
return;
|
||||
}
|
||||
tpl.error.set('');
|
||||
Meteor.call('saveTrelloCredentials', key, token, err => {
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
return;
|
||||
}
|
||||
// Don't keep the token sitting in the browser; it now lives server-side.
|
||||
tpl.find('.js-trello-key').value = '';
|
||||
tpl.find('.js-trello-token').value = '';
|
||||
});
|
||||
},
|
||||
'click .js-trello-delete-creds'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.error.set('');
|
||||
Meteor.call('deleteTrelloCredentials', err => {
|
||||
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
});
|
||||
tpl.find('.js-trello-key').value = '';
|
||||
tpl.find('.js-trello-token').value = '';
|
||||
},
|
||||
'click .js-trello-list-workspaces'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
// key/token may be empty when saved credentials exist; the server falls
|
||||
// back to the saved ones and returns an error if neither is available.
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
tpl.error.set('');
|
||||
tpl.loading.set(true);
|
||||
Meteor.call('trelloListWorkspaces', key, token, (err, res) => {
|
||||
tpl.loading.set(false);
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
tpl.workspaces.set([]);
|
||||
tpl.selectedBoards.set({});
|
||||
} else {
|
||||
const workspaces = res || [];
|
||||
tpl.workspaces.set(workspaces);
|
||||
// Preselect all boards by default.
|
||||
tpl.selectedBoards.set(allBoardIds(workspaces));
|
||||
}
|
||||
});
|
||||
},
|
||||
// Toggle a single board's animated checkbox.
|
||||
'click .js-toggle-board'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const id = this.id;
|
||||
const sel = { ...tpl.selectedBoards.get() };
|
||||
if (sel[id]) {
|
||||
delete sel[id];
|
||||
} else {
|
||||
sel[id] = true;
|
||||
}
|
||||
tpl.selectedBoards.set(sel);
|
||||
},
|
||||
// Toggle all boards in a workspace: if all are selected, clear them; else
|
||||
// select them all.
|
||||
'click .js-toggle-workspace'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const boards = this.boards || [];
|
||||
const sel = { ...tpl.selectedBoards.get() };
|
||||
const allSelected = boards.length > 0 && boards.every(b => sel[b.id]);
|
||||
boards.forEach(b => {
|
||||
if (allSelected) {
|
||||
delete sel[b.id];
|
||||
} else {
|
||||
sel[b.id] = true;
|
||||
}
|
||||
});
|
||||
tpl.selectedBoards.set(sel);
|
||||
},
|
||||
'click .js-trello-select-all'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.selectedBoards.set(allBoardIds(tpl.workspaces.get()));
|
||||
},
|
||||
'click .js-trello-unselect-all'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.selectedBoards.set({});
|
||||
},
|
||||
'click .js-trello-import-selected'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
const sel = tpl.selectedBoards.get();
|
||||
const boardIds = Object.keys(sel).filter(id => sel[id]);
|
||||
if (!boardIds.length) {
|
||||
tpl.error.set('trello-select-boards');
|
||||
return;
|
||||
}
|
||||
const parentEl = tpl.find('.js-trello-parent-workspace');
|
||||
const parentId = parentEl && parentEl.value ? parentEl.value : null;
|
||||
|
||||
tpl.error.set('');
|
||||
// Start the server-side job; progress shows up via the subscription. The
|
||||
// user is free to navigate away and come back.
|
||||
Meteor.call('trelloStartImport', key, token, boardIds, parentId, err => {
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
}
|
||||
});
|
||||
},
|
||||
'click .js-trello-resume'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
if (!key || !token) {
|
||||
tpl.error.set('trello-api-credentials-required');
|
||||
return;
|
||||
}
|
||||
tpl.error.set('');
|
||||
Meteor.call('trelloResumeImport', job._id, key, token, err => {
|
||||
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
});
|
||||
},
|
||||
'click .js-trello-cancel'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
Meteor.call('trelloCancelImport', job._id, false);
|
||||
},
|
||||
'click .js-trello-cancel-delete'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(TAPi18n.__('trello-cancel-delete-confirm'))) return;
|
||||
Meteor.call('trelloCancelImport', job._id, true);
|
||||
},
|
||||
'click .js-trello-clear'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
Meteor.call('trelloClearImportJob', job._id, false);
|
||||
},
|
||||
'click .js-trello-copy-errors'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
const text = jobErrorText(job);
|
||||
if (!text) return;
|
||||
const done = () => {
|
||||
tpl.copied.set(true);
|
||||
setTimeout(() => tpl.copied.set(false), 2000);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, () => {
|
||||
// Fall back to selecting the textarea so the user can copy manually.
|
||||
const ta = tpl.find('.js-trello-errors-text');
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const ta = tpl.find('.js-trello-errors-text');
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
done();
|
||||
} catch (e) {
|
||||
// user can copy manually from the selected textarea
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -4,15 +4,24 @@ export function wekanGetMembersToMap(data) {
|
||||
// we will work on the list itself (an ordered array of objects) when a
|
||||
// mapping is done, we add a 'wekan' field to the object representing the
|
||||
// imported member
|
||||
const membersToMap = data.members;
|
||||
const users = data.users;
|
||||
const membersToMap = data.members || [];
|
||||
const users = data.users || [];
|
||||
// auto-map based on username
|
||||
const mappable = [];
|
||||
membersToMap.forEach(importedMember => {
|
||||
importedMember.id = importedMember.userId;
|
||||
delete importedMember.userId;
|
||||
const user = users.filter(user => {
|
||||
return user._id === importedMember.id;
|
||||
})[0];
|
||||
// The exported `users` array only contains users that still exist in the
|
||||
// source instance, so a member whose account was deleted (or any other
|
||||
// dangling user reference, e.g. from an older export) has no matching
|
||||
// user. Skip it instead of dereferencing `undefined`, which would throw
|
||||
// and make the whole import fail as "error-json-malformed".
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
if (user.profile && user.profile.fullname) {
|
||||
importedMember.fullName = user.profile.fullname;
|
||||
}
|
||||
@@ -21,6 +30,7 @@ export function wekanGetMembersToMap(data) {
|
||||
if (wekanUser) {
|
||||
importedMember.wekanId = wekanUser._id;
|
||||
}
|
||||
mappable.push(importedMember);
|
||||
});
|
||||
return membersToMap;
|
||||
return mappable;
|
||||
}
|
||||
@@ -38,6 +38,15 @@ Template.list.onRendered(function () {
|
||||
const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
|
||||
const $cards = this.$('.js-minicards');
|
||||
|
||||
// Destroy any existing sortable before re-initializing. Without this, a
|
||||
// re-render of the list (e.g. when a Card Details panel opens) binds a SECOND
|
||||
// sortable to the same .js-minicards element, so dragging a card from its
|
||||
// handle fires the `stop` handler twice and the card appears to duplicate.
|
||||
// Mirrors the guard in swimlanes.js initSortable().
|
||||
if ($cards.data('uiSortable') || $cards.data('sortable')) {
|
||||
$cards.sortable('destroy');
|
||||
}
|
||||
|
||||
$cards.sortable({
|
||||
connectWith: '.js-minicards:not(.js-list-full)',
|
||||
tolerance: 'pointer',
|
||||
@@ -345,9 +354,19 @@ Template.list.onCreated(function () {
|
||||
return 550;
|
||||
};
|
||||
|
||||
// Read the horizontal page coordinate from either a jQuery mouse event
|
||||
// or a native touch event (touchstart/move expose `touches`, touchend
|
||||
// exposes `changedTouches`).
|
||||
const getEventPageX = (e) => {
|
||||
const oe = e.originalEvent || e;
|
||||
if (oe.touches && oe.touches.length) return oe.touches[0].pageX;
|
||||
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageX;
|
||||
return e.pageX;
|
||||
};
|
||||
|
||||
const startResize = (e) => {
|
||||
isResizing = true;
|
||||
startX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
startX = getEventPageX(e);
|
||||
startWidth = $list.outerWidth();
|
||||
|
||||
// Add visual feedback
|
||||
@@ -366,7 +385,7 @@ Template.list.onCreated(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const newWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
|
||||
@@ -390,7 +409,7 @@ Template.list.onCreated(function () {
|
||||
isResizing = false;
|
||||
|
||||
// Calculate final width
|
||||
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const finalWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
const listConstraint = getListConstraint();
|
||||
@@ -468,10 +487,15 @@ Template.list.onCreated(function () {
|
||||
$(document).on('mousemove', doResize);
|
||||
$(document).on('mouseup', stopResize);
|
||||
|
||||
// Touch events for mobile
|
||||
$resizeHandle.on('touchstart', startResize, { passive: false });
|
||||
$(document).on('touchmove', doResize, { passive: false });
|
||||
$(document).on('touchend', stopResize, { passive: false });
|
||||
// Touch events for mobile.
|
||||
// NOTE: jQuery's .on() does not accept an addEventListener-style options
|
||||
// object — passing { passive: false } made jQuery bind the object itself
|
||||
// as the handler, throwing "handler.apply is not a function" on every
|
||||
// touch. Use the native API so { passive: false } actually applies and
|
||||
// preventDefault() works during the resize.
|
||||
$resizeHandle[0].addEventListener('touchstart', startResize, { passive: false });
|
||||
document.addEventListener('touchmove', doResize, { passive: false });
|
||||
document.addEventListener('touchend', stopResize, { passive: false });
|
||||
|
||||
// Prevent dragscroll interference
|
||||
$resizeHandle.on('mousedown', (e) => {
|
||||
@@ -492,8 +516,8 @@ Template.list.onCreated(function () {
|
||||
tpl.view.onViewDestroyed(() => {
|
||||
$(document).off('mousemove', doResize);
|
||||
$(document).off('mouseup', stopResize);
|
||||
$(document).off('touchmove', doResize);
|
||||
$(document).off('touchend', stopResize);
|
||||
document.removeEventListener('touchmove', doResize);
|
||||
document.removeEventListener('touchend', stopResize);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -194,6 +194,19 @@ Template.listBody.onCreated(function () {
|
||||
const clickedLinkedReference = $target.closest('.js-linked-link').length > 0;
|
||||
const clickedChecklist = $target.closest('.minicard-checklist').length > 0;
|
||||
|
||||
// When multi-selection is active (or Shift is held), a click anywhere on the
|
||||
// minicard toggles its selection. This must be checked before the title and
|
||||
// checklist handlers below, otherwise clicking a card's title (which covers
|
||||
// most of the minicard) would open the card details instead of (de)selecting
|
||||
// it, making it impossible to check some cards in multi-selection mode.
|
||||
if (MultiSelection.isActive() || evt.shiftKey) {
|
||||
evt.stopImmediatePropagation();
|
||||
evt.preventDefault();
|
||||
const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
|
||||
MultiSelection[methodName](card._id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedChecklist) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
@@ -214,16 +227,7 @@ Template.listBody.onCreated(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (MultiSelection.isActive() || evt.shiftKey) {
|
||||
evt.stopImmediatePropagation();
|
||||
evt.preventDefault();
|
||||
const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
|
||||
MultiSelection[methodName](card._id);
|
||||
|
||||
// If the card is already selected, we want to de-select it.
|
||||
// XXX We should probably modify the minicard href attribute instead of
|
||||
// overwriting the event in case the card is already selected.
|
||||
} else if (Utils.isMiniScreen()) {
|
||||
if (Utils.isMiniScreen()) {
|
||||
evt.preventDefault();
|
||||
Session.set('popupCardId', card._id);
|
||||
this.cardDetailsPopup(evt);
|
||||
@@ -244,9 +248,18 @@ Template.listBody.onCreated(function () {
|
||||
};
|
||||
|
||||
this.toggleMultiSelection = (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.stopImmediatePropagation();
|
||||
evt.preventDefault();
|
||||
MultiSelection.toggle(Template.currentData()._id);
|
||||
// Resolve the card from the clicked checkbox element. Using
|
||||
// Blaze.getData(evt.currentTarget) is reliable for the per-card data
|
||||
// context, whereas Template.currentData() can resolve to the enclosing
|
||||
// list context and toggle the wrong id (so the checkbox appears to do
|
||||
// nothing). Falls back to Template.currentData() just in case.
|
||||
const card =
|
||||
Blaze.getData(evt.currentTarget) || Template.currentData();
|
||||
if (card && card._id) {
|
||||
MultiSelection.toggle(card._id);
|
||||
}
|
||||
};
|
||||
|
||||
this.cardDetailsPopup = (event) => {
|
||||
|
||||
@@ -104,6 +104,26 @@
|
||||
height: 24px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
/* On non-board pages (All Boards / home, public, my cards, …) the second bar
|
||||
holds only a title (e.g. "My Boards"). The fixed 40px bar height plus the
|
||||
floated h1 with a 1.7em line-height clipped the bottom of the text on iOS
|
||||
Safari. Vertically center the title in a bar that can grow so it is never
|
||||
cut off. Scoped to non-board-view so the board header (floated buttons) is
|
||||
untouched. */
|
||||
body:not(.board-view) #header #header-main-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
body:not(.board-view) #header #header-main-bar h1 {
|
||||
float: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
#header-quick-access {
|
||||
color: #fff;
|
||||
transition: background-color 0.4s;
|
||||
@@ -157,6 +177,10 @@
|
||||
#header-quick-access i.fa:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
/* Drag-handle toggle icons 30% smaller than the other quick-access icons. */
|
||||
#header-quick-access .js-toggle-desktop-drag-handles .fa {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
#header-quick-access #header-user-bar,
|
||||
#header-quick-access #header-new-board-icon,
|
||||
#header-quick-access ul li {
|
||||
@@ -1155,9 +1179,76 @@
|
||||
display: none;
|
||||
}
|
||||
#header-quick-access #header-user-bar {
|
||||
right: 0px;
|
||||
right: auto;
|
||||
padding: 10px;
|
||||
margin: -8px 0 -10px -10px;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- Small-screen quick-access bar (ALL browsers, width-based) ----
|
||||
These rules use width-based selectors (not the .iphone-device class) so
|
||||
they apply to every browser at small width — Fairphone/Android, narrow
|
||||
desktop and iPhone alike. Keep every item visible: let the bar wrap and
|
||||
grow instead of pinning its height and clipping the overflow. */
|
||||
#header-quick-access {
|
||||
height: auto !important;
|
||||
min-height: 28px !important;
|
||||
overflow: visible !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: center !important;
|
||||
row-gap: 4px !important;
|
||||
}
|
||||
/* The All Boards page scales this bar up to 2em-3em for every browser, which
|
||||
is too big. Tame it to a moderate size so ~7 items fit in two wrapped rows.
|
||||
(.iphone-device variants also cover the board view on detected iPhones.) */
|
||||
body:not(.board-view) #header-quick-access,
|
||||
.wrapper ~ #header-quick-access,
|
||||
.iphone-device #header-quick-access,
|
||||
.iphone-device .wrapper ~ #header-quick-access,
|
||||
.iphone-device body:not(.board-view) #header-quick-access {
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
body:not(.board-view) #header-quick-access .fa,
|
||||
body:not(.board-view) #header-quick-access .icon,
|
||||
.wrapper ~ #header-quick-access .fa,
|
||||
.wrapper ~ #header-quick-access .icon,
|
||||
.iphone-device #header-quick-access .fa,
|
||||
.iphone-device #header-quick-access .icon,
|
||||
.iphone-device .wrapper ~ #header-quick-access .fa,
|
||||
.iphone-device .wrapper ~ #header-quick-access .icon,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .icon {
|
||||
font-size: 1.5em !important;
|
||||
}
|
||||
/* Zoom (100%), drag-handle toggle and mobile/desktop toggle 30% smaller than
|
||||
the other quick-access controls at small width — for all browsers. */
|
||||
body:not(.board-view) #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
body:not(.board-view) #header-quick-access .mobile-mode-toggle .fa,
|
||||
.wrapper ~ #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.wrapper ~ #header-quick-access .mobile-mode-toggle .fa,
|
||||
.iphone-device #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.iphone-device #header-quick-access .mobile-mode-toggle .fa,
|
||||
.iphone-device .wrapper ~ #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.iphone-device .wrapper ~ #header-quick-access .mobile-mode-toggle .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .mobile-mode-toggle .fa {
|
||||
font-size: 1.05em !important; /* ~30% smaller than the 1.5em icons */
|
||||
}
|
||||
body:not(.board-view) #header-quick-access .zoom-controls,
|
||||
body:not(.board-view) #header-quick-access .zoom-controls .zoom-level,
|
||||
body:not(.board-view) #header-quick-access .zoom-controls .zoom-input,
|
||||
.wrapper ~ #header-quick-access .zoom-controls,
|
||||
.wrapper ~ #header-quick-access .zoom-controls .zoom-level,
|
||||
.wrapper ~ #header-quick-access .zoom-controls .zoom-input,
|
||||
.iphone-device #header-quick-access .zoom-controls,
|
||||
.iphone-device #header-quick-access .zoom-controls .zoom-level,
|
||||
.iphone-device #header-quick-access .zoom-controls .zoom-input,
|
||||
.iphone-device .wrapper ~ #header-quick-access .zoom-controls,
|
||||
.iphone-device .wrapper ~ #header-quick-access .zoom-controls .zoom-level,
|
||||
.iphone-device .wrapper ~ #header-quick-access .zoom-controls .zoom-input,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .zoom-controls,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .zoom-controls .zoom-level,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .zoom-controls .zoom-input {
|
||||
font-size: 0.7em !important; /* ~30% smaller than surrounding text */
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
|
||||
@@ -593,12 +593,20 @@ a:not(.disabled).is-active i.fa {
|
||||
}
|
||||
|
||||
#header-quick-access {
|
||||
/* Keep quick-access items in one row */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
/* On narrow screens (e.g. iPhone 12 mini, 375px) the icon buttons cannot
|
||||
shrink below their min-width, so a single nowrap row overflows and the
|
||||
base rule's overflow:hidden clips the right-side items (mobile/desktop
|
||||
toggle, notifications, member/user menu). Allow wrapping to a second row
|
||||
and let the bar grow so nothing is ever hidden. */
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* Hide elements that should move to the hamburger menu on mobile */
|
||||
@@ -607,20 +615,18 @@ a:not(.disabled).is-active i.fa {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show only the home icon (hide the trailing text) on mobile */
|
||||
/* Show the home link with its full "All Boards" text on mobile. The label is
|
||||
a bare text node (not a span), so the old max-width:28px + overflow:hidden
|
||||
clip revealed only "A"; removing it shows the whole label. The bar wraps,
|
||||
so the extra width is fine. */
|
||||
#header-quick-access .home-icon a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 28px; /* enough to display the icon */
|
||||
overflow: hidden;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hide text in home icon on mobile, show only icon */
|
||||
#header-quick-access .home-icon a span:not(.fa) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing for mobile header elements */
|
||||
#header-quick-access .zoom-controls {
|
||||
margin-left: auto;
|
||||
@@ -673,22 +679,18 @@ a:not(.disabled).is-active i.fa {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Admin panel mobile optimization */
|
||||
/* Admin panel mobile optimization.
|
||||
Keep the side-by-side (row) layout from settingBody.css even on narrow
|
||||
screens — do NOT switch to a stacked column layout, which left the menu
|
||||
only 32vw wide with empty space beside it and pushed the content off
|
||||
screen. The side menu keeps a fixed basis and the main body fills the
|
||||
rest, so both stay visible. */
|
||||
.setting-content .content-body {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .side-menu {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body {
|
||||
order: 1;
|
||||
min-height: 60vh;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Search + pagination controls for the admin report tabs (files, rules,
|
||||
boards, cards). Styled to match the admin People panel (peopleBody.css). */
|
||||
|
||||
.admin-report-search {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-report-search input {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
padding: 0 8px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.admin-report-search button {
|
||||
min-width: 90px;
|
||||
height: 34px;
|
||||
background: #01628c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.admin-report-search button:hover {
|
||||
background: #005377;
|
||||
}
|
||||
|
||||
.admin-report-search button:active {
|
||||
background: #004766;
|
||||
}
|
||||
|
||||
.admin-report-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-report-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-report-pagination button.active {
|
||||
background: #01628c;
|
||||
border-color: #01628c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-report-pagination button.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-report-page-info {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
@@ -8,27 +8,27 @@ template(name="adminReports")
|
||||
ul
|
||||
li
|
||||
a.js-report-broken(data-id="report-broken")
|
||||
span.emoji-icon 🔗
|
||||
i.fa.fa-chain-broken
|
||||
| {{_ 'broken-cards'}}
|
||||
|
||||
li
|
||||
a.js-report-files(data-id="report-files")
|
||||
span.emoji-icon 📎
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'filesReportTitle'}}
|
||||
|
||||
li
|
||||
a.js-report-rules(data-id="report-rules")
|
||||
span.emoji-icon ✨
|
||||
i.fa.fa-magic
|
||||
| {{_ 'rulesReportTitle'}}
|
||||
|
||||
li
|
||||
a.js-report-boards(data-id="report-boards")
|
||||
span.emoji-icon ✨
|
||||
i.fa.fa-columns
|
||||
| {{_ 'boardsReportTitle'}}
|
||||
|
||||
li
|
||||
a.js-report-cards(data-id="report-cards")
|
||||
span.emoji-icon ✨
|
||||
i.fa.fa-id-card-o
|
||||
| {{_ 'cardsReportTitle'}}
|
||||
|
||||
.main-body
|
||||
@@ -37,13 +37,13 @@ template(name="adminReports")
|
||||
else if showBrokenCardsReport.get
|
||||
+brokenCardsReport
|
||||
else if showFilesReport.get
|
||||
+filesReport
|
||||
+filesReport(currentPage=filesCurrentPage totalPages=filesTotalPages hasPrevPage=hasFilesPrevPage hasNextPage=hasFilesNextPage)
|
||||
else if showRulesReport.get
|
||||
+rulesReport
|
||||
+rulesReport(currentPage=rulesCurrentPage totalPages=rulesTotalPages hasPrevPage=hasRulesPrevPage hasNextPage=hasRulesNextPage)
|
||||
else if showBoardsReport.get
|
||||
+boardsReport
|
||||
+boardsReport(currentPage=boardsCurrentPage totalPages=boardsTotalPages hasPrevPage=hasBoardsPrevPage hasNextPage=hasBoardsNextPage)
|
||||
else if showCardsReport.get
|
||||
+cardsReport
|
||||
+cardsReport(currentPage=cardsCurrentPage totalPages=cardsTotalPages hasPrevPage=hasCardsPrevPage hasNextPage=hasCardsNextPage)
|
||||
|
||||
|
||||
template(name="brokenCardsReport")
|
||||
@@ -56,6 +56,17 @@ template(name="brokenCardsReport")
|
||||
|
||||
template(name="rulesReport")
|
||||
h1 {{_ 'rulesReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-rules-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-rules-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-rules-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-rules-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table
|
||||
thead
|
||||
@@ -77,6 +88,17 @@ template(name="rulesReport")
|
||||
|
||||
template(name="filesReport")
|
||||
h1 {{_ 'filesReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-files-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-files-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-files-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-files-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table
|
||||
thead
|
||||
@@ -102,6 +124,17 @@ template(name="filesReport")
|
||||
|
||||
template(name="cardsReport")
|
||||
h1 {{_ 'cardsReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-cards-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-cards-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-cards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-cards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table.table
|
||||
thead
|
||||
@@ -127,6 +160,17 @@ template(name="cardsReport")
|
||||
|
||||
template(name="boardsReport")
|
||||
h1 {{_ 'boardsReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-boards-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-boards-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table.table
|
||||
thead
|
||||
|
||||
@@ -3,8 +3,6 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import Attachments, { AttachmentStorage } from '/models/attachments';
|
||||
import { CardSearchPaged } from '/client/lib/cardSearch';
|
||||
import SessionData from '/models/usersessiondata';
|
||||
import { QueryParams } from '/config/query-classes';
|
||||
import { OPERATOR_LIMIT } from '/config/search-const';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
import Rules from '/models/rules';
|
||||
@@ -35,8 +33,12 @@ function abbreviate(text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function collectionResults(collection) {
|
||||
return collection.find();
|
||||
// The report publications already send only the current page (server-side
|
||||
// search + limit/skip, sorted). Display exactly what was published, applying
|
||||
// the same sort so minimongo order matches the server page. Re-applying
|
||||
// skip/limit here would paginate an already-paginated set.
|
||||
function collectionResults(collection, sort) {
|
||||
return collection.find({}, sort ? { sort } : {});
|
||||
}
|
||||
|
||||
function collectionResultsCount(collection) {
|
||||
@@ -45,6 +47,22 @@ function collectionResultsCount(collection) {
|
||||
|
||||
// --- adminReports template ---
|
||||
|
||||
// Rows per page for the paginated reports (files, rules, boards, cards).
|
||||
// Kept in sync with the admin People panel so all admin lists page alike.
|
||||
const REPORTS_PER_PAGE = 25;
|
||||
|
||||
// Static description of each paginated report: which page/count reactive vars
|
||||
// it uses, which publication feeds it and which count method backs it. Lets a
|
||||
// single loadReport() drive all of them the same way.
|
||||
function reportConfig(tmpl) {
|
||||
return {
|
||||
'report-files': { page: tmpl.filesPage, count: tmpl.filesCount, search: tmpl.filesSearch, pub: 'attachmentsList', countMethod: 'getAttachmentsReportCount' },
|
||||
'report-rules': { page: tmpl.rulesPage, count: tmpl.rulesCount, search: tmpl.rulesSearch, pub: 'rulesReport', countMethod: 'getRulesReportCount' },
|
||||
'report-boards': { page: tmpl.boardsPage, count: tmpl.boardsCount, search: tmpl.boardsSearch, pub: 'boardsReport', countMethod: 'getBoardsReportCount' },
|
||||
'report-cards': { page: tmpl.cardsPage, count: tmpl.cardsCount, search: tmpl.cardsSearch, pub: 'cardsReport', countMethod: 'getCardsReportCount' },
|
||||
};
|
||||
}
|
||||
|
||||
Template.adminReports.onCreated(function () {
|
||||
this.subscription = null;
|
||||
this.showFilesReport = new ReactiveVar(false);
|
||||
@@ -56,6 +74,60 @@ Template.adminReports.onCreated(function () {
|
||||
this.sessionId = SessionData.getSessionId();
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
|
||||
// Pagination state, one current-page + total-count pair per report.
|
||||
this.activeReport = new ReactiveVar('');
|
||||
this.filesPage = new ReactiveVar(1);
|
||||
this.rulesPage = new ReactiveVar(1);
|
||||
this.boardsPage = new ReactiveVar(1);
|
||||
this.cardsPage = new ReactiveVar(1);
|
||||
this.filesCount = new ReactiveVar(0);
|
||||
this.rulesCount = new ReactiveVar(0);
|
||||
this.boardsCount = new ReactiveVar(0);
|
||||
this.cardsCount = new ReactiveVar(0);
|
||||
// Current search term per report (empty string = no filter).
|
||||
this.filesSearch = new ReactiveVar('');
|
||||
this.rulesSearch = new ReactiveVar('');
|
||||
this.boardsSearch = new ReactiveVar('');
|
||||
this.cardsSearch = new ReactiveVar('');
|
||||
|
||||
// (Re)subscribe the given report for its current page and refresh its total
|
||||
// count. Server-side search + limit/skip means only the matching page ever
|
||||
// reaches minimongo.
|
||||
this.loadReport = (reportId) => {
|
||||
const cfg = reportConfig(this)[reportId];
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
// Note: we deliberately do not toggle the global `loading` spinner here.
|
||||
// The spinner is shown once on tab switch (switchMenu); paging/searching
|
||||
// re-subscribes silently so the search box keeps focus and the report
|
||||
// stays mounted, just like the admin People panel.
|
||||
if (this.subscription) {
|
||||
this.subscription.stop();
|
||||
}
|
||||
const searchTerm = cfg.search.get();
|
||||
const limit = REPORTS_PER_PAGE;
|
||||
const skip = (cfg.page.get() - 1) * REPORTS_PER_PAGE;
|
||||
this.subscription = Meteor.subscribe(cfg.pub, searchTerm, limit, skip, () => {
|
||||
this.loading.set(false);
|
||||
});
|
||||
Meteor.call(cfg.countMethod, searchTerm, (error, count) => {
|
||||
if (error) {
|
||||
console.error(`Failed to load ${cfg.countMethod}:`, error);
|
||||
return;
|
||||
}
|
||||
const total = count || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / REPORTS_PER_PAGE));
|
||||
// If rows were removed while on a now-out-of-range page, clamp and reload.
|
||||
if (cfg.page.get() > totalPages) {
|
||||
cfg.page.set(totalPages);
|
||||
this.loadReport(reportId);
|
||||
return;
|
||||
}
|
||||
cfg.count.set(total);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Template.adminReports.helpers({
|
||||
@@ -80,6 +152,39 @@ Template.adminReports.helpers({
|
||||
loading() {
|
||||
return Template.instance().loading;
|
||||
},
|
||||
|
||||
// --- Pagination helpers, passed down into each report sub-template ---
|
||||
filesCurrentPage() { return Template.instance().filesPage.get(); },
|
||||
filesTotalPages() { return Math.max(1, Math.ceil((Template.instance().filesCount.get() || 0) / REPORTS_PER_PAGE)); },
|
||||
hasFilesPrevPage() { return Template.instance().filesPage.get() > 1; },
|
||||
hasFilesNextPage() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.filesPage.get() < Math.max(1, Math.ceil((tpl.filesCount.get() || 0) / REPORTS_PER_PAGE));
|
||||
},
|
||||
|
||||
rulesCurrentPage() { return Template.instance().rulesPage.get(); },
|
||||
rulesTotalPages() { return Math.max(1, Math.ceil((Template.instance().rulesCount.get() || 0) / REPORTS_PER_PAGE)); },
|
||||
hasRulesPrevPage() { return Template.instance().rulesPage.get() > 1; },
|
||||
hasRulesNextPage() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.rulesPage.get() < Math.max(1, Math.ceil((tpl.rulesCount.get() || 0) / REPORTS_PER_PAGE));
|
||||
},
|
||||
|
||||
boardsCurrentPage() { return Template.instance().boardsPage.get(); },
|
||||
boardsTotalPages() { return Math.max(1, Math.ceil((Template.instance().boardsCount.get() || 0) / REPORTS_PER_PAGE)); },
|
||||
hasBoardsPrevPage() { return Template.instance().boardsPage.get() > 1; },
|
||||
hasBoardsNextPage() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.boardsPage.get() < Math.max(1, Math.ceil((tpl.boardsCount.get() || 0) / REPORTS_PER_PAGE));
|
||||
},
|
||||
|
||||
cardsCurrentPage() { return Template.instance().cardsPage.get(); },
|
||||
cardsTotalPages() { return Math.max(1, Math.ceil((Template.instance().cardsCount.get() || 0) / REPORTS_PER_PAGE)); },
|
||||
hasCardsPrevPage() { return Template.instance().cardsPage.get() > 1; },
|
||||
hasCardsNextPage() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.cardsPage.get() < Math.max(1, Math.ceil((tpl.cardsCount.get() || 0) / REPORTS_PER_PAGE));
|
||||
},
|
||||
});
|
||||
|
||||
Template.adminReports.events({
|
||||
@@ -98,8 +203,60 @@ Template.adminReports.events({
|
||||
'click a.js-report-boards'(event) {
|
||||
switchMenu(event, Template.instance());
|
||||
},
|
||||
|
||||
// --- Pagination controls (buttons live inside the report sub-templates,
|
||||
// their clicks bubble up to this parent template) ---
|
||||
'click .js-files-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-files'); },
|
||||
'click .js-files-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-files'); },
|
||||
'click .js-rules-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-rules'); },
|
||||
'click .js-rules-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-rules'); },
|
||||
'click .js-boards-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-boards'); },
|
||||
'click .js-boards-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-boards'); },
|
||||
'click .js-cards-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-cards'); },
|
||||
'click .js-cards-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-cards'); },
|
||||
|
||||
// --- Search (one input + button per report, mirrors the People panel) ---
|
||||
'click .js-files-search-button'(event, tmpl) { runSearch(tmpl, 'report-files', '.js-files-search-input'); },
|
||||
'keydown .js-files-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-files', '.js-files-search-input'); },
|
||||
'click .js-rules-search-button'(event, tmpl) { runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
|
||||
'keydown .js-rules-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
|
||||
'click .js-boards-search-button'(event, tmpl) { runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
|
||||
'keydown .js-boards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
|
||||
'click .js-cards-search-button'(event, tmpl) { runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
|
||||
'keydown .js-cards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
|
||||
});
|
||||
|
||||
function goPrevPage(event, tmpl, reportId) {
|
||||
event.preventDefault();
|
||||
const cfg = reportConfig(tmpl)[reportId];
|
||||
const current = cfg.page.get();
|
||||
if (current > 1) {
|
||||
cfg.page.set(current - 1);
|
||||
tmpl.loadReport(reportId);
|
||||
}
|
||||
}
|
||||
|
||||
function goNextPage(event, tmpl, reportId) {
|
||||
event.preventDefault();
|
||||
const cfg = reportConfig(tmpl)[reportId];
|
||||
const total = cfg.count.get() || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / REPORTS_PER_PAGE));
|
||||
const current = cfg.page.get();
|
||||
if (current < totalPages) {
|
||||
cfg.page.set(current + 1);
|
||||
tmpl.loadReport(reportId);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the report's search box, store the term, reset to page 1 and reload.
|
||||
function runSearch(tmpl, reportId, inputSelector) {
|
||||
const cfg = reportConfig(tmpl)[reportId];
|
||||
const value = (tmpl.$(inputSelector).val() || '').trim();
|
||||
cfg.search.set(value);
|
||||
cfg.page.set(1);
|
||||
tmpl.loadReport(reportId);
|
||||
}
|
||||
|
||||
function switchMenu(event, tmpl) {
|
||||
const target = $(event.target);
|
||||
if (!target.hasClass('active')) {
|
||||
@@ -117,6 +274,7 @@ function switchMenu(event, tmpl) {
|
||||
$('.side-menu li.active').removeClass('active');
|
||||
target.parent().addClass('active');
|
||||
const targetID = target.data('id');
|
||||
tmpl.activeReport.set(targetID);
|
||||
|
||||
if ('report-broken' === targetID) {
|
||||
tmpl.showBrokenCardsReport.set(true);
|
||||
@@ -129,32 +287,24 @@ function switchMenu(event, tmpl) {
|
||||
);
|
||||
} else if ('report-files' === targetID) {
|
||||
tmpl.showFilesReport.set(true);
|
||||
tmpl.subscription = Meteor.subscribe('attachmentsList', () => {
|
||||
tmpl.loading.set(false);
|
||||
});
|
||||
tmpl.filesPage.set(1);
|
||||
tmpl.filesSearch.set('');
|
||||
tmpl.loadReport('report-files');
|
||||
} else if ('report-rules' === targetID) {
|
||||
tmpl.subscription = Meteor.subscribe('rulesReport', () => {
|
||||
tmpl.showRulesReport.set(true);
|
||||
tmpl.loading.set(false);
|
||||
});
|
||||
tmpl.showRulesReport.set(true);
|
||||
tmpl.rulesPage.set(1);
|
||||
tmpl.rulesSearch.set('');
|
||||
tmpl.loadReport('report-rules');
|
||||
} else if ('report-boards' === targetID) {
|
||||
tmpl.subscription = Meteor.subscribe('boardsReport', () => {
|
||||
tmpl.showBoardsReport.set(true);
|
||||
tmpl.loading.set(false);
|
||||
});
|
||||
tmpl.showBoardsReport.set(true);
|
||||
tmpl.boardsPage.set(1);
|
||||
tmpl.boardsSearch.set('');
|
||||
tmpl.loadReport('report-boards');
|
||||
} else if ('report-cards' === targetID) {
|
||||
const qp = new QueryParams();
|
||||
qp.addPredicate(OPERATOR_LIMIT, 300);
|
||||
tmpl.subscription = Meteor.subscribe(
|
||||
'globalSearch',
|
||||
tmpl.sessionId,
|
||||
qp.getParams(),
|
||||
qp.text,
|
||||
() => {
|
||||
tmpl.showCardsReport.set(true);
|
||||
tmpl.loading.set(false);
|
||||
},
|
||||
);
|
||||
tmpl.showCardsReport.set(true);
|
||||
tmpl.cardsPage.set(1);
|
||||
tmpl.cardsSearch.set('');
|
||||
tmpl.loadReport('report-cards');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +313,7 @@ function switchMenu(event, tmpl) {
|
||||
|
||||
Template.filesReport.helpers({
|
||||
results() {
|
||||
return collectionResults(Attachments);
|
||||
return collectionResults(Attachments, { name: 1 });
|
||||
},
|
||||
resultsCount() {
|
||||
return collectionResultsCount(Attachments);
|
||||
@@ -179,19 +329,17 @@ Template.rulesReport.helpers({
|
||||
results() {
|
||||
const rules = [];
|
||||
|
||||
ReactiveCache.getRules().forEach(rule => {
|
||||
ReactiveCache.getRules({}, { sort: { boardId: 1 } }).forEach(rule => {
|
||||
rules.push({
|
||||
_id: rule._id,
|
||||
title: rule.title,
|
||||
boardId: rule.boardId,
|
||||
boardTitle: rule.board().title,
|
||||
boardTitle: rule.board()?.title,
|
||||
action: rule.action(),
|
||||
trigger: rule.trigger(),
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('rows:', rules);
|
||||
return rules;
|
||||
},
|
||||
resultsCount() {
|
||||
@@ -203,7 +351,7 @@ Template.rulesReport.helpers({
|
||||
|
||||
Template.boardsReport.helpers({
|
||||
results() {
|
||||
return collectionResults(Boards);
|
||||
return collectionResults(Boards, { sort: 1 });
|
||||
},
|
||||
resultsCount() {
|
||||
return collectionResultsCount(Boards);
|
||||
@@ -247,7 +395,7 @@ Template.boardsReport.helpers({
|
||||
|
||||
Template.cardsReport.helpers({
|
||||
results() {
|
||||
return collectionResults(Cards);
|
||||
return collectionResults(Cards, { boardId: 1, sort: 1 });
|
||||
},
|
||||
resultsCount() {
|
||||
return collectionResultsCount(Cards);
|
||||
|
||||
@@ -2,7 +2,241 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.attachments-content .move-compact-section,
|
||||
.setting-content .content-body .main-body .form-group .compact-block {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background: #fff8e1;
|
||||
max-width: 650px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.attachments-content .move-compact-section p,
|
||||
.setting-content .content-body .main-body .form-group .compact-block p {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.attachments-content .move-compact-section h4,
|
||||
.setting-content .content-body .main-body h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachments-content .compact-result-list,
|
||||
.setting-content .content-body .main-body .compact-result-list {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
font-size: 0.85em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-section {
|
||||
margin: 12px 0 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-counter {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-filename {
|
||||
font-size: 0.85em;
|
||||
color: #57606a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-bar-track {
|
||||
background: #dde1e4;
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-bar-fill {
|
||||
background: #1976d2;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-bar-indeterminate {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, transparent 0%, #1976d2 40%, #42a5f5 60%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: move-progress-slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes move-progress-slide {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-selects {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-form .form-group {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-form .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachments-content .move-attachment-buttons button,
|
||||
.attachments-content .board-attachments td button {
|
||||
background-color: #1976d2;
|
||||
border: 1px solid #1565c0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.attachments-content .move-attachment-buttons button:hover,
|
||||
.attachments-content .board-attachments td button:hover {
|
||||
background-color: #1565c0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.attachments-content hr {
|
||||
height: 0px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row input[type="checkbox"] {
|
||||
position: static;
|
||||
left: auto;
|
||||
visibility: visible;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row input[type="checkbox"]:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row label {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row input[type="checkbox"]:disabled + label {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .gridfs-stats-table {
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .gridfs-stats-table th,
|
||||
.setting-content .content-body .main-body .gridfs-stats-table td {
|
||||
border: 1px solid #d0d7de;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
/* Cloud storage (S3 / Azure / GCS): example value shown below each input. */
|
||||
.attachments-content .cloud-input-example {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 0.82em;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Cloud storage: where-to-find-this-value path shown below the example. */
|
||||
.attachments-content .cloud-input-path {
|
||||
display: block;
|
||||
color: #6a737d;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Cloud storage: translated label shown under the (English) label. */
|
||||
.attachments-content .cloud-input-label-tr {
|
||||
display: block;
|
||||
color: #57606a;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Cloud storage: description text (English literal and translated). */
|
||||
.attachments-content .cloud-input-desc {
|
||||
display: block;
|
||||
color: #6a737d;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -3,94 +3,547 @@ template(name="attachments")
|
||||
unless currentUser.isAdmin
|
||||
| {{_ 'error-notAuthorized'}}
|
||||
else
|
||||
.content-title.ext-box
|
||||
span
|
||||
span.emoji-icon
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'attachments'}}
|
||||
.content-body
|
||||
.side-menu
|
||||
ul
|
||||
li
|
||||
a.js-move-attachments(data-id="move-attachments")
|
||||
li(class="{{#if isMoveActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="move")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move'}}
|
||||
li(class="{{#if isDefaultStorageActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="default-save-storage")
|
||||
span.emoji-icon
|
||||
i.fa.fa-save
|
||||
| {{_ 'default-save-storage'}}
|
||||
li(class="{{#if isLimitsActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="limits")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sliders
|
||||
| {{_ 'attachment-limits'}}
|
||||
li(class="{{#if isGridFsActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="gridfs")
|
||||
span.emoji-icon
|
||||
i.fa.fa-database
|
||||
| {{_ 'mongodb-gridfs-storage'}}
|
||||
li(class="{{#if isFilesystemActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="filesystem")
|
||||
span.emoji-icon
|
||||
i.fa.fa-folder-open
|
||||
| {{_ 'filesystem-storage'}}
|
||||
li(class="{{#if isS3Active}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="s3")
|
||||
span.emoji-icon
|
||||
i.fa.fa-cloud
|
||||
| {{_ 's3-minio-storage'}}
|
||||
li(class="{{#if isAzureActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="azure")
|
||||
span.emoji-icon
|
||||
i.fa.fa-cloud
|
||||
| {{_ 'azure-blob-storage'}}
|
||||
li(class="{{#if isGcsActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="gcs")
|
||||
span.emoji-icon
|
||||
i.fa.fa-cloud
|
||||
| {{_ 'gcs-storage'}}
|
||||
|
||||
.main-body
|
||||
if loading.get
|
||||
+spinner
|
||||
else if showMoveAttachments.get
|
||||
+moveAttachments
|
||||
else if isMoveActive
|
||||
ul#attachment-move-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'attachment-move'}}
|
||||
+moveAttachments
|
||||
else if isDefaultStorageActive
|
||||
ul#attachment-default-storage-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'default-save-storage'}}
|
||||
.description {{_ 'default-save-storage-description'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-default-save-storage
|
||||
each defaultStorageOptions
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group
|
||||
button.js-save-default-storage.primary {{_ 'save'}}
|
||||
else if isLimitsActive
|
||||
ul#attachment-limits-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'attachment-transfer-limits-title'}}
|
||||
.description {{_ 'attachment-transfer-limits-description'}}
|
||||
|
||||
li
|
||||
.title {{_ 'attachment-upload-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="attachmentsUploadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'attachmentsUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'attachmentsUploadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#attachments-upload-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'attachmentsUploadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="attachmentsUploadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'attachmentsUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
.title {{_ 'attachment-download-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="attachmentsDownloadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'attachmentsDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'attachmentsDownloadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#attachments-download-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'attachmentsDownloadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="attachmentsDownloadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'attachmentsDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
.title {{_ 'api-upload-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="apiUploadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'apiUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'apiUploadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#api-upload-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'apiUploadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="apiUploadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'apiUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
.title {{_ 'api-download-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="apiDownloadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'apiDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'apiDownloadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#api-download-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'apiDownloadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="apiDownloadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'apiDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
button.js-save-attachment-transfer-limits.primary {{_ 'save'}}
|
||||
else if isFilesystemActive
|
||||
ul#attachment-filesystem-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'filesystem-storage'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input.js-toggle-filesystem-read#filesystem-read(type="checkbox" checked="{{filesystemRead}}")
|
||||
label(for="filesystem-read") {{_ 'storage-read'}}
|
||||
|
||||
.form-group
|
||||
button.js-calculate-filesystem-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if filesystemStatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if filesystemStatsError
|
||||
p.form-text.text-danger {{filesystemStatsError}}
|
||||
if hasFilesystemStats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-count'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{filesystemStats.attachments}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{filesystemStats.avatars}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'writable-path'}}
|
||||
input.wekan-form-control#filesystem-path(type="text" value="{{filesystemPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'filesystem-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'attachments-path'}}
|
||||
input.wekan-form-control#attachments-path(type="text" value="{{attachmentsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'attachments-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'avatars-path'}}
|
||||
input.wekan-form-control#avatars-path(type="text" value="{{avatarsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'avatars-path-description'}}
|
||||
|
||||
else if isGridFsActive
|
||||
ul#attachment-gridfs-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'mongodb-gridfs-storage'}}
|
||||
.description {{_ 'gridfs-move-collectionfs-note'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input.js-toggle-gridfs-read#gridfs-read(type="checkbox" checked="{{gridfsRead}}")
|
||||
label(for="gridfs-read") {{_ 'storage-read'}}
|
||||
.form-group
|
||||
button.js-calculate-gridfs-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if gridFsStatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if gridFsStatsError
|
||||
p.form-text.text-danger {{gridFsStatsError}}
|
||||
if hasGridFsStats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-collectionfs'}}
|
||||
th {{_ 'stats-mongo-files'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{gridFsStats.attachments.collectionFs}}
|
||||
td {{gridFsStats.attachments.mongoFiles}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{gridFsStats.avatars.collectionFs}}
|
||||
td {{gridFsStats.avatars.mongoFiles}}
|
||||
.form-group
|
||||
.compact-block
|
||||
h4 {{_ 'mongodb-compact'}}
|
||||
p.form-text {{_ 'mongodb-compact-description'}}
|
||||
p.form-text.text-muted {{_ 'mongodb-compact-warning'}}
|
||||
button.js-compact-mongodb-gridfs.btn.btn-warning(type="button") {{_ 'mongodb-compact-run'}}
|
||||
if compactLoading
|
||||
p.form-text.text-muted {{_ 'mongodb-compact-running'}}
|
||||
if compactError
|
||||
p.form-text.text-danger {{_ 'mongodb-compact-error'}} {{compactError}}
|
||||
if compactResult
|
||||
p.form-text.text-success {{_ 'mongodb-compact-success'}}
|
||||
ul.compact-result-list
|
||||
each item in compactResultItems
|
||||
li
|
||||
strong {{item.name}}
|
||||
| : {{item.status}}
|
||||
|
||||
else if isS3Active
|
||||
ul#attachment-s3-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 's3-minio-storage'}}
|
||||
.description {{_ 's3-minio-storage-description'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input#s3-enabled(type="checkbox" checked=cloudEnabled.s3)
|
||||
label(for="s3-enabled") {{_ 'storage-enabled'}}
|
||||
.storage-rw-item
|
||||
input#s3-read(type="checkbox" checked=cloudRead.s3)
|
||||
label(for="s3-read") {{_ 'storage-read'}}
|
||||
|
||||
.form-group
|
||||
button.js-save-cloud-settings.btn.btn-primary(data-provider="s3") {{_ 'save'}}
|
||||
|
||||
.form-group
|
||||
button.js-calculate-s3-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if s3StatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if s3StatsError
|
||||
p.form-text.text-danger {{s3StatsError}}
|
||||
if hasS3Stats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-count'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{s3Stats.attachments}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{s3Stats.avatars}}
|
||||
|
||||
.form-group
|
||||
label Endpoint
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-endpoint'}}
|
||||
input.wekan-form-control#s3-endpoint(type="text" value=cloudValue.s3.endpoint)
|
||||
small.form-text.cloud-input-example https://s3.eu-west-1.amazonaws.com
|
||||
small.form-text.cloud-input-desc S3 endpoint URL (e.g., s3.amazonaws.com or minio.example.com)
|
||||
small.form-text.cloud-input-desc {{_ 's3-endpoint-description'}}
|
||||
small.form-text.cloud-input-path AWS: leave blank (uses the region endpoint). S3-compatible: the Endpoint URL from your MinIO / Cloudflare R2 / Backblaze B2 / Wasabi / DigitalOcean Spaces console.
|
||||
small.form-text.cloud-input-path {{_ 's3-endpoint-menu-path'}}
|
||||
.form-group
|
||||
label AWS Region
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-region'}}
|
||||
input.wekan-form-control#s3-region(type="text" value=cloudValue.s3.region)
|
||||
small.form-text.cloud-input-example eu-west-1
|
||||
small.form-text.cloud-input-desc AWS S3 region (e.g., us-east-1)
|
||||
small.form-text.cloud-input-desc {{_ 's3-region-description'}}
|
||||
small.form-text.cloud-input-path AWS Console → S3 → Buckets → your bucket → Properties → AWS Region.
|
||||
small.form-text.cloud-input-path {{_ 's3-region-menu-path'}}
|
||||
.form-group
|
||||
label Bucket name
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-bucket'}}
|
||||
input.wekan-form-control#s3-bucket(type="text" value=cloudValue.s3.bucket)
|
||||
small.form-text.cloud-input-example wekan-s3-test-369341155299-eu-west-1-an
|
||||
small.form-text.cloud-input-desc S3 bucket name for storing files
|
||||
small.form-text.cloud-input-desc {{_ 's3-bucket-description'}}
|
||||
small.form-text.cloud-input-path AWS Console → S3 → Buckets → your bucket name (or Create bucket → Bucket name).
|
||||
small.form-text.cloud-input-path {{_ 's3-bucket-menu-path'}}
|
||||
.form-group
|
||||
label Access key ID
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-access-key'}}
|
||||
input.wekan-form-control#s3-access-key(type="text" value=cloudValue.s3.accessKeyId)
|
||||
small.form-text.cloud-input-example AKIAIOSFODNN7EXAMPLE
|
||||
small.form-text.cloud-input-desc AWS S3 access key for authentication
|
||||
small.form-text.cloud-input-desc {{_ 's3-access-key-description'}}
|
||||
small.form-text.cloud-input-path AWS Console → IAM → Users → your user → Security credentials → Access keys → Create access key → choose use case 'Application running outside AWS' → Create access key. Shown as 'Access key ID'.
|
||||
small.form-text.cloud-input-path {{_ 's3-access-key-menu-path'}}
|
||||
.form-group
|
||||
label Secret access key
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-secret-key'}}
|
||||
input.wekan-form-control#s3-secret-key(type="password" placeholder=cloudSecretPlaceholder.s3.secretAccessKey)
|
||||
small.form-text.cloud-input-example wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
small.form-text.cloud-input-desc Leave blank to keep the current value.
|
||||
small.form-text.cloud-input-desc {{_ 'cloud-secret-keep-blank'}}
|
||||
small.form-text.cloud-input-path Same screen as the Access key ID; 'Secret access key' is shown only once when the key is created — copy it then (or Download .csv).
|
||||
small.form-text.cloud-input-path {{_ 's3-secret-key-menu-path'}}
|
||||
.form-group
|
||||
.storage-rw-item
|
||||
input#s3-force-path-style(type="checkbox" checked=cloudValue.s3.forcePathStyle)
|
||||
label(for="s3-force-path-style") {{_ 's3-force-path-style'}}
|
||||
small.form-text.text-muted {{_ 's3-force-path-style-description'}}
|
||||
|
||||
.form-group
|
||||
button.js-test-cloud-connection.btn.btn-secondary(data-provider="s3") {{_ 'test-cloud-connection'}}
|
||||
button.js-save-cloud-settings.btn.btn-primary(data-provider="s3") {{_ 'save'}}
|
||||
if cloudTestResult.s3
|
||||
p.form-text.text-success {{_ 'cloud-connection-success'}}
|
||||
if cloudTestError.s3
|
||||
p.form-text.text-danger {{_ 'cloud-connection-failed'}}: {{cloudTestError.s3}}
|
||||
|
||||
else if isAzureActive
|
||||
ul#attachment-azure-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'azure-blob-storage'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input#azure-enabled(type="checkbox" checked=cloudEnabled.azure)
|
||||
label(for="azure-enabled") {{_ 'storage-enabled'}}
|
||||
.storage-rw-item
|
||||
input#azure-read(type="checkbox" checked=cloudRead.azure)
|
||||
label(for="azure-read") {{_ 'storage-read'}}
|
||||
|
||||
.form-group
|
||||
button.js-save-cloud-settings.btn.btn-primary(data-provider="azure") {{_ 'save'}}
|
||||
|
||||
.form-group
|
||||
button.js-calculate-azure-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if azureStatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if azureStatsError
|
||||
p.form-text.text-danger {{azureStatsError}}
|
||||
if hasAzureStats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-count'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{azureStats.attachments}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{azureStats.avatars}}
|
||||
|
||||
.form-group
|
||||
label Storage account name
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-account-name'}}
|
||||
input.wekan-form-control#azure-account-name(type="text" value=cloudValue.azure.accountName)
|
||||
small.form-text.cloud-input-example wekanstorage
|
||||
small.form-text.cloud-input-desc The name of your Azure Storage account.
|
||||
small.form-text.cloud-input-desc {{_ 'azure-account-name-description'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → name shown at the top (also under Security + networking → Access keys → Storage account name).
|
||||
small.form-text.cloud-input-path {{_ 'azure-account-name-menu-path'}}
|
||||
.form-group
|
||||
label Storage account key
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-account-key'}}
|
||||
input.wekan-form-control#azure-account-key(type="password" placeholder=cloudSecretPlaceholder.azure.accountKey)
|
||||
small.form-text.cloud-input-example base64AccountKey==
|
||||
small.form-text.cloud-input-desc Leave blank to keep the current value.
|
||||
small.form-text.cloud-input-desc {{_ 'cloud-secret-keep-blank'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → Security + networking → Access keys → key1 → Show → Key.
|
||||
small.form-text.cloud-input-path {{_ 'azure-account-key-menu-path'}}
|
||||
.form-group
|
||||
label Connection string
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-connection-string'}}
|
||||
input.wekan-form-control#azure-connection-string(type="password" placeholder=cloudSecretPlaceholder.azure.connectionString)
|
||||
small.form-text.cloud-input-example DefaultEndpointsProtocol=https;AccountName=wekanstorage;AccountKey=...;EndpointSuffix=core.windows.net
|
||||
small.form-text.cloud-input-desc Optional: use a full connection string instead of account name and key.
|
||||
small.form-text.cloud-input-desc {{_ 'azure-connection-string-description'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → Security + networking → Access keys → key1 → Connection string → Show.
|
||||
small.form-text.cloud-input-path {{_ 'azure-connection-string-menu-path'}}
|
||||
.form-group
|
||||
label Container name
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-container'}}
|
||||
input.wekan-form-control#azure-bucket(type="text" value=cloudValue.azure.bucket)
|
||||
small.form-text.cloud-input-example wekan-attachments
|
||||
small.form-text.cloud-input-desc The blob container to store files in.
|
||||
small.form-text.cloud-input-desc {{_ 'azure-container-description'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → Data storage → Containers → your container (or + Container to create one).
|
||||
small.form-text.cloud-input-path {{_ 'azure-container-menu-path'}}
|
||||
|
||||
.form-group
|
||||
button.js-test-cloud-connection.btn.btn-secondary(data-provider="azure") {{_ 'test-cloud-connection'}}
|
||||
button.js-save-cloud-settings.btn.btn-primary(data-provider="azure") {{_ 'save'}}
|
||||
if cloudTestResult.azure
|
||||
p.form-text.text-success {{_ 'cloud-connection-success'}}
|
||||
if cloudTestError.azure
|
||||
p.form-text.text-danger {{_ 'cloud-connection-failed'}}: {{cloudTestError.azure}}
|
||||
|
||||
else if isGcsActive
|
||||
ul#attachment-gcs-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'gcs-storage'}}
|
||||
.description Give WeKan's service account read/write access to the bucket: Google Cloud Console → Cloud Storage → Buckets → your bucket → Permissions → Grant access. In "New principals", paste the service account email — the "client_email" value from your JSON key — then choose the role "Storage Object Admin" and Save.
|
||||
.description {{_ 'gcs-permissions-note'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input#gcs-enabled(type="checkbox" checked=cloudEnabled.gcs)
|
||||
label(for="gcs-enabled") {{_ 'storage-enabled'}}
|
||||
.storage-rw-item
|
||||
input#gcs-read(type="checkbox" checked=cloudRead.gcs)
|
||||
label(for="gcs-read") {{_ 'storage-read'}}
|
||||
|
||||
.form-group
|
||||
button.js-save-cloud-settings.btn.btn-primary(data-provider="gcs") {{_ 'save'}}
|
||||
|
||||
.form-group
|
||||
button.js-calculate-gcs-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if gcsStatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if gcsStatsError
|
||||
p.form-text.text-danger {{gcsStatsError}}
|
||||
if hasGcsStats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-count'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{gcsStats.attachments}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{gcsStats.avatars}}
|
||||
|
||||
.form-group
|
||||
label Project ID
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-project-id'}}
|
||||
input.wekan-form-control#gcs-project-id(type="text" value=cloudValue.gcs.projectId)
|
||||
small.form-text.cloud-input-example my-gcp-project-123456
|
||||
small.form-text.cloud-input-desc Your Google Cloud project id.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-project-id-description'}}
|
||||
small.form-text.cloud-input-path Google Cloud Console → project selector at the top → your project's ID (Cloud overview → Dashboard → Project info → Project ID).
|
||||
small.form-text.cloud-input-path {{_ 'gcs-project-id-menu-path'}}
|
||||
.form-group
|
||||
label Bucket name
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-bucket'}}
|
||||
input.wekan-form-control#gcs-bucket(type="text" value=cloudValue.gcs.bucket)
|
||||
small.form-text.cloud-input-example wekan-attachments
|
||||
small.form-text.cloud-input-desc The name of the Cloud Storage bucket to store files in.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-bucket-description'}}
|
||||
small.form-text.cloud-input-path Google Cloud Console → Cloud Storage → Buckets → your bucket name (or Create to make one).
|
||||
small.form-text.cloud-input-path {{_ 'gcs-bucket-menu-path'}}
|
||||
.form-group
|
||||
label Service account key file path
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-key-filename'}}
|
||||
input.wekan-form-control#gcs-key-filename(type="text" value=cloudValue.gcs.keyFilename)
|
||||
small.form-text.cloud-input-example /data/gcs-service-account.json
|
||||
small.form-text.cloud-input-desc Optional: path to a service account JSON key file on the server.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-key-filename-description'}}
|
||||
small.form-text.cloud-input-path Path on the WeKan server to the downloaded service account JSON key file (created below). Use this OR paste the JSON into the field below.
|
||||
small.form-text.cloud-input-path {{_ 'gcs-key-filename-menu-path'}}
|
||||
.form-group
|
||||
label Service account key (JSON)
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-credentials'}}
|
||||
textarea.wekan-form-control#gcs-credentials(rows="4" placeholder=cloudSecretPlaceholder.gcs.credentials)
|
||||
small.form-text.cloud-input-example { "type": "service_account", "project_id": "my-gcp-project", "client_email": "wekan@my-gcp-project.iam.gserviceaccount.com", "private_key": "-----BEGIN PRIVATE KEY-----..." }
|
||||
small.form-text.cloud-input-desc Optional: paste the service account JSON key. Leave blank to use the key file or default credentials.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-credentials-description'}}
|
||||
small.form-text.cloud-input-path Google Cloud Console → IAM & Admin → Service accounts → your service account → Keys → Add key → Create new key → JSON → Create (downloads the file). Paste its contents here, or save the file and set its path above.
|
||||
small.form-text.cloud-input-path {{_ 'gcs-credentials-menu-path'}}
|
||||
small.form-text.text-muted {{_ 'gcs-credentials-description'}}
|
||||
|
||||
.form-group
|
||||
button.js-test-cloud-connection.btn.btn-secondary(data-provider="gcs") {{_ 'test-cloud-connection'}}
|
||||
button.js-save-cloud-settings.btn.btn-primary(data-provider="gcs") {{_ 'save'}}
|
||||
if cloudTestResult.gcs
|
||||
p.form-text.text-success {{_ 'cloud-connection-success'}}
|
||||
if cloudTestError.gcs
|
||||
p.form-text.text-danger {{_ 'cloud-connection-failed'}}: {{cloudTestError.gcs}}
|
||||
|
||||
template(name="moveAttachments")
|
||||
.move-attachment-buttons
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-to-fs {{_ 'move-all-attachments-to-fs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-to-gridfs {{_ 'move-all-attachments-to-gridfs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-to-s3 {{_ 'move-all-attachments-to-s3'}}
|
||||
.move-storage-form
|
||||
.move-storage-selects
|
||||
.form-group
|
||||
label {{_ 'move-scope'}}
|
||||
select.wekan-form-control.js-move-scope
|
||||
option(value="attachments") {{_ 'move-scope-attachments'}}
|
||||
option(value="avatars") {{_ 'move-scope-avatars'}}
|
||||
option(value="both") {{_ 'move-scope-both'}}
|
||||
.form-group
|
||||
label {{_ 'move-source'}}
|
||||
select.wekan-form-control.js-move-source-storage
|
||||
option(value="all") {{_ 'move-storage-all'}}
|
||||
option(value="collectionfs") {{_ 'move-storage-collectionfs'}}
|
||||
option(value="gridfs") {{_ 'move-storage-gridfs'}}
|
||||
option(value="fs") {{_ 'move-storage-fs'}}
|
||||
option(value="s3") {{_ 'move-storage-s3'}}
|
||||
option(value="azure") {{_ 'move-storage-azure'}}
|
||||
option(value="gcs") {{_ 'move-storage-gcs'}}
|
||||
.form-group
|
||||
label {{_ 'move-destination'}}
|
||||
select.wekan-form-control.js-move-dest-storage
|
||||
option(value="gridfs") {{_ 'move-storage-gridfs'}}
|
||||
option(value="fs") {{_ 'move-storage-fs'}}
|
||||
option(value="s3") {{_ 'move-storage-s3'}}
|
||||
option(value="azure") {{_ 'move-storage-azure'}}
|
||||
option(value="gcs") {{_ 'move-storage-gcs'}}
|
||||
option(value="collectionfs") {{_ 'move-storage-collectionfs'}}
|
||||
button.js-move-all-attachments.btn.btn-primary {{_ 'move-all-attachments'}}
|
||||
|
||||
each board in getBoardsWithAttachments
|
||||
+moveBoardAttachments board
|
||||
if lastMove
|
||||
.move-last-operation
|
||||
span.emoji-icon
|
||||
i.fa.fa-info-circle
|
||||
| {{_ 'attachment-last-move'}}: {{lastMoveText}}
|
||||
|
||||
template(name="moveBoardAttachments")
|
||||
hr
|
||||
.board-description
|
||||
table
|
||||
tr
|
||||
th {{_ 'board'}} ID
|
||||
th {{_ 'board-title'}}
|
||||
tr
|
||||
td {{ _id }}
|
||||
td {{ title }}
|
||||
.move-repair-section
|
||||
h4 {{_ 'attachment-repair-locations'}}
|
||||
.description {{_ 'attachment-repair-locations-description'}}
|
||||
button.js-repair-attachment-locations.btn.btn-secondary(type="button") {{_ 'attachment-repair-locations'}}
|
||||
if repairLoading
|
||||
p.form-text.text-muted {{_ 'attachment-repair-running'}}
|
||||
if repairError
|
||||
p.form-text.text-danger {{repairError}}
|
||||
if repairResult
|
||||
p.form-text.text-success {{_ 'attachment-repair-done'}}
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'attachment-repair-scanned'}}
|
||||
th {{_ 'attachment-repair-repaired'}}
|
||||
th {{_ 'attachment-repair-broken'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{repairResult.attachments.scanned}}
|
||||
td {{repairResult.attachments.repaired}}
|
||||
td {{repairResult.attachments.broken}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{repairResult.avatars.scanned}}
|
||||
td {{repairResult.avatars.repaired}}
|
||||
td {{repairResult.avatars.broken}}
|
||||
|
||||
.move-attachment-buttons
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-of-board-to-fs {{_ 'move-all-attachments-of-board-to-fs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-of-board-to-gridfs {{_ 'move-all-attachments-of-board-to-gridfs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-of-board-to-s3 {{_ 'move-all-attachments-of-board-to-s3'}}
|
||||
|
||||
.board-attachments
|
||||
table
|
||||
tr
|
||||
th {{_ 'card'}}-Id
|
||||
th {{_ 'attachment'}}-Id
|
||||
th {{_ 'name'}}
|
||||
th {{_ 'path'}}
|
||||
th {{_ 'version-name'}}
|
||||
th {{_ 'size'}}
|
||||
th GridFsFileId
|
||||
th S3FileId
|
||||
th {{_ 'storage'}}
|
||||
th {{_ 'action'}}
|
||||
|
||||
each attachment in attachments
|
||||
+moveAttachment attachment
|
||||
|
||||
template(name="moveAttachment")
|
||||
each version in flatVersion
|
||||
tr
|
||||
td {{ meta.cardId }}
|
||||
td {{ _id }}
|
||||
td {{ name }}
|
||||
td {{ version.path }}
|
||||
td {{ version.versionName }}
|
||||
td {{ fileSize version.size }}
|
||||
td {{ version.meta.gridFsFileId }}
|
||||
td {{ version.meta.s3FileId }}
|
||||
td {{ version.storageName }}
|
||||
td
|
||||
if $neq version.storageName "fs"
|
||||
button.js-move-storage-fs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-fs'}}
|
||||
|
||||
if $neq version.storageName "gridfs"
|
||||
if version.storageName
|
||||
button.js-move-storage-gridfs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-gridfs'}}
|
||||
|
||||
if $neq version.storageName "s3"
|
||||
if version.storageName
|
||||
button.js-move-storage-s3
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-s3'}}
|
||||
if moveProgress
|
||||
.move-progress-section
|
||||
.move-progress-header
|
||||
.move-progress-info
|
||||
.move-progress-counter {{_ 'move-progress-file'}} {{moveProgress.current}} / {{moveProgress.total}}
|
||||
.move-progress-filename {{moveProgress.name}} ({{moveProgressSize}})
|
||||
.move-progress-bar-track
|
||||
.move-progress-bar-fill(style="{{moveProgressBarStyle}}")
|
||||
.move-progress-bar-track
|
||||
.move-progress-bar-indeterminate
|
||||
.move-progress-controls
|
||||
if moveProgressPaused
|
||||
button.js-resume-move.btn.btn-secondary {{_ 'move-progress-resume'}}
|
||||
else
|
||||
button.js-pause-move.btn.btn-secondary {{_ 'move-progress-pause'}}
|
||||
button.js-cancel-move.btn.btn-danger {{_ 'move-progress-cancel'}}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -354,3 +354,27 @@ table td:first-child {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Roles tab. The .main-body scrolls horizontally and is wider than the visible
|
||||
area (the side-menu takes ~250px), so by default a long description would run
|
||||
off the right edge and only be fully readable at the smallest (mobile) width.
|
||||
Pin the heading/description to the visible left edge and wrap them within the
|
||||
viewport so they stay visible at every width. */
|
||||
.roles-settings {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.roles-settings h3,
|
||||
.roles-settings h4,
|
||||
.roles-settings .roles-description {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
/* Cap to ~60 characters, and never wider than the viewport, so the text wraps
|
||||
into the visible left area at every width (desktop, mid and mobile) instead
|
||||
of running off the horizontally-scrolling main-body. */
|
||||
max-width: min(60ch, 88vw);
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.roles-settings .roles-description {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -63,6 +63,11 @@ template(name="people")
|
||||
i.fa.fa-lock
|
||||
unless isMiniScreen
|
||||
| {{_ 'accounts-lockout-locked-users'}}
|
||||
else if rolesSetting.get
|
||||
span
|
||||
i.fa.fa-key
|
||||
unless isMiniScreen
|
||||
| {{_ 'roles'}}
|
||||
|
||||
.content-body
|
||||
.side-menu
|
||||
@@ -84,6 +89,10 @@ template(name="people")
|
||||
span.emoji-icon.text-red
|
||||
i.fa.fa-lock
|
||||
| {{_ 'accounts-lockout-locked-users'}}
|
||||
li
|
||||
a.js-roles-menu(data-id="roles-setting")
|
||||
i.fa.fa-key
|
||||
| {{_ 'roles'}}
|
||||
.main-body
|
||||
if loading.get
|
||||
+spinner
|
||||
@@ -95,6 +104,8 @@ template(name="people")
|
||||
+peopleGeneral(peopleList=peopleList peopleCurrentPage=peopleCurrentPage peopleTotalPages=peopleTotalPages hasPeoplePrevPage=hasPeoplePrevPage hasPeopleNextPage=hasPeopleNextPage)
|
||||
else if lockedUsersSetting.get
|
||||
+lockedUsersGeneral
|
||||
else if rolesSetting.get
|
||||
+rolesGeneral
|
||||
|
||||
|
||||
template(name="orgGeneral")
|
||||
@@ -719,3 +730,22 @@ template(name="lockedUsersGeneral")
|
||||
input.wekan-form-control#unknown-failure-window(type="number", min="1", max="60", placeholder="15" value="{{unknownFailureWindow}}")
|
||||
|
||||
button.js-lockout-save.primary {{_ 'save'}}
|
||||
|
||||
template(name="rolesGeneral")
|
||||
.roles-settings
|
||||
h3 {{_ 'roles'}}
|
||||
p.roles-description {{_ 'roles-info'}}
|
||||
|
||||
h4 {{_ 'allow-invite-to-board'}}
|
||||
ul#roles-setting.setting-detail
|
||||
li
|
||||
a.flex.js-toggle-all-roles
|
||||
.materialCheckBox(class="{{#if allRolesAllowed}}is-checked{{/if}}")
|
||||
span {{_ 'all-board-members'}}
|
||||
each roleOptions
|
||||
li
|
||||
a.flex.js-toggle-role(data-role="{{key}}")
|
||||
.materialCheckBox(class="{{#if allowed}}is-checked{{/if}}")
|
||||
span {{_ label}}
|
||||
|
||||
button.js-roles-save.primary {{_ 'save'}}
|
||||
@@ -6,6 +6,10 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Org from '/models/org';
|
||||
import Team from '/models/team';
|
||||
import Users from '/models/users';
|
||||
import InviteToBoardRolesSettings, {
|
||||
INVITE_TO_BOARD_ROLES,
|
||||
INVITE_TO_BOARD_ROLES_ID,
|
||||
} from '/models/inviteToBoardRolesSettings';
|
||||
|
||||
const orgsPerPage = 25;
|
||||
const teamsPerPage = 25;
|
||||
@@ -22,6 +26,8 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting = new ReactiveVar(false);
|
||||
this.peopleSetting = new ReactiveVar(false);
|
||||
this.lockedUsersSetting = new ReactiveVar(false);
|
||||
this.rolesSetting = new ReactiveVar(false);
|
||||
this.subscribe('inviteToBoardRolesSettings');
|
||||
this.findOrgsOptions = new ReactiveVar({});
|
||||
this.findTeamsOptions = new ReactiveVar({});
|
||||
this.findUsersOptions = new ReactiveVar({});
|
||||
@@ -191,6 +197,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting.set('team-setting' === targetID);
|
||||
this.peopleSetting.set('people-setting' === targetID);
|
||||
this.lockedUsersSetting.set('locked-users-setting' === targetID);
|
||||
this.rolesSetting.set('roles-setting' === targetID);
|
||||
|
||||
// When switching to locked users tab, refresh the locked users list
|
||||
if ('locked-users-setting' === targetID) {
|
||||
@@ -263,14 +270,17 @@ Template.people.helpers({
|
||||
lockedUsersSetting() {
|
||||
return Template.instance().lockedUsersSetting;
|
||||
},
|
||||
rolesSetting() {
|
||||
return Template.instance().rolesSetting;
|
||||
},
|
||||
orgList() {
|
||||
const tpl = Template.instance();
|
||||
const page = tpl.orgPage.get();
|
||||
const skipOrgs = (page - 1) * orgsPerPage;
|
||||
// The 'org' publication already returns only the current page (server-side
|
||||
// limit/skip, sorted createdAt:-1). Display exactly what it published:
|
||||
// re-applying skip/limit here paginated an already-paginated set, which
|
||||
// left page 2 with a single stray doc and later pages empty.
|
||||
const orgs = Org.find(tpl.findOrgsOptions.get(), {
|
||||
sort: { orgDisplayName: 1 },
|
||||
skip: skipOrgs,
|
||||
limit: orgsPerPage,
|
||||
sort: { createdAt: -1 },
|
||||
fields: {
|
||||
_id: 1,
|
||||
orgDisplayName: 1,
|
||||
@@ -285,12 +295,12 @@ Template.people.helpers({
|
||||
},
|
||||
teamList() {
|
||||
const tpl = Template.instance();
|
||||
const page = tpl.teamPage.get();
|
||||
const skipTeams = (page - 1) * teamsPerPage;
|
||||
// The 'team' publication already returns only the current page (server-side
|
||||
// limit/skip, sorted createdAt:-1). Display exactly what it published:
|
||||
// re-applying skip/limit here paginated an already-paginated set, which
|
||||
// left page 2 with a single stray doc and later pages empty.
|
||||
const teams = Team.find(tpl.findTeamsOptions.get(), {
|
||||
sort: { teamDisplayName: 1 },
|
||||
skip: skipTeams,
|
||||
limit: teamsPerPage,
|
||||
sort: { createdAt: -1 },
|
||||
fields: {
|
||||
_id: 1,
|
||||
teamDisplayName: 1,
|
||||
@@ -305,12 +315,13 @@ Template.people.helpers({
|
||||
},
|
||||
peopleList() {
|
||||
const tpl = Template.instance();
|
||||
const page = tpl.peoplePage.get();
|
||||
const skipUsers = (page - 1) * usersPerPage;
|
||||
// The 'people' publication already returns only the current page (server-side
|
||||
// limit/skip, sorted createdAt:-1). Display exactly what it published:
|
||||
// re-applying skip/limit here paginated an already-paginated set — that is
|
||||
// why page 1 showed 25, page 2 showed a single stray doc (the admin's own
|
||||
// user record, always present in minimongo) and later pages were empty.
|
||||
const users = Users.find(tpl.findUsersOptions.get(), {
|
||||
sort: { username: 1 },
|
||||
skip: skipUsers,
|
||||
limit: usersPerPage,
|
||||
sort: { createdAt: -1 },
|
||||
fields: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
@@ -384,7 +395,10 @@ Template.people.helpers({
|
||||
|
||||
Template.people.events({
|
||||
'scroll .main-body'(event, tpl) {
|
||||
if (tpl.peopleSetting.get()) {
|
||||
// Orgs, teams and people all use explicit prev/next pagination (server-side
|
||||
// limit/skip driven by orgPage/teamPage/peoplePage). Infinite scroll must
|
||||
// stay disabled on those tabs so the two paging mechanisms don't fight.
|
||||
if (tpl.orgSetting.get() || tpl.teamSetting.get() || tpl.peopleSetting.get()) {
|
||||
return;
|
||||
}
|
||||
tpl.infiniteScrolling.checkScrollPosition(event.currentTarget, () => {
|
||||
@@ -521,6 +535,66 @@ Template.people.events({
|
||||
'click a.js-locked-users-menu'(event, tpl) {
|
||||
tpl.switchMenu(event);
|
||||
},
|
||||
'click a.js-roles-menu'(event, tpl) {
|
||||
tpl.switchMenu(event);
|
||||
},
|
||||
});
|
||||
|
||||
Template.rolesGeneral.onCreated(function () {
|
||||
// Working copy of the allowed-roles set; null until the published doc loads.
|
||||
this.workingRoles = new ReactiveVar(null);
|
||||
this.autorun(() => {
|
||||
if (this.workingRoles.get() === null) {
|
||||
const doc = InviteToBoardRolesSettings.findOne(INVITE_TO_BOARD_ROLES_ID);
|
||||
if (doc) {
|
||||
this.workingRoles.set((doc.allowedRoles || []).slice());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.rolesGeneral.helpers({
|
||||
roleOptions() {
|
||||
const working = Template.instance().workingRoles.get() || [];
|
||||
// The role key doubles as the i18n key. 'board-admin' renders as
|
||||
// "Board Admin", deliberately distinct from the global Admin Panel admin.
|
||||
return INVITE_TO_BOARD_ROLES.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
allowed: working.includes(key),
|
||||
}));
|
||||
},
|
||||
allRolesAllowed() {
|
||||
const working = Template.instance().workingRoles.get() || [];
|
||||
return INVITE_TO_BOARD_ROLES.every((key) => working.includes(key));
|
||||
},
|
||||
});
|
||||
|
||||
Template.rolesGeneral.events({
|
||||
'click a.js-toggle-role'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const role = $(event.currentTarget).data('role');
|
||||
const working = (tpl.workingRoles.get() || []).slice();
|
||||
const idx = working.indexOf(role);
|
||||
if (idx >= 0) {
|
||||
working.splice(idx, 1);
|
||||
} else {
|
||||
working.push(role);
|
||||
}
|
||||
tpl.workingRoles.set(working);
|
||||
},
|
||||
'click a.js-toggle-all-roles'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const working = tpl.workingRoles.get() || [];
|
||||
const allOn = INVITE_TO_BOARD_ROLES.every((key) => working.includes(key));
|
||||
tpl.workingRoles.set(allOn ? [] : INVITE_TO_BOARD_ROLES.slice());
|
||||
},
|
||||
'click .js-roles-save'(event, tpl) {
|
||||
event.preventDefault();
|
||||
InviteToBoardRolesSettings.update(INVITE_TO_BOARD_ROLES_ID, {
|
||||
$set: { allowedRoles: tpl.workingRoles.get() || [] },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Template.orgRow.helpers({
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
}
|
||||
.setting-content .content-body {
|
||||
display: flex;
|
||||
/* Always keep the menu (left) and content (right) side by side, even on
|
||||
narrow windows. The side menu has a fixed basis and the main body fills
|
||||
the rest, so both stay visible without switching to a stacked layout. */
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
padding-top: 2vh;
|
||||
height: 100%;
|
||||
gap: 1.3vw;
|
||||
@@ -30,6 +35,8 @@
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 0.5vw;
|
||||
width: min(250px, 32vw);
|
||||
/* Never let the menu be squeezed away on narrow windows; keep it visible. */
|
||||
flex: 0 0 auto;
|
||||
box-shadow: inset -0.2vh -0.2vh 0.4vh rgba(0,0,0,0.05);
|
||||
}
|
||||
.setting-content .content-body .side-menu ul li {
|
||||
@@ -62,9 +69,14 @@
|
||||
overflow-x: scroll !important;
|
||||
overflow-y: scroll !important;
|
||||
scrollbar-gutter: stable;
|
||||
/* Force horizontal scrollbar to always be visible */
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
/* Grow into the space left next to the side menu. flex-basis MUST be 0 here:
|
||||
this element holds a min-width:1200px table and a 100vw ::after, so a
|
||||
content-based (auto) basis is huge and, as an overflow:scroll flex item,
|
||||
resolves unpredictably (collapsing the panel). Basis 0 + grow makes the
|
||||
width = container - side menu, deterministically. min-width:0 lets it
|
||||
shrink so the side menu is never pushed out on narrow windows. */
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Ensure scrollbars are always visible with proper styling for all admin pages */
|
||||
@@ -249,3 +261,20 @@ li.has-error .form-group .wekan-form-control {
|
||||
max-width: calc(100vw - 240px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .attachment-limit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .attachment-limit-row input.wekan-form-control {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .attachment-limit-row select.wekan-form-control {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -44,11 +44,6 @@ template(name="setting")
|
||||
span.emoji-icon
|
||||
i.fa.fa-globe
|
||||
| {{_ 'global-webhook'}}
|
||||
else if isAttachmentSettings
|
||||
span
|
||||
span.emoji-icon
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'attachments'}}
|
||||
//- COMMENTED OUT: Migration settings title section
|
||||
.content-body
|
||||
.side-menu
|
||||
@@ -94,86 +89,10 @@ template(name="setting")
|
||||
span.emoji-icon
|
||||
i.fa.fa-globe
|
||||
| {{_ 'global-webhook'}}
|
||||
li(class="{{#if isAttachmentSettings}}active{{/if}}")
|
||||
a.js-setting-menu(data-id="attachment-settings")
|
||||
span.emoji-icon
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'attachments'}}
|
||||
//- COMMENTED OUT: Migration menu item
|
||||
.main-body
|
||||
if isLoading
|
||||
+spinner
|
||||
else if isAttachmentSettings
|
||||
ul#attachment-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'attachment-storage-configuration'}}
|
||||
.form-group
|
||||
label {{_ 'writable-path'}}
|
||||
input.wekan-form-control#filesystem-path(type="text" value="{{filesystemPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'filesystem-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'attachments-path'}}
|
||||
input.wekan-form-control#attachments-path(type="text" value="{{attachmentsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'attachments-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'avatars-path'}}
|
||||
input.wekan-form-control#avatars-path(type="text" value="{{avatarsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'avatars-path-description'}}
|
||||
|
||||
li
|
||||
h3 {{_ 'mongodb-gridfs-storage'}}
|
||||
.form-group
|
||||
label {{_ 'gridfs-enabled'}}
|
||||
input.wekan-form-control#gridfs-enabled(type="checkbox" checked="{{gridfsEnabled}}" disabled)
|
||||
small.form-text.text-muted {{_ 'gridfs-enabled-description'}}
|
||||
|
||||
li
|
||||
h3 {{_ 's3-minio-storage'}}
|
||||
.form-group
|
||||
label {{_ 's3-enabled'}}
|
||||
input.wekan-form-control#s3-enabled(type="checkbox" checked="{{s3Enabled}}" disabled)
|
||||
small.form-text.text-muted {{_ 's3-enabled-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-endpoint'}}
|
||||
input.wekan-form-control#s3-endpoint(type="text" value="{{s3Endpoint}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-endpoint-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-bucket'}}
|
||||
input.wekan-form-control#s3-bucket(type="text" value="{{s3Bucket}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-bucket-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-region'}}
|
||||
input.wekan-form-control#s3-region(type="text" value="{{s3Region}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-region-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-access-key'}}
|
||||
input.wekan-form-control#s3-access-key(type="text" placeholder="{{_ 's3-access-key-placeholder'}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-access-key-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-secret-key'}}
|
||||
input.wekan-form-control#s3-secret-key(type="password" placeholder="{{_ 's3-secret-key-placeholder'}}")
|
||||
small.form-text.text-muted {{_ 's3-secret-key-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-ssl-enabled'}}
|
||||
input.wekan-form-control#s3-ssl-enabled(type="checkbox" checked="{{s3SslEnabled}}" disabled)
|
||||
small.form-text.text-muted {{_ 's3-ssl-enabled-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-port'}}
|
||||
input.wekan-form-control#s3-port(type="number" value="{{s3Port}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-port-description'}}
|
||||
|
||||
.form-group
|
||||
button.js-test-s3-connection.btn.btn-secondary {{_ 'test-s3-connection'}}
|
||||
button.js-save-s3-settings.btn.btn-primary {{_ 'save-s3-settings'}}
|
||||
else if isGeneralSetting
|
||||
+general
|
||||
else if isEmailSetting
|
||||
|
||||
@@ -118,6 +118,128 @@ function cleanAndValidateJSON(content) {
|
||||
}
|
||||
}
|
||||
|
||||
const LIMIT_UNIT_FACTORS = {
|
||||
bytes: 1,
|
||||
mb: 1024 * 1024,
|
||||
gb: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT_SETTINGS = {
|
||||
attachmentsUploadMaxBytes: 0,
|
||||
attachmentsDownloadMaxBytes: 0,
|
||||
apiUploadMaxBytes: 50 * 1024 * 1024,
|
||||
apiDownloadMaxBytes: 20 * 1024 * 1024,
|
||||
};
|
||||
|
||||
function toNonNegativeInteger(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeLimitSettings(settingsDoc) {
|
||||
const fromDoc = settingsDoc?.limitSettings || {};
|
||||
const legacyUpload = settingsDoc?.uploadSettings?.maxFileSize;
|
||||
|
||||
const attachmentsUploadMaxBytes = Number.isFinite(fromDoc.attachmentsUploadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.attachmentsUploadMaxBytes, DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes)
|
||||
: (Number.isFinite(legacyUpload)
|
||||
? toNonNegativeInteger(legacyUpload, DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes);
|
||||
|
||||
return {
|
||||
attachmentsUploadMaxBytes,
|
||||
attachmentsDownloadMaxBytes: Number.isFinite(fromDoc.attachmentsDownloadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.attachmentsDownloadMaxBytes, DEFAULT_LIMIT_SETTINGS.attachmentsDownloadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.attachmentsDownloadMaxBytes,
|
||||
apiUploadMaxBytes: Number.isFinite(fromDoc.apiUploadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.apiUploadMaxBytes, DEFAULT_LIMIT_SETTINGS.apiUploadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.apiUploadMaxBytes,
|
||||
apiDownloadMaxBytes: Number.isFinite(fromDoc.apiDownloadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.apiDownloadMaxBytes, DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function pickUnitForBytes(bytes) {
|
||||
const safeBytes = toNonNegativeInteger(bytes, 0);
|
||||
if (safeBytes > 0 && safeBytes % LIMIT_UNIT_FACTORS.gb === 0) {
|
||||
return 'gb';
|
||||
}
|
||||
if (safeBytes > 0 && safeBytes % LIMIT_UNIT_FACTORS.mb === 0) {
|
||||
return 'mb';
|
||||
}
|
||||
return 'bytes';
|
||||
}
|
||||
|
||||
function toDisplayValue(bytes, unit) {
|
||||
const safeBytes = toNonNegativeInteger(bytes, 0);
|
||||
const factor = LIMIT_UNIT_FACTORS[unit] || LIMIT_UNIT_FACTORS.bytes;
|
||||
return safeBytes / factor;
|
||||
}
|
||||
|
||||
function toBytes(value, unit) {
|
||||
const numericValue = Number.parseFloat(value);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return null;
|
||||
}
|
||||
const factor = LIMIT_UNIT_FACTORS[unit] || LIMIT_UNIT_FACTORS.bytes;
|
||||
return Math.round(numericValue * factor);
|
||||
}
|
||||
|
||||
function refreshAttachmentStorageSettings(tpl, showLoading = false) {
|
||||
if (!tpl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
tpl.loading.set(true);
|
||||
}
|
||||
|
||||
Meteor.call('getAttachmentStorageSettings', (error, settings) => {
|
||||
if (showLoading) {
|
||||
tpl.loading.set(false);
|
||||
}
|
||||
|
||||
if (error || !settings) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Failed to load attachment storage settings:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLimits = normalizeLimitSettings(settings);
|
||||
tpl.attachmentStorageSettings.set({
|
||||
...settings,
|
||||
limitSettings: normalizedLimits,
|
||||
});
|
||||
|
||||
tpl.attachmentLimitUnits.set({
|
||||
attachmentsUploadMaxBytes: pickUnitForBytes(normalizedLimits.attachmentsUploadMaxBytes),
|
||||
attachmentsDownloadMaxBytes: pickUnitForBytes(normalizedLimits.attachmentsDownloadMaxBytes),
|
||||
apiUploadMaxBytes: pickUnitForBytes(normalizedLimits.apiUploadMaxBytes),
|
||||
apiDownloadMaxBytes: pickUnitForBytes(normalizedLimits.apiDownloadMaxBytes),
|
||||
});
|
||||
|
||||
tpl.attachmentLimitEnabled.set({
|
||||
attachmentsUploadMaxBytes: normalizedLimits.attachmentsUploadMaxBytes > 0,
|
||||
attachmentsDownloadMaxBytes: normalizedLimits.attachmentsDownloadMaxBytes > 0,
|
||||
apiUploadMaxBytes: normalizedLimits.apiUploadMaxBytes > 0,
|
||||
apiDownloadMaxBytes: normalizedLimits.apiDownloadMaxBytes > 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLimitUnitOptions(selectedUnit) {
|
||||
return [
|
||||
{ value: 'gb', labelKey: 'attachment-limit-unit-gb', selected: selectedUnit === 'gb' },
|
||||
{ value: 'mb', labelKey: 'attachment-limit-unit-mb', selected: selectedUnit === 'mb' },
|
||||
{ value: 'bytes', labelKey: 'attachment-limit-unit-bytes', selected: selectedUnit === 'bytes' },
|
||||
];
|
||||
}
|
||||
|
||||
Template.setting.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
@@ -131,6 +253,19 @@ Template.setting.onCreated(function () {
|
||||
this.layoutSetting = new ReactiveVar(false);
|
||||
this.webhookSetting = new ReactiveVar(false);
|
||||
this.attachmentSettings = new ReactiveVar(false);
|
||||
this.attachmentStorageSettings = new ReactiveVar(null);
|
||||
this.attachmentLimitUnits = new ReactiveVar({
|
||||
attachmentsUploadMaxBytes: 'mb',
|
||||
attachmentsDownloadMaxBytes: 'mb',
|
||||
apiUploadMaxBytes: 'mb',
|
||||
apiDownloadMaxBytes: 'mb',
|
||||
});
|
||||
this.attachmentLimitEnabled = new ReactiveVar({
|
||||
attachmentsUploadMaxBytes: false,
|
||||
attachmentsDownloadMaxBytes: false,
|
||||
apiUploadMaxBytes: false,
|
||||
apiDownloadMaxBytes: false,
|
||||
});
|
||||
// this.cronSettings = new ReactiveVar(false);
|
||||
// this.migrationErrorsList = new ReactiveVar([]);
|
||||
|
||||
@@ -142,6 +277,7 @@ Template.setting.onCreated(function () {
|
||||
Meteor.subscribe('accessibilitySettings');
|
||||
Meteor.subscribe('globalwebhooks');
|
||||
Meteor.subscribe('lockoutSettings');
|
||||
Meteor.subscribe('attachmentStorageSettings');
|
||||
|
||||
// Poll for migration errors
|
||||
// this.errorPollInterval = Meteor.setInterval(() => {
|
||||
@@ -161,6 +297,52 @@ Template.setting.onDestroyed(function () {
|
||||
// }
|
||||
});
|
||||
|
||||
Template.setting.onRendered(function () {
|
||||
this.previousAttachmentLimitEnabled = null;
|
||||
|
||||
this.autorun(() => {
|
||||
const enabledMap = this.attachmentLimitEnabled?.get() || {};
|
||||
const previousMap = this.previousAttachmentLimitEnabled || {};
|
||||
const fields = [
|
||||
'attachmentsUploadMaxBytes',
|
||||
'attachmentsDownloadMaxBytes',
|
||||
'apiUploadMaxBytes',
|
||||
'apiDownloadMaxBytes',
|
||||
];
|
||||
|
||||
fields.forEach((fieldName) => {
|
||||
const row = this.$(`.attachment-limit-row[data-field="${fieldName}"]`);
|
||||
if (!row.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnabled = enabledMap[fieldName] === true;
|
||||
const wasEnabled = previousMap[fieldName];
|
||||
|
||||
if (typeof wasEnabled === 'undefined') {
|
||||
if (isEnabled) {
|
||||
row.show();
|
||||
} else {
|
||||
row.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnabled === wasEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnabled) {
|
||||
row.stop(true, true).hide().slideDown();
|
||||
} else {
|
||||
row.stop(true, true).slideUp();
|
||||
}
|
||||
});
|
||||
|
||||
this.previousAttachmentLimitEnabled = { ...enabledMap };
|
||||
});
|
||||
});
|
||||
|
||||
Template.setting.helpers({
|
||||
isGeneralSetting() {
|
||||
const inst = Template.instance();
|
||||
@@ -252,6 +434,28 @@ Template.setting.helpers({
|
||||
return process.env.S3_PORT || 443;
|
||||
},
|
||||
|
||||
attachmentTransferLimitValue(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const settingsDoc = tpl.attachmentStorageSettings.get();
|
||||
const units = tpl.attachmentLimitUnits.get() || {};
|
||||
const limits = normalizeLimitSettings(settingsDoc);
|
||||
const unit = units[fieldName] || 'bytes';
|
||||
return toDisplayValue(limits[fieldName], unit);
|
||||
},
|
||||
|
||||
attachmentTransferLimitUnitOptions(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const units = tpl.attachmentLimitUnits.get() || {};
|
||||
const selectedUnit = units[fieldName] || 'bytes';
|
||||
return getLimitUnitOptions(selectedUnit);
|
||||
},
|
||||
|
||||
isAttachmentLimitEnabled(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const enabledMap = tpl.attachmentLimitEnabled.get() || {};
|
||||
return enabledMap[fieldName] === true;
|
||||
},
|
||||
|
||||
// Cron settings helpers
|
||||
// migrationStatus() {
|
||||
// return cronMigrationStatus.get() || TAPi18n.__('idle');
|
||||
@@ -466,6 +670,7 @@ Template.setting.events({
|
||||
tpl.webhookSetting.set(true);
|
||||
} else if (targetID === 'attachment-settings') {
|
||||
tpl.attachmentSettings.set(true);
|
||||
refreshAttachmentStorageSettings(tpl, true);
|
||||
// Set default sub-menu state for attachment settings
|
||||
console.log('Initializing attachment sub-menu');
|
||||
} // else if (targetID === 'cron-settings') {
|
||||
@@ -800,6 +1005,93 @@ Template.setting.events({
|
||||
});
|
||||
},
|
||||
|
||||
'change select.js-attachment-limit-unit'(event, tpl) {
|
||||
const fieldName = event.currentTarget.dataset.field;
|
||||
const selectedUnit = event.currentTarget.value;
|
||||
if (!fieldName || !selectedUnit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = tpl.attachmentLimitUnits.get() || {};
|
||||
tpl.attachmentLimitUnits.set({
|
||||
...current,
|
||||
[fieldName]: selectedUnit,
|
||||
});
|
||||
},
|
||||
|
||||
'click a.js-toggle-attachment-limit'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const fieldName = event.currentTarget.dataset.field;
|
||||
if (!fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = tpl.attachmentLimitEnabled.get() || {};
|
||||
tpl.attachmentLimitEnabled.set({
|
||||
...current,
|
||||
[fieldName]: !current[fieldName],
|
||||
});
|
||||
},
|
||||
|
||||
'click button.js-save-attachment-transfer-limits'(event, tpl) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentSettings = tpl.attachmentStorageSettings.get();
|
||||
if (!currentSettings) {
|
||||
alert(TAPi18n.__('attachment-transfer-limits-save-failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUnits = tpl.attachmentLimitUnits.get() || {};
|
||||
const enabledMap = tpl.attachmentLimitEnabled.get() || {};
|
||||
const fieldConfig = [
|
||||
{ fieldName: 'attachmentsUploadMaxBytes', inputId: '#attachments-upload-limit-value' },
|
||||
{ fieldName: 'attachmentsDownloadMaxBytes', inputId: '#attachments-download-limit-value' },
|
||||
{ fieldName: 'apiUploadMaxBytes', inputId: '#api-upload-limit-value' },
|
||||
{ fieldName: 'apiDownloadMaxBytes', inputId: '#api-download-limit-value' },
|
||||
];
|
||||
|
||||
const nextLimitSettings = {};
|
||||
for (const field of fieldConfig) {
|
||||
if (!enabledMap[field.fieldName]) {
|
||||
nextLimitSettings[field.fieldName] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const unit = currentUnits[field.fieldName] || 'bytes';
|
||||
const value = $(field.inputId).val();
|
||||
const bytesValue = toBytes(value, unit);
|
||||
if (bytesValue === null || bytesValue <= 0) {
|
||||
alert(TAPi18n.__('attachment-transfer-limits-invalid-value'));
|
||||
return;
|
||||
}
|
||||
nextLimitSettings[field.fieldName] = bytesValue;
|
||||
}
|
||||
|
||||
const nextSettings = {
|
||||
...currentSettings,
|
||||
uploadSettings: {
|
||||
...(currentSettings.uploadSettings || {}),
|
||||
// Keep legacy field in sync for backward compatibility.
|
||||
maxFileSize: nextLimitSettings.attachmentsUploadMaxBytes,
|
||||
},
|
||||
limitSettings: {
|
||||
...(currentSettings.limitSettings || {}),
|
||||
...nextLimitSettings,
|
||||
},
|
||||
};
|
||||
|
||||
Meteor.call('updateAttachmentStorageSettings', nextSettings, (error) => {
|
||||
if (error) {
|
||||
alert(`${TAPi18n.__('attachment-transfer-limits-save-failed')}: ${error.reason || error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
alert(TAPi18n.__('attachment-transfer-limits-saved'));
|
||||
refreshAttachmentStorageSettings(tpl, false);
|
||||
});
|
||||
},
|
||||
|
||||
// Event handlers for cron settings
|
||||
// 'click button.js-start-migration'(event, tpl) {
|
||||
// event.preventDefault();
|
||||
|
||||
@@ -26,3 +26,26 @@
|
||||
margin-top: 1px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* Keep all admin tabs (Settings, People, Reports, Attachments, Translation,
|
||||
Version) visible on narrow screens. The bar is a fixed 40px tall and the
|
||||
tabs float, so on narrow widths they wrapped below the bar and were clipped
|
||||
(disappeared). Let the bar grow and wrap the tabs to as many rows as needed
|
||||
so none are ever hidden. Scoped via :has() so the board header is untouched. */
|
||||
#header #header-main-bar:has(.setting-header-btns) {
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
padding-bottom: 6px;
|
||||
overflow: visible;
|
||||
}
|
||||
#header #header-main-bar .setting-header-btns.left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
float: none;
|
||||
}
|
||||
#header #header-main-bar .setting-header-btn {
|
||||
float: none;
|
||||
margin: 2px 0;
|
||||
}
|
||||
@@ -342,3 +342,51 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
|
||||
padding-top: 5px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
/* Board backgrounds management popup */
|
||||
.board-backgrounds-manage .board-backgrounds-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.board-bg-item {
|
||||
width: 48%;
|
||||
margin: 1%;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.board-bg-item.is-active {
|
||||
border-color: #3cb500;
|
||||
}
|
||||
.board-bg-thumb {
|
||||
height: 80px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
}
|
||||
.board-bg-active-check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: #3cb500;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
}
|
||||
.board-bg-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.board-bg-actions a {
|
||||
cursor: pointer;
|
||||
color: #4d4d4d;
|
||||
}
|
||||
.board-bg-actions a:hover {
|
||||
color: #000;
|
||||
}
|
||||
.js-bg-upload-button {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ template(name="membersWidget")
|
||||
if currentUser.isBoardMember
|
||||
a.member.add-member.sandstorm-powerbox-request-identity(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
else if currentUser.isBoardAdmin
|
||||
else if currentUser.canInviteToBoard
|
||||
a.member.add-member.js-manage-board-members(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
.clearfix
|
||||
@@ -129,6 +129,38 @@ template(name="boardChangeColorPopup")
|
||||
if isSelected
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="boardBackgroundsPopup")
|
||||
.board-backgrounds-manage
|
||||
input.js-bg-upload-input.hide(type="file" name="file" accept="image/*")
|
||||
a.primary.wide.js-bg-upload-button
|
||||
i.fa.fa-plus
|
||||
= ' '
|
||||
| {{_ 'upload-background'}}
|
||||
if uploading.get
|
||||
+spinner
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
.board-backgrounds-grid.clearfix
|
||||
each backgrounds
|
||||
.board-bg-item(class="{{#if isActiveBackground}}is-active{{/if}}")
|
||||
.board-bg-thumb(style="background-image: url('{{link}}');")
|
||||
if isActiveBackground
|
||||
i.fa.fa-check.board-bg-active-check
|
||||
.board-bg-actions
|
||||
unless isActiveBackground
|
||||
a.js-set-board-background(title="{{_ 'set-as-active'}}")
|
||||
i.fa.fa-check-circle-o
|
||||
a.js-download-board-background(href="{{link}}?download=true" download="{{name}}" title="{{_ 'download'}}")
|
||||
i.fa.fa-arrow-down
|
||||
a.js-delete-board-background(title="{{_ 'delete'}}")
|
||||
i.fa.fa-trash-o
|
||||
|
||||
template(name="deleteBoardBackgroundPopup")
|
||||
p {{_ 'board-background-delete-pop'}}
|
||||
button.js-confirm.negate.full(type="submit")
|
||||
i.fa.fa-trash-o
|
||||
| {{_ 'delete'}}
|
||||
|
||||
template(name="boardChangeBackgroundImagePopup")
|
||||
form
|
||||
label
|
||||
@@ -567,6 +599,10 @@ template(name="boardMenuPopup")
|
||||
a.js-change-background-image
|
||||
i.fa.fa-picture-o
|
||||
| {{_ 'board-change-background-image'}}
|
||||
li
|
||||
a.js-manage-board-backgrounds
|
||||
i.fa.fa-image
|
||||
| {{_ 'board-backgrounds'}}
|
||||
//Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
|
||||
//if currentUser.isBoardAdmin
|
||||
//
|
||||
|
||||
@@ -7,6 +7,8 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { InfiniteScrolling } from '/client/lib/infiniteScrolling';
|
||||
import AccessibilitySettings from '/models/accessibilitySettings';
|
||||
import Boards from '/models/boards';
|
||||
import Attachments from '/models/attachments';
|
||||
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
|
||||
import Integrations from '/models/integrations';
|
||||
import Lists from '/models/lists';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
@@ -347,6 +349,7 @@ Template.boardMenuPopup.events({
|
||||
},
|
||||
'click .js-change-board-color': Popup.open('boardChangeColor'),
|
||||
'click .js-change-background-image': Popup.open('boardChangeBackgroundImage'),
|
||||
'click .js-manage-board-backgrounds': Popup.open('boardBackgrounds'),
|
||||
'click .js-board-info-on-my-boards': Popup.open('boardInfoOnMyBoards'),
|
||||
'click .js-change-language': Popup.open('changeLanguage'),
|
||||
'click .js-delete-duplicate-lists': Popup.afterConfirm('deleteDuplicateLists', function() {
|
||||
@@ -869,6 +872,87 @@ Template.boardChangeBackgroundImagePopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// Manage the board's stored background images (upload / set active / download /
|
||||
// delete). Backgrounds are board-level Attachments (meta.boardId, no cardId,
|
||||
// meta.source === 'board-background') in the default attachments storage.
|
||||
Template.boardBackgroundsPopup.onCreated(function () {
|
||||
this.uploading = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar('');
|
||||
const board = Utils.getCurrentBoard();
|
||||
this.boardId = board && board._id;
|
||||
if (this.boardId) {
|
||||
this.subscribe('boardBackgrounds', this.boardId);
|
||||
}
|
||||
});
|
||||
|
||||
Template.boardBackgroundsPopup.helpers({
|
||||
uploading() {
|
||||
return Template.instance().uploading;
|
||||
},
|
||||
error() {
|
||||
return Template.instance().error;
|
||||
},
|
||||
backgrounds() {
|
||||
// Raw collection docs don't carry the .link() helper, so compute the URL.
|
||||
return Attachments.collection
|
||||
.find({
|
||||
'meta.boardId': Template.instance().boardId,
|
||||
'meta.source': 'board-background',
|
||||
})
|
||||
.fetch()
|
||||
.map(att => ({
|
||||
_id: att._id,
|
||||
name: att.name,
|
||||
link: generateUniversalAttachmentUrl(att._id),
|
||||
}));
|
||||
},
|
||||
isActiveBackground() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return board && board.backgroundImageId === this._id;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardBackgroundsPopup.events({
|
||||
'click .js-bg-upload-button'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.find('.js-bg-upload-input').click();
|
||||
},
|
||||
async 'change .js-bg-upload-input'(event, tpl) {
|
||||
const file = event.currentTarget.files && event.currentTarget.files[0];
|
||||
if (!file) return;
|
||||
tpl.error.set('');
|
||||
tpl.uploading.set(true);
|
||||
const uploader = await Attachments.insertAsync(
|
||||
{
|
||||
file,
|
||||
chunkSize: 'dynamic',
|
||||
meta: { boardId: tpl.boardId, source: 'board-background' },
|
||||
},
|
||||
false,
|
||||
);
|
||||
uploader.on('end', (err) => {
|
||||
tpl.uploading.set(false);
|
||||
if (err) tpl.error.set(err.reason || 'upload-failed');
|
||||
});
|
||||
uploader.on('error', (err) => {
|
||||
tpl.uploading.set(false);
|
||||
tpl.error.set((err && err.reason) || 'upload-failed');
|
||||
});
|
||||
uploader.start();
|
||||
// allow re-selecting the same file later
|
||||
event.currentTarget.value = '';
|
||||
},
|
||||
async 'click .js-set-board-background'() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
await board.setBackgroundImage(this._id);
|
||||
Utils.setBackgroundImage();
|
||||
},
|
||||
'click .js-delete-board-background': Popup.afterConfirm('deleteBoardBackground', function () {
|
||||
Meteor.call('removeBoardBackground', this._id);
|
||||
Popup.back();
|
||||
}),
|
||||
});
|
||||
|
||||
Template.boardInfoOnMyBoardsPopup.onCreated(function() {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
});
|
||||
|
||||
@@ -139,6 +139,15 @@ Template.createCustomFieldPopup.helpers({
|
||||
});
|
||||
},
|
||||
|
||||
// The template iterates `{{#each dropdownItems.get}}`. This component used to
|
||||
// be a BlazeComponent, where `dropdownItems` resolved to the instance's
|
||||
// ReactiveVar; after the migration to a plain Template that binding rendered
|
||||
// nothing (so dropdown options never appeared and were lost on save). Expose
|
||||
// the ReactiveVar as a helper again so `dropdownItems.get` works.
|
||||
dropdownItems() {
|
||||
return Template.instance().dropdownItems;
|
||||
},
|
||||
|
||||
getDropdownItems() {
|
||||
return getDropdownItems(Template.instance());
|
||||
},
|
||||
|
||||
@@ -163,6 +163,19 @@ function currentListIsInThisSwimlane(swimlaneId) {
|
||||
|
||||
function currentCardIsInThisList(listId, swimlaneId) {
|
||||
const currentCard = Utils.getCurrentCard();
|
||||
if (!currentCard) return false;
|
||||
// On desktop a clicked card is shown as a draggable popup via the openCards
|
||||
// list (rendered once in boardBody). Don't ALSO render it inline here, or the
|
||||
// card opens twice — the inline copy sits under the popup and becomes visible
|
||||
// when the popup is dragged away by its titlebar handle. Direct card-URL
|
||||
// navigation sets only currentCard (not openCards), so those still render
|
||||
// inline as before.
|
||||
if (!Utils.isMiniScreen()) {
|
||||
const openCards = Session.get('openCards') || [];
|
||||
if (openCards.includes(currentCard._id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (
|
||||
//currentUser &&
|
||||
@@ -475,9 +488,19 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
const minHeight = 100;
|
||||
const maxHeight = 2000;
|
||||
|
||||
// Read the vertical page coordinate from either a jQuery mouse event or a
|
||||
// native touch event (touchstart/move expose `touches`, touchend exposes
|
||||
// `changedTouches`).
|
||||
const getEventPageY = (e) => {
|
||||
const oe = e.originalEvent || e;
|
||||
if (oe.touches && oe.touches.length) return oe.touches[0].pageY;
|
||||
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageY;
|
||||
return e.pageY;
|
||||
};
|
||||
|
||||
const startResize = (e) => {
|
||||
isResizing = true;
|
||||
startY = e.pageY || e.originalEvent.touches[0].pageY;
|
||||
startY = getEventPageY(e);
|
||||
startHeight = parseInt($swimlane.css('height')) || 300;
|
||||
|
||||
$swimlane.addClass('swimlane-resizing');
|
||||
@@ -493,7 +516,7 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentY = e.pageY || e.originalEvent.touches[0].pageY;
|
||||
const currentY = getEventPageY(e);
|
||||
const deltaY = currentY - startY;
|
||||
const newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
|
||||
|
||||
@@ -517,7 +540,7 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
isResizing = false;
|
||||
|
||||
// Calculate final height
|
||||
const currentY = e.pageY || e.originalEvent.touches[0].pageY;
|
||||
const currentY = getEventPageY(e);
|
||||
const deltaY = currentY - startY;
|
||||
const finalHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
|
||||
|
||||
@@ -582,10 +605,15 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
$(document).on('mousemove', doResize);
|
||||
$(document).on('mouseup', stopResize);
|
||||
|
||||
// Touch events for mobile
|
||||
$resizeHandle.on('touchstart', startResize, { passive: false });
|
||||
$(document).on('touchmove', doResize, { passive: false });
|
||||
$(document).on('touchend', stopResize, { passive: false });
|
||||
// Touch events for mobile.
|
||||
// NOTE: jQuery's .on() does not accept an addEventListener-style options
|
||||
// object — passing { passive: false } made jQuery bind the object itself as
|
||||
// the handler, throwing "handler.apply is not a function" on every touch.
|
||||
// Use the native API so { passive: false } actually applies and
|
||||
// preventDefault() works during the resize.
|
||||
$resizeHandle[0].addEventListener('touchstart', startResize, { passive: false });
|
||||
document.addEventListener('touchmove', doResize, { passive: false });
|
||||
document.addEventListener('touchend', stopResize, { passive: false });
|
||||
|
||||
// Prevent dragscroll interference
|
||||
$resizeHandle.on('mousedown', (e) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ template(name='passwordInput')
|
||||
label(for='at-field-{{_id}}') {{displayName}}
|
||||
.password-input-container
|
||||
input.password-field(type="{{type}}" placeholder="{{displayName}}" autocomplete="{{autocomplete}}" required="{{required}}")
|
||||
button.password-toggle-btn(type="button" tabindex="-1" aria-label="Toggle password visibility" title="Toggle password visibility")
|
||||
button.password-toggle-btn.primary(type="button" tabindex="-1" aria-label="Toggle password visibility" title="Toggle password visibility")
|
||||
i.fa.fa-eye.eye-icon
|
||||
i.fa.fa-eye-slash.eye-slash-icon
|
||||
if errs
|
||||
|
||||
@@ -25,65 +25,57 @@
|
||||
width: 100%;
|
||||
}
|
||||
.password-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
.password-input-container input {
|
||||
flex: 1;
|
||||
padding-right: 55px; /* More room for the bigger button */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.password-toggle-btn {
|
||||
position: absolute;
|
||||
right: 5px; /* Adjusted for larger button */
|
||||
top: calc(50% - 26px); /* Moved up by 20px + 6px = 26px total */
|
||||
transform: translateY(-50%);
|
||||
background: #f8f8f8 !important;
|
||||
border: 1px solid #ddd !important;
|
||||
border-radius: 3px !important;
|
||||
color: #000 !important; /* Black color for the icon */
|
||||
cursor: pointer;
|
||||
padding: 8px 6px 8px 12px; /* 2x bigger padding, 6px less on right */
|
||||
font-size: 16px; /* 2x bigger font size */
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
line-height: 1;
|
||||
display: flex !important;
|
||||
/* The show/hide password button is a square: its width and height match the
|
||||
password input field height, and the field flex-grows to fill the space the
|
||||
narrower button frees up on the right. */
|
||||
.password-input-container .password-field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-layout .auth-dialog .at-form .password-input-container .password-toggle-btn {
|
||||
position: static;
|
||||
transform: none;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
min-width: 40px; /* 2x bigger minimum width */
|
||||
min-height: 32px; /* 2x bigger minimum height */
|
||||
}
|
||||
/* Adjust position for login and register pages */
|
||||
.auth-layout .password-toggle-btn {
|
||||
top: calc(50% - 11px); /* Move 15px down for login/register */
|
||||
}
|
||||
.password-toggle-btn .eye-text {
|
||||
color: #000 !important;
|
||||
font-size: 16px !important;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
filter: grayscale(100%);
|
||||
-webkit-filter: grayscale(100%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.eye-slash-line {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
stroke: #000;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
.auth-layout .auth-dialog .at-form .password-input-container .password-toggle-btn .fa {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.password-toggle-btn:hover .eye-text {
|
||||
color: #000 !important;
|
||||
filter: grayscale(100%);
|
||||
-webkit-filter: grayscale(100%);
|
||||
opacity: 0.8;
|
||||
.auth-layout .auth-dialog .at-form .password-input-container .password-field {
|
||||
padding-right: 0;
|
||||
}
|
||||
body.mobile-mode .password-input-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
body.mobile-mode .auth-layout .auth-dialog .at-form .password-input-container .password-toggle-btn {
|
||||
aspect-ratio: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.auth-layout .auth-dialog .at-form button {
|
||||
width: 100%;
|
||||
|
||||
@@ -3,6 +3,7 @@ import '/client/components/cards/cardCustomFields.jade';
|
||||
import '/client/components/cards/cardDate.jade';
|
||||
import '/client/components/cards/cardDescription.jade';
|
||||
import '/client/components/cards/cardDetails.jade';
|
||||
import '/client/components/cards/cardStickers.jade';
|
||||
import '/client/components/cards/cardTime.jade';
|
||||
import '/client/components/cards/checklists.jade';
|
||||
import '/client/components/cards/inlinedCardDescription.jade';
|
||||
@@ -16,6 +17,7 @@ import '/client/components/cards/cardCustomFields.js';
|
||||
import '/client/components/cards/cardDate.js';
|
||||
import '/client/components/cards/cardDescription.js';
|
||||
import '/client/components/cards/cardDetails.js';
|
||||
import '/client/components/cards/cardStickers.js';
|
||||
import '/client/components/cards/cardTime.js';
|
||||
import '/client/components/cards/checklists.js';
|
||||
import '/client/components/cards/labels.js';
|
||||
@@ -31,6 +33,6 @@ import '/client/components/cards/cardTime.css';
|
||||
import '/client/components/cards/checklists.css';
|
||||
import '/client/components/cards/labels.css';
|
||||
import '/client/components/cards/minicard.css';
|
||||
import '/client/components/cards/minicardOverrides.css';
|
||||
import '/client/components/cards/resultCard.css';
|
||||
import '/client/components/cards/subtasks.css';
|
||||
|
||||
@@ -16,6 +16,7 @@ import '/imports/lib/customHeadDefaults';
|
||||
import '/imports/lib/dateUtils';
|
||||
import '/imports/lib/secureDOMPurify';
|
||||
import '/models/attachmentMigrationStatus';
|
||||
import '/models/attachmentBulkMoveStatus';
|
||||
import '/imports/cronMigrationClient';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Native one-finger touch scrolling for `.dragscroll` containers.
|
||||
//
|
||||
// The @wekanteam/dragscroll library only listens for mouse events
|
||||
// (mousedown / mousemove / mouseup). On iPad / iPhone Safari finger
|
||||
// scrolling of those containers works anyway, because they are also native
|
||||
// overflow scrollers with momentum scrolling. But on Fairphone 4 postmarketOS
|
||||
// Firefox and on the newer Ubuntu Touch Morph Browser, dragging a finger over
|
||||
// a `.dragscroll` container (All Boards list, board canvas, swimlanes, lists,
|
||||
// calendar) did not scroll it, because those browsers do not emit the
|
||||
// synthetic mouse events dragscroll relies on.
|
||||
//
|
||||
// This module adds real touch handling so a one-finger drag scrolls the
|
||||
// nearest `.dragscroll` ancestor on every touch browser. It mirrors the
|
||||
// behaviour of dragscroll's mouse handler (same `.nodragscroll` / form-field
|
||||
// exclusions) and is delegated on `document`, so it also covers `.dragscroll`
|
||||
// elements that are added or replaced reactively without needing a reset.
|
||||
//
|
||||
// iOS is intentionally skipped: there finger scrolling already works natively
|
||||
// (with momentum), and taking it over with preventDefault would only remove
|
||||
// that momentum.
|
||||
|
||||
const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent);
|
||||
|
||||
let active = null;
|
||||
|
||||
function isExcluded(target) {
|
||||
return !!(
|
||||
target.closest &&
|
||||
target.closest(
|
||||
// Form fields and editors must keep their own touch behaviour, and
|
||||
// `.nodragscroll` opts an element out explicitly. `.handle` (the
|
||||
// swimlane/list drag handles, .js-swimlane-header-handle /
|
||||
// .js-list-handle) must be excluded too: those start a jQuery UI
|
||||
// sortable reorder via touch-punch, and hijacking the touch to scroll
|
||||
// here would block dragging the handle to reorder swimlanes/lists.
|
||||
'input, select, textarea, .note-editable, .nodragscroll, .handle, .ui-sortable-handle',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canScroll(el) {
|
||||
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
||||
}
|
||||
|
||||
function onTouchStart(e) {
|
||||
active = null;
|
||||
if (e.touches.length !== 1) return;
|
||||
const target = e.target;
|
||||
if (isExcluded(target)) return;
|
||||
const el = target.closest && target.closest('.dragscroll');
|
||||
if (!el || !canScroll(el)) return;
|
||||
const touch = e.touches[0];
|
||||
active = {
|
||||
el,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
scrollLeft: el.scrollLeft,
|
||||
scrollTop: el.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!active || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const el = active.el;
|
||||
el.scrollLeft = active.scrollLeft - (touch.clientX - active.startX);
|
||||
el.scrollTop = active.scrollTop - (touch.clientY - active.startY);
|
||||
// We own the gesture: suppress page rubber-banding and the synthetic mouse
|
||||
// events that would otherwise double-scroll through dragscroll.
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
active = null;
|
||||
}
|
||||
|
||||
// Guard so importing this module from more than one place (boardBody,
|
||||
// boardsList, ...) still only attaches the listeners once.
|
||||
if (!isIOS && typeof document !== 'undefined' && !window.__wekanDragscrollTouch) {
|
||||
window.__wekanDragscrollTouch = true;
|
||||
document.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
}
|
||||
+17
-2
@@ -84,7 +84,7 @@ window.Popup = new (class {
|
||||
hasPopupParent: clickFromPopup(evt),
|
||||
title: self._getTitle(popupName),
|
||||
depth: self._stack.length,
|
||||
offset: self._getOffset(openerElement),
|
||||
offset: self._getOffset(openerElement, popupName),
|
||||
dataContext: (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ window.Popup = new (class {
|
||||
// We automatically calculate the popup offset from the reference element
|
||||
// position and dimensions. We also reactively use the window dimensions to
|
||||
// ensure that the popup is always visible on the screen.
|
||||
_getOffset(element) {
|
||||
_getOffset(element, popupName) {
|
||||
const $element = $(element);
|
||||
return () => {
|
||||
Utils.windowResizeDep.depend();
|
||||
@@ -230,6 +230,21 @@ window.Popup = new (class {
|
||||
// Calculate actual popup width based on CSS: min(380px, 55vw)
|
||||
const popupWidth = Math.min(380, viewportWidth * 0.55);
|
||||
|
||||
// Card details popup: dock it to the top of the viewport so it overlays
|
||||
// the global and board header bars (its z-index already sits above them)
|
||||
// instead of opening downward from the clicked minicard.
|
||||
if (popupName === 'cardDetailsPopup') {
|
||||
const cardWidth = Math.min(600, viewportWidth * 0.8);
|
||||
const centeredLeft = (viewportWidth - cardWidth) / 2;
|
||||
// top is forced to 0 in CSS (.pop-over[data-popup="cardDetailsPopup"])
|
||||
// so the card overlays the header bars; use the full viewport height.
|
||||
return {
|
||||
left: Math.max(viewportPadding, centeredLeft),
|
||||
top: 0,
|
||||
maxHeight: viewportHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// If the opener element is missing (e.g., programmatic open), fallback to viewport origin
|
||||
if (!$element || $element.length === 0) {
|
||||
return {
|
||||
|
||||
+34
-6
@@ -99,9 +99,27 @@ export const Utils = {
|
||||
return user.profile.mobileMode;
|
||||
}
|
||||
|
||||
// Default to mobile mode for iPhone/iPod
|
||||
const isIPhone = /iPhone|iPod/i.test(navigator.userAgent);
|
||||
return isIPhone;
|
||||
// No explicit preference: pick a sensible default from the device.
|
||||
// Previously this returned true ONLY for iPhone/iPod, so every other phone
|
||||
// (Fairphone 4 postmarketOS Firefox, Ubuntu Touch Morph, Android Firefox,
|
||||
// ...) defaulted to desktop-mode. desktop-mode applies large desktop sizing
|
||||
// (see .desktop-mode rules in boardHeader.css) which on a small phone screen
|
||||
// looks roughly 2x too big. Default to mobile-mode for any phone-sized touch
|
||||
// device, not just iPhone, so non-iPhone phones render at the correct size.
|
||||
const ua = navigator.userAgent;
|
||||
const isIPhone = /iPhone|iPod/i.test(ua);
|
||||
const isPhoneUA =
|
||||
/Mobile|Android|BlackBerry|IEMobile|Opera Mini/i.test(ua) &&
|
||||
!/iPad/i.test(ua);
|
||||
const isUbuntuTouch = /Ubuntu/i.test(ua);
|
||||
const hasTouch =
|
||||
('maxTouchPoints' in navigator && navigator.maxTouchPoints > 0) ||
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
||||
// A small touch screen (phone-sized) — covers touch browsers whose UA does
|
||||
// not contain a phone token. Desktop browsers (no touch, or wide) stay in
|
||||
// desktop-mode.
|
||||
const isSmallTouchScreen = hasTouch && window.innerWidth <= 800;
|
||||
return isIPhone || isPhoneUA || isUbuntuTouch || isSmallTouchScreen;
|
||||
},
|
||||
|
||||
setMobileMode(enabled) {
|
||||
@@ -171,9 +189,19 @@ export const Utils = {
|
||||
boardCanvas.style.height = '';
|
||||
}
|
||||
} else {
|
||||
// Desktop mode: apply zoom to entire board-wrapper as before
|
||||
boardWrapper.style.transform = `scale(${level})`;
|
||||
boardWrapper.style.transformOrigin = 'top left';
|
||||
// Desktop mode: apply zoom to entire board-wrapper as before.
|
||||
// At 100% do NOT set transform: scale(1): any transform makes the
|
||||
// board-wrapper the containing block + stacking context for its
|
||||
// position:fixed descendants (the opened/dragged card), which traps the
|
||||
// card inside the board area below the page header bars. Clearing it at
|
||||
// 100% lets the opened card overlay the top bars.
|
||||
if (level === 1) {
|
||||
boardWrapper.style.transform = '';
|
||||
boardWrapper.style.transformOrigin = '';
|
||||
} else {
|
||||
boardWrapper.style.transform = `scale(${level})`;
|
||||
boardWrapper.style.transformOrigin = 'top left';
|
||||
}
|
||||
|
||||
// If zoom is 50% or lower, make board wrapper full width like content
|
||||
if (level <= 0.5) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# WeKan multi-tenant deployment on one Linux host
|
||||
#
|
||||
# Use this compose file when you want to run several WeKan instances
|
||||
# (one per customer / domain) on the SAME Linux host, all sharing the
|
||||
# host's kernel network namespace (network_mode: "host") and one
|
||||
# MongoDB replica set, with separate logical databases per tenant.
|
||||
#
|
||||
# This file shows the minimal extra configuration needed for the
|
||||
# `uws` DDP transport plus `changeStreams` reactivity to work
|
||||
# correctly in this topology, namely:
|
||||
#
|
||||
# - DDP_TRANSPORT=uws on every service
|
||||
# - METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling on every service
|
||||
# - METEOR_SETTINGS with a DISTINCT uws.port per service (5001, 5002, …)
|
||||
#
|
||||
# Why the distinct uws.port: the uws DDP transport runs an internal
|
||||
# WebSocket proxy server on its own TCP port (default 127.0.0.1:5001)
|
||||
# and forwards WS upgrades from the public PORT into it. When two
|
||||
# services share a kernel netns and both default to 127.0.0.1:5001,
|
||||
# the second one to start fails to bind because the listen socket is
|
||||
# opened exclusively. Give each service its own internal port via
|
||||
# METEOR_SETTINGS and they coexist cleanly.
|
||||
#
|
||||
# Reference: see docs/Platforms/FOSS/Docker/Meteor3/multitenancy.md
|
||||
# and https://docs.meteor.com/performance/ddp-transport#multitenancy
|
||||
#
|
||||
# Setup:
|
||||
# docker compose -f docker-compose-multitenancy.yml up -d
|
||||
#
|
||||
# Tear down:
|
||||
# docker compose -f docker-compose-multitenancy.yml down -v
|
||||
#
|
||||
# Adding more tenants:
|
||||
# Copy a tenant block, change the service name, PORT, ROOT_URL,
|
||||
# MONGO_URL database name, and the uws.port inside METEOR_SETTINGS
|
||||
# to a fresh value (5003, 5004, …). The MongoDB service and the
|
||||
# reverse proxy in front of WeKan don't need any change.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Shared MongoDB 7 with replica set (required for changeStreams).
|
||||
# All tenants connect to this instance using DIFFERENT logical
|
||||
# databases (?authSource if you enable auth).
|
||||
# ---------------------------------------------------------------------
|
||||
wekandb:
|
||||
image: mongo:7
|
||||
container_name: wekan-db
|
||||
restart: always
|
||||
network_mode: "host"
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
mongod --storageEngine wiredTiger --wiredTigerCacheSizeGB 1 --timeZoneInfo /usr/share/zoneinfo --oplogSize 2048 --replSet rs0 --bind_ip 127.0.0.1 --port 27017 --quiet &
|
||||
until mongosh --host 127.0.0.1 --port 27017 --quiet --eval 'try { const s = rs.status(); quit(s.ok === 1 ? 0 : 1); } catch (e) { if (e.codeName === "NotYetInitialized" || /no replset config has been received|not yet initialized/.test(e.message)) { rs.initiate({_id:"rs0", members:[{_id:0, host:"127.0.0.1:27017"}]}); quit(1); } quit(1); }' >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
wait
|
||||
# Report healthy only once the replica set has an elected primary, so the
|
||||
# tenant apps (depends_on: condition: service_healthy) do not start before
|
||||
# MongoDB is ready and crash with "Topology is closed". start_period gives a
|
||||
# large database time to replay its journal after an upgrade.
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--host", "127.0.0.1", "--port", "27017", "--quiet", "--eval", "try { quit(rs.status().ok === 1 && db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
volumes:
|
||||
- wekan-db:/data/db
|
||||
- wekan-db-dump:/dump
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Tenant 1
|
||||
#
|
||||
# Each tenant needs three things distinct from every other tenant:
|
||||
# 1. PORT — public HTTP port (here 8081)
|
||||
# 2. MONGO_URL database name — here `wekan_tenant1`
|
||||
# 3. uws.port in METEOR_SETTINGS — here 5001
|
||||
# ---------------------------------------------------------------------
|
||||
wekan-tenant1:
|
||||
image: ghcr.io/wekan/wekan:latest
|
||||
container_name: wekan-tenant1
|
||||
restart: always
|
||||
network_mode: "host"
|
||||
depends_on:
|
||||
wekandb:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PORT=8081
|
||||
- ROOT_URL=https://tenant1.example.com
|
||||
- MONGO_URL=mongodb://127.0.0.1:27017/wekan_tenant1?replicaSet=rs0
|
||||
- MONGO_OPLOG_URL=mongodb://127.0.0.1:27017/local?replicaSet=rs0
|
||||
- DDP_TRANSPORT=uws
|
||||
- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
# Internal uws proxy port — MUST be distinct per service.
|
||||
- METEOR_SETTINGS={"packages":{"ddp-server":{"uws":{"port":5001,"host":"127.0.0.1"}}}}
|
||||
- WRITABLE_PATH=/data
|
||||
- WITH_API=true
|
||||
volumes:
|
||||
- wekan-tenant1-data:/data
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Tenant 2 — same template, distinct PORT / database / uws.port
|
||||
# ---------------------------------------------------------------------
|
||||
wekan-tenant2:
|
||||
image: ghcr.io/wekan/wekan:latest
|
||||
container_name: wekan-tenant2
|
||||
restart: always
|
||||
network_mode: "host"
|
||||
depends_on:
|
||||
wekandb:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PORT=8082
|
||||
- ROOT_URL=https://tenant2.example.com
|
||||
- MONGO_URL=mongodb://127.0.0.1:27017/wekan_tenant2?replicaSet=rs0
|
||||
- MONGO_OPLOG_URL=mongodb://127.0.0.1:27017/local?replicaSet=rs0
|
||||
- DDP_TRANSPORT=uws
|
||||
- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
- METEOR_SETTINGS={"packages":{"ddp-server":{"uws":{"port":5002,"host":"127.0.0.1"}}}}
|
||||
- WRITABLE_PATH=/data
|
||||
- WITH_API=true
|
||||
volumes:
|
||||
- wekan-tenant2-data:/data
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Add more tenants by copying a block above and bumping:
|
||||
# PORT (e.g. 8083, 8084, …)
|
||||
# ROOT_URL (the tenant's public domain)
|
||||
# MONGO_URL (a new database name)
|
||||
# uws.port (5003, 5004, …)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
volumes:
|
||||
wekan-db:
|
||||
wekan-db-dump:
|
||||
wekan-tenant1-data:
|
||||
wekan-tenant2-data:
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Verifying the uws internal listen sockets are isolated.
|
||||
#
|
||||
# From the host (or any container sharing the netns):
|
||||
#
|
||||
# cat /proc/net/tcp | awk '$4 == "0A" {print $2}' | sort | uniq -c
|
||||
#
|
||||
# You want to see ONE listener per uws port — the hex addresses
|
||||
# `0100007F:1389` (5001) and `0100007F:138A` (5002) should each
|
||||
# appear with count 1, no duplicates. If you see `2 0100007F:1389`,
|
||||
# two services are trying to share the default port; reconfigure
|
||||
# their METEOR_SETTINGS.uws.port to distinct values and restart.
|
||||
# ---------------------------------------------------------------------
|
||||
+46
-5
@@ -258,6 +258,17 @@ services:
|
||||
- wekan-tier
|
||||
expose:
|
||||
- 27017
|
||||
# Healthcheck: report healthy only once the replica set has an elected
|
||||
# primary, so the wekan app (depends_on: condition: service_healthy) does
|
||||
# not start before MongoDB is ready and crash with "Topology is closed".
|
||||
# start_period gives a large database time to replay its journal after an
|
||||
# upgrade before failures are counted.
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--host", "127.0.0.1", "--port", "27017", "--quiet", "--eval", "try { quit(rs.status().ok === 1 && db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- wekan-db:/data/db
|
||||
@@ -315,9 +326,9 @@ services:
|
||||
# https://github.com/meteor/meteor/blob/release-3.5/v3-docs/docs/performance/change-streams-observer-driver.md#choosing-the-reactivity-driver-order
|
||||
# Use oplog,polling to fix performance until changeStreams is fixed at next release of Meteor 3.5 Beta:
|
||||
# https://github.com/wekan/wekan/issues/6307#issuecomment-4299349231
|
||||
- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
- DDP_TRANSPORT=sockjs
|
||||
#- DDP_TRANSPORT=uws
|
||||
- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
- DDP_TRANSPORT=uws
|
||||
#- DDP_TRANSPORT=sockjs
|
||||
#-----------------------------------------------------------------
|
||||
# ==== WRITEABLE PATH FOR FILE UPLOADS ====
|
||||
- WRITABLE_PATH=/data
|
||||
@@ -558,6 +569,13 @@ services:
|
||||
#- OAUTH2_ADFS_ENABLED=false
|
||||
# Azure AD B2C. https://github.com/wekan/wekan/issues/5242
|
||||
#- OAUTH2_B2C_ENABLED=false
|
||||
# SECURITY (GHSA-mp7g-hj5q-gxhq): Link an OIDC login to a pre-existing
|
||||
# Wekan account (password/LDAP) with the same email. OFF by default; when
|
||||
# false an OIDC login whose email already exists is rejected instead of
|
||||
# merged, preventing account takeover via spoofed email claims. Only
|
||||
# enable if you fully trust your OIDC provider's email claims, and even
|
||||
# then the provider must send email_verified=true for the merge to happen.
|
||||
#- OAUTH2_MERGE_EXISTING_USERS=false
|
||||
# OAuth2 login style: popup or redirect.
|
||||
#- OAUTH2_LOGIN_STYLE=redirect
|
||||
# Application GUID captured during app registration:
|
||||
@@ -786,7 +804,15 @@ services:
|
||||
# The limit number of entries (0=unlimited)
|
||||
#- LDAP_SEARCH_SIZE_LIMIT=0
|
||||
#
|
||||
# Enable group filtering. Note the authenticated ldap user must be able to query all relevant group data with own login data from ldap.
|
||||
# Enable the login restriction group filter. Note the authenticated ldap user must be able to query all relevant group data with own login data from ldap.
|
||||
# When true, only members of LDAP_GROUP_FILTER_GROUP_NAME are allowed to log in.
|
||||
# NOTE: This flag ONLY controls the login restriction. Admin status sync
|
||||
# (LDAP_SYNC_ADMIN_STATUS / LDAP_SYNC_ADMIN_GROUPS) and group->role sync
|
||||
# (LDAP_SYNC_GROUP_ROLES) query LDAP groups independently and do NOT require
|
||||
# this flag to be true. The group filter metadata below
|
||||
# (LDAP_GROUP_FILTER_OBJECTCLASS, LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE,
|
||||
# LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT, LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE)
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
@@ -840,6 +866,12 @@ services:
|
||||
# with the LDAP_SYNC_USER_DATA_FIELDMAP is defined in setting LDAP_DEFAULT_DOMAIN above.
|
||||
#
|
||||
# Enable/Disable syncing of admin status based on ldap groups:
|
||||
# NOTE: Admin status sync and group->role sync (LDAP_SYNC_GROUP_ROLES) query
|
||||
# LDAP groups on their own. They no longer require LDAP_GROUP_FILTER_ENABLE=true,
|
||||
# which only controls the login restriction filter. The group filter metadata
|
||||
# (LDAP_GROUP_FILTER_OBJECTCLASS, LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE,
|
||||
# LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT, LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE)
|
||||
# must still be configured for the group search to work.
|
||||
#- LDAP_SYNC_ADMIN_STATUS=true
|
||||
#
|
||||
# Comma separated list of admin group names to sync.
|
||||
@@ -851,6 +883,9 @@ services:
|
||||
#- HEADER_LOGIN_FIRSTNAME=HEADERFIRSTNAME
|
||||
#- HEADER_LOGIN_LASTNAME=HEADERLASTNAME
|
||||
#- HEADER_LOGIN_EMAIL=HEADEREMAILADDRESS
|
||||
# Optional comma-separated trusted proxy source IPs for header login.
|
||||
# If empty, all source IPs are accepted (not recommended).
|
||||
#- HEADER_LOGIN_TRUSTED_IPS=127.0.0.1,10.0.0.2
|
||||
#---------------------------------------------------------------------
|
||||
# ==== LOGOUT TIMER, probably does not work yet ====
|
||||
# LOGOUT_WITH_TIMER : Enables or not the option logout with timer
|
||||
@@ -892,7 +927,13 @@ services:
|
||||
# Wait spinner to use
|
||||
# - WAIT_SPINNER=Bounce
|
||||
#---------------------------------------------------------------------
|
||||
#depends_on:
|
||||
# Wait for MongoDB to be healthy (replica-set primary elected) before
|
||||
# starting WeKan, so index creation does not crash with "Topology is closed".
|
||||
# If you use FerretDB instead, comment out the wekandb block below and
|
||||
# uncomment the wekan-ferretdb block.
|
||||
depends_on:
|
||||
wekandb:
|
||||
condition: service_healthy
|
||||
# wekan-migrate:
|
||||
# condition: service_completed_successfully
|
||||
# wekan-ferretdb:
|
||||
|
||||
+9
-1
@@ -378,7 +378,15 @@ services:
|
||||
# LDAP_SEARCH_SIZE_LIMIT : The limit number of entries (0=unlimited)
|
||||
# example : LDAP_SEARCH_SIZE_LIMIT=12345
|
||||
- LDAP_SEARCH_SIZE_LIMIT=0
|
||||
# LDAP_GROUP_FILTER_ENABLE : Enable group filtering
|
||||
# LDAP_GROUP_FILTER_ENABLE : Enable the login restriction group filter.
|
||||
# When true, only members of LDAP_GROUP_FILTER_GROUP_NAME are allowed to log in.
|
||||
# NOTE: This flag ONLY controls the login restriction. Admin status sync
|
||||
# (LDAP_SYNC_ADMIN_STATUS / LDAP_SYNC_ADMIN_GROUPS) and group->role sync
|
||||
# (LDAP_SYNC_GROUP_ROLES) query LDAP groups independently and do NOT require
|
||||
# this flag to be true. The group filter metadata below
|
||||
# (LDAP_GROUP_FILTER_OBJECTCLASS, LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE,
|
||||
# LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT, LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE)
|
||||
# must still be configured for any group search to work.
|
||||
# example : LDAP_GROUP_FILTER_ENABLE=true
|
||||
- LDAP_GROUP_FILTER_ENABLE=false
|
||||
# LDAP_GROUP_FILTER_OBJECTCLASS : The object class for filtering
|
||||
|
||||
@@ -31,14 +31,13 @@
|
||||
# - WeKan hosting CloudFlare DNS: customer.wekan.team A Server-IPv4-Address
|
||||
|
||||
wekan.customer.com {
|
||||
# b) No custom domain, using customer.wekan.team:
|
||||
# - Using CloudFlare TLS Origin Certificate with Strict Enforcement
|
||||
# - At CloudFlare DNS change to orange cloud, with CloudFlare proxy,
|
||||
# - https://github.com/wekan/wekan/blob/main/docs/Webserver/Caddy.md
|
||||
# - Add this tls section to this example:
|
||||
# tls {
|
||||
# load /etc/caddy/certs
|
||||
# }
|
||||
# b) No custom domain, using customer.wekan.team:
|
||||
# - Using CloudFlare TLS Origin Certificate with Strict Enforcement
|
||||
# - At CloudFlare DNS change to orange cloud, with CloudFlare proxy,
|
||||
# - https://github.com/wekan/wekan/blob/main/docs/Webserver/Caddy.md
|
||||
# tls {
|
||||
# load /etc/caddy/certs
|
||||
# }
|
||||
header {
|
||||
-Content-Security-Policy-Report-Only
|
||||
-Content-Security-Policy
|
||||
@@ -54,7 +53,7 @@ wekan.customer.com {
|
||||
header Access-Control-Allow-Origin *
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3039
|
||||
reverse_proxy 127.0.0.1:3001
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +64,16 @@ wekan.customer.com {
|
||||
# - Difference is additional tls section: tls { load /etc/caddy/certs }
|
||||
|
||||
customer.wekan.team {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
header {
|
||||
-Content-Security-Policy-Report-Only
|
||||
-Content-Security-Policy
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://static.cloudflareinsights.com https://accounts.google.com https://apis.google.com; worker-src 'self' blob:; connect-src 'self' wss://customer.wekan.team https://customer.wekan.team https://cloudflareinsights.com https://accounts.google.com; img-src 'self' data: https://*.googleusercontent.com; frame-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:;"
|
||||
Cross-Origin-Opener-Policy "same-origin-allow-popups"
|
||||
X-Content-Type-Options nosniff
|
||||
Strict-Transport-Security "max-age=31536000;"
|
||||
Strict-Transport-Security "max-age=31536000;"
|
||||
-X-Frame-Options
|
||||
Cache-Control "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
@@ -82,9 +81,10 @@ customer.wekan.team {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3040
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3002
|
||||
}
|
||||
}
|
||||
|
||||
# 3) Static website
|
||||
@@ -247,6 +247,7 @@ boards.wekan.team {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
|
||||
header {
|
||||
-Content-Security-Policy-Report-Only
|
||||
-Content-Security-Policy
|
||||
@@ -433,8 +434,8 @@ boards.wekan.team {
|
||||
"prefer_related_applications": false
|
||||
}`
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3025
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,96 @@
|
||||
# Multitenancy
|
||||
|
||||
TLDR a) and b)
|
||||
Run several WeKan instances (one per customer / domain) on the same
|
||||
Linux host. Two supported topologies, pick whichever fits your
|
||||
deployment.
|
||||
|
||||
## a) Oplog sockjs
|
||||
## a) Recommended — one host, many WeKan domains, `uws` + `changeStreams`
|
||||
|
||||
- One server/Many WeKan domains/One IPv4
|
||||
- Multitenancy works
|
||||
Highest DDP throughput and lowest reactive latency.
|
||||
|
||||
docker-compose.yml:
|
||||
Configure each instance with:
|
||||
|
||||
- `DDP_TRANSPORT=uws`
|
||||
- `METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling`
|
||||
- A **distinct** `Meteor.settings.packages['ddp-server'].uws.port`
|
||||
per instance (`5001`, `5002`, `5003`, … in the example below)
|
||||
- `network_mode: "host"` (or any setup that shares one kernel
|
||||
network namespace)
|
||||
- The same MongoDB replica set (separate logical databases per
|
||||
tenant)
|
||||
|
||||
Why the distinct `uws.port`: the `uws` DDP transport runs its own
|
||||
internal WebSocket proxy server on a dedicated TCP port (default
|
||||
`127.0.0.1:5001`) and forwards public WebSocket upgrades into it.
|
||||
The internal listen socket is opened exclusively, so each instance
|
||||
in the same kernel netns must declare its own internal port.
|
||||
|
||||
Working example: see [`docker-compose-multitenancy.yml`](../../../../../docker-compose-multitenancy.yml)
|
||||
in the repository root.
|
||||
|
||||
Sketch of the per-tenant environment block:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PORT=8081 # public HTTP port
|
||||
- ROOT_URL=https://tenant1.example.com
|
||||
- MONGO_URL=mongodb://127.0.0.1:27017/wekan_tenant1?replicaSet=rs0
|
||||
- DDP_TRANSPORT=uws
|
||||
- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
- METEOR_SETTINGS={"packages":{"ddp-server":{"uws":{"port":5001,"host":"127.0.0.1"}}}}
|
||||
```
|
||||
|
||||
For tenant 2, bump `PORT`, `ROOT_URL`, the MongoDB database name, and
|
||||
the `uws.port` (e.g. `5002`). The reverse proxy in front of WeKan
|
||||
keeps routing customer domains to each tenant's public `PORT`
|
||||
exactly as before — the internal `uws.port` is never exposed.
|
||||
|
||||
### Verifying the configuration
|
||||
|
||||
From the host (or from inside any container sharing the netns):
|
||||
|
||||
```sh
|
||||
cat /proc/net/tcp | awk '$4 == "0A" {print $2}' | sort | uniq -c
|
||||
```
|
||||
|
||||
Each `uws.port` should appear exactly once. `0x1389` is `5001`,
|
||||
`0x138A` is `5002`, etc. If you see `2 0100007F:1389`, two tenants
|
||||
are trying to share the default port; reconfigure their
|
||||
`METEOR_SETTINGS.uws.port` to distinct values and restart.
|
||||
|
||||
### If you forget to set a distinct `uws.port`
|
||||
|
||||
The second tenant to start refuses the bind and Meteor throws at
|
||||
startup with an actionable error:
|
||||
|
||||
```text
|
||||
Error: uWebSockets.js: failed to listen on 127.0.0.1:5001 (address already in use).
|
||||
Another Meteor instance in this network namespace is already bound to this port.
|
||||
Set a distinct Meteor.settings.packages["ddp-server"].uws.port (or .host) for each instance.
|
||||
```
|
||||
|
||||
Add a `METEOR_SETTINGS` line with a free port to the failing
|
||||
service, restart.
|
||||
|
||||
## b) Alternative — `sockjs` + `oplog` (maximum proxy compatibility)
|
||||
|
||||
Use this if your reverse proxy / load balancer cannot guarantee
|
||||
WebSocket upgrade pass-through, or if some clients sit on networks
|
||||
that block raw WebSocket. SockJS will fall back to HTTP long-polling
|
||||
on those clients.
|
||||
|
||||
```yaml
|
||||
- DDP_TRANSPORT=sockjs
|
||||
- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
- DDP_TRANSPORT=uws
|
||||
```
|
||||
|
||||
## b) changeStreams uws
|
||||
No per-tenant `uws.port` configuration is needed because `sockjs`
|
||||
does not run a separate internal listen socket. The trade-off is
|
||||
lower DDP throughput compared to `uws`.
|
||||
|
||||
- One server/One WeKan domain/One IPv4
|
||||
## Reference
|
||||
|
||||
Multitenancy does not work, causes bug: round robin changing
|
||||
WeKan website at every webpage reload at same address.
|
||||
|
||||
For example:
|
||||
|
||||
1) At https://boards.wekan.team shows https://wekan.customer2.com
|
||||
2) Reload webpage. https://boards.wekan.team shows https://wekan.customer3.com
|
||||
3) Reload webpage. https://boards.wekan.team shows https://wekan.customer4.com
|
||||
- 4) Reload webpage. https://boards.wekan.team shows https://customer4.wekan.team
|
||||
- etc.
|
||||
|
||||
## Details
|
||||
|
||||
When multiple Meteor 3 applications are running on the same server using
|
||||
`network_mode: "host"` and are shared across the same MongoDB server,
|
||||
the combination `DDP_TRANSPORT=uws` and `METEOR_REACTIVITY_ORDER=changeStreams`
|
||||
is subject to a very rare but critical **Context/Descriptor Confusion**.
|
||||
|
||||
Here is a detailed technical explanation of what is happening behind the scenes and why it breaks a multi-tenant environment:
|
||||
|
||||
### 1. µWebSockets (`uws`) and shared host networking
|
||||
|
||||
`uws` is an extremely high-performance WebSocket engine written in C++.
|
||||
It bypasses many of the traditional networking layers of Node.js and
|
||||
handles network sockets and their file handles (File Descriptors)
|
||||
directly at a low level in its own Event Loop.
|
||||
|
||||
When containers are set to `network_mode: "host"`, they are not
|
||||
network-level isolated. They share the same Linux kernel
|
||||
network stack and memory space for network traffic.
|
||||
When your browser does a WebSocket handshake (`Upgrade: websocket`)
|
||||
after an HTTP request, `uws` allocates a memory address for the socket.
|
||||
|
||||
The bug occurs when multiple containers do this at the same time
|
||||
in the same host network environment,
|
||||
**the low-level C++ code in the `uws` engine can cross-identify socket identifiers from different processes**
|
||||
if the framework (in this case, an early beta integration of Meteor 3)
|
||||
is unable to keep connection ownership (v8 isolation) completely separate.
|
||||
|
||||
### 2. `changeStreams` and data misrouting
|
||||
|
||||
MongoDB's `changeStreams` relies on the application opening a long-lived,
|
||||
real-time listening pipe to the database. It is heavier and more complex
|
||||
than the old `oplog` table because it creates constantly changing,
|
||||
stateful cursors between the application and the database.
|
||||
|
||||
When this is combined with `uws` WebSocket:
|
||||
|
||||
1. User A opens the `boards.wekan.team` page.
|
||||
|
||||
2. The browser requests a WebSocket connection, and Meteor creates a
|
||||
`changeStream` listener on the database for User A's container.
|
||||
|
||||
3. At the same time, User B loads the `kanban.buetzow.de` page.
|
||||
|
||||
4. Since both containers talk locally to the same MongoDB process
|
||||
and share the same host interface, the application layer's
|
||||
reactivity engine (multiplexer) confuses which `changeStream`
|
||||
data input belongs to which active `uws` socket address.
|
||||
|
||||
**Result:** A reactive data packet from the database (e.g. different customer 1 boards)
|
||||
is pushed into the `uws` pipe that was locally open to the `boards.wekan.team` browser.
|
||||
The browser gets a perfectly valid HTML body, but the Javascript layer draws the data
|
||||
it received from the WebSocket.
|
||||
|
||||
### Why did good old `sockjs` and `oplog` fix the situation?
|
||||
|
||||
* **`sockjs`** is written in pure JavaScript (Node.js' native network stack).
|
||||
It strictly follows Node.js' own process boundaries, and does not perform
|
||||
direct C++-level optimizations beyond the kernel. Even though containers
|
||||
share the host network, Node.js makes sure that each process's sockets
|
||||
remain strictly within its own application window.
|
||||
|
||||
* **`oplog`** is a more passive way to read the database. The application
|
||||
reads the raw log file (`local.oplog.rs`) and filters its own data from
|
||||
there, instead of MongoDB's active `changeStream` engine trying to route
|
||||
and load balance cursors between different processes on the fly.
|
||||
|
||||
Simply put: `uws` and `changeStreams` are great tools when you are running
|
||||
**only one large application** on a single server for which you want
|
||||
maximum performance. In a multi-tenant environment
|
||||
(many different clients on the same machine), they are too aggressive
|
||||
and break the invisible boundaries between containers.
|
||||
- Meteor 3.5 DDP transport docs, including the multi-tenant section:
|
||||
<https://docs.meteor.com/performance/ddp-transport#multitenancy>
|
||||
- Meteor changeStreams reactivity driver:
|
||||
<https://docs.meteor.com/performance/change-streams-observer-driver>
|
||||
@@ -17,7 +17,7 @@ for d in */ ; do
|
||||
docker compose up -d --force-recreate
|
||||
#docker compose stop
|
||||
#docker compose start
|
||||
#docker rm "${FOLDER_NAME}-app"
|
||||
#docker rm "${FOLDER_NAME}-tenant"
|
||||
#docker compose up -d
|
||||
|
||||
# Return to the parent directory
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
# ========== CUSTOM END =====
|
||||
## See ../multitenancy.md about commented out settings:
|
||||
- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
- DDP_TRANSPORT=uws
|
||||
#- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
#- DDP_TRANSPORT=sockjs
|
||||
- WRITABLE_PATH=/data
|
||||
- WITH_API=true
|
||||
- RICHER_CARD_COMMENT_EDITOR=false
|
||||
- CARD_OPENED_WEBHOOK_ENABLED=false
|
||||
- BIGEVENTS_PATTERN=NONE
|
||||
- NODE_FLAGS=--max-old-space-size=16384
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
- MONGO_URL=mongodb://PARAMETER_user:PASSWORD@localhost:27017/els?authSource=els
|
||||
- MONGO_OPLOG_URL=mongodb://PARAMETER_user:PASSWORD@localhost:27017/local?replicaSet=rs0&authSource=els
|
||||
- PORT=3001
|
||||
- ROOT_URL=https://wekan.customer.com
|
||||
- MONGO_URL=mongodb://PARAMETER_user:PASSWORD@localhost:27017/PARAMETER?replicaSet=rs0&appName=PARAMETER-tenant&authSource=PARAMETER
|
||||
- MONGO_OPLOG_URL=mongodb://PARAMETER_user:PASSWORD@localhost:27017/local?replicaSet=rs0&appName=PARAMETER-tenant-oplog&authSource=PARAMETER
|
||||
# Distinct uws internal port per tenant — required since the
|
||||
# 3.5-beta.12 fix (PR #14425) binds with LIBUS_LISTEN_EXCLUSIVE_PORT.
|
||||
- METEOR_SETTINGS={"packages":{"ddp-server":{"uws":{"port":5001,"host":"127.0.0.1"}}}}
|
||||
- MAIL_FROM=Customer WeKan Boards <boards@wekan.team>
|
||||
- MAIL_URL=smtp://AKIA-USERNAME:PASSWORD@email-smtp.eu-west-1.amazonaws.com:587?tls={ciphers:"SSLv3"}&secureConnection=false
|
||||
- PORT=3032
|
||||
- ROOT_URL=https://wekan.customer.com
|
||||
@@ -2,19 +2,8 @@ services:
|
||||
|
||||
PARAMETER:
|
||||
image: ghcr.io/wekan/wekan:v9.20
|
||||
container_name: PARAMETER-app
|
||||
restart: always
|
||||
container_name: PARAMETER-tenant
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
environment:
|
||||
## See ../multitenancy.md about commented out settings:
|
||||
#- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
#- DDP_TRANSPORT=uws
|
||||
- DDP_TRANSPORT=sockjs
|
||||
- WRITABLE_PATH=/data
|
||||
- WITH_API=true
|
||||
- RICHER_CARD_COMMENT_EDITOR=false
|
||||
- CARD_OPENED_WEBHOOK_ENABLED=false
|
||||
- BIGEVENTS_PATTERN=NONE
|
||||
- NODE_FLAGS=--max-old-space-size=16384
|
||||
# ========== CUSTOM START =====
|
||||
@@ -14,7 +14,7 @@ for d in restore/*/ ; do
|
||||
|
||||
# Run the Docker commands using the folder name
|
||||
docker compose down
|
||||
docker rm "${FOLDER_NAME}-app"
|
||||
docker rm "${FOLDER_NAME}-tenant"
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
# Return to the parent directory
|
||||
|
||||
@@ -25,7 +25,7 @@ for d in restore/*/ ; do
|
||||
|
||||
# Run the Docker commands using the clean folder name
|
||||
docker compose down
|
||||
docker rm "${FOLDER_NAME}-app" 2>/dev/null # 2>/dev/null ignores errors if it's already gone
|
||||
docker rm "${FOLDER_NAME}-tenant" 2>/dev/null # 2>/dev/null ignores errors if it's already gone
|
||||
docker compose up -d --force-recreate
|
||||
)
|
||||
done
|
||||
|
||||
@@ -10,22 +10,23 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-9.23-win64.zip](https://github.com/wekan/wekan/releases/download/v9.23/wekan-9.23-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-9.42-win64.zip](https://github.com/wekan/wekan/releases/download/v9.42/wekan-9.42-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
|
||||
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
|
||||
|
||||
3. [mongodb-windows-x86_64-7.0.32-signed.msi](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-7.0.32-signed.msi)
|
||||
3. [mongodb-windows-x86_64-7.0.34-signed.msi](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-7.0.34-signed.msi)
|
||||
|
||||
4. [start-wekan.bat](https://raw.githubusercontent.com/wekan/wekan/main/start-wekan.bat)
|
||||
4. (already exists: [start-wekan.bat](https://raw.githubusercontent.com/wekan/wekan/main/start-wekan.bat))
|
||||
|
||||
5. Copy files from steps 1-4 with USB stick or DVD to offline Windows computer
|
||||
5. Copy files from steps 1-3 with USB stick or DVD to offline Windows computer
|
||||
|
||||
6. Double click `mongodb-windows-x86_64-7.0.32-signed.msi` . In installer, uncheck downloading MongoDB compass.
|
||||
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
|
||||
|
||||
7. Unzip `wekan-9.23-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-9.42-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|_ start-wekan.bat (downloaded file)
|
||||
|_ start-wekan.bat (already exists)
|
||||
|_ node.exe (downloaded file)
|
||||
|_ main.js (extracted file)
|
||||
```
|
||||
|
||||
@@ -161,6 +161,83 @@ npx playwright test --project=chromium
|
||||
|
||||
This runs headless browser tests inside Flatpak sandboxed VS Code terminal (no external/native browser window needed).
|
||||
|
||||
### Run Firefox/WebKit too
|
||||
|
||||
By default this repo runs only the `chromium` Playwright project.
|
||||
To enable `firefox` and `webkit` projects, set:
|
||||
|
||||
```bash
|
||||
export WEKAN_PLAYWRIGHT_ALL=1
|
||||
```
|
||||
|
||||
Then run for example:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan/tests/playwright
|
||||
export HOME="/home/wekan/repos/wekan/.tools"
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
export PLAYWRIGHT_BROWSERS_PATH="/home/wekan/.var/app/com.visualstudio.code/cache/ms-playwright"
|
||||
export WEKAN_PLAYWRIGHT_ALL=1
|
||||
|
||||
/home/wekan/repos/wekan/.tools/.meteor/meteor npm exec playwright test -- --project=firefox
|
||||
/home/wekan/repos/wekan/.tools/.meteor/meteor npm exec playwright test -- --project=webkit
|
||||
```
|
||||
|
||||
Without `WEKAN_PLAYWRIGHT_ALL=1`, Playwright config exposes only `chromium`, and `--project=firefox`/`--project=webkit` will fail with "Project not found".
|
||||
|
||||
### If `playwright install-deps` fails because of unrelated apt repo keys
|
||||
|
||||
On some hosts, this command can fail even when browser deps are already installed:
|
||||
|
||||
```bash
|
||||
sudo npx playwright install-deps
|
||||
```
|
||||
|
||||
Example failure:
|
||||
|
||||
- `NO_PUBKEY ...`
|
||||
- `The repository ... is not signed`
|
||||
|
||||
This is usually caused by a third-party apt source (PPA/repo), not by Playwright itself.
|
||||
|
||||
Use either of these approaches:
|
||||
|
||||
1. Install required packages directly (recommended fallback):
|
||||
|
||||
```bash
|
||||
sudo apt install -y \
|
||||
libgtk-4-1 \
|
||||
libxml2 \
|
||||
libevent-2.1-7t64 \
|
||||
libflite1 \
|
||||
libjpeg-turbo8 \
|
||||
libmanette-0.2-0 \
|
||||
libenchant-2-2 \
|
||||
libwoff1
|
||||
```
|
||||
|
||||
2. Or temporarily disable the broken third-party apt source, run `install-deps`, then re-enable it.
|
||||
|
||||
In this sandbox workflow, manual apt package install is enough and avoids failures caused by unrelated repositories.
|
||||
|
||||
### Verified WebKit status in this setup
|
||||
|
||||
After applying the sandbox env settings from this document and running with:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan/tests/playwright
|
||||
export HOME="/home/wekan/repos/wekan/.tools"
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
export PLAYWRIGHT_BROWSERS_PATH="/home/wekan/.var/app/com.visualstudio.code/cache/ms-playwright"
|
||||
export WEKAN_PLAYWRIGHT_ALL=1
|
||||
|
||||
/home/wekan/repos/wekan/.tools/.meteor/meteor npm exec playwright test -- --project=webkit
|
||||
```
|
||||
|
||||
validated result:
|
||||
|
||||
- `105 passed` (WebKit project)
|
||||
|
||||
## 10) Optional smoke test for headless Chromium in sandbox
|
||||
|
||||
```bash
|
||||
@@ -172,3 +249,153 @@ node -e "(async()=>{const {chromium}=require('playwright');const b=await chromiu
|
||||
```
|
||||
|
||||
If output is `PW_OK`, headless Chromium launch is working inside sandbox.
|
||||
|
||||
## 11) Additional findings from real test runs (important)
|
||||
|
||||
These were verified while running the full Playwright suite in this exact sandbox setup.
|
||||
|
||||
### A) Wekan app startup for tests requires `WRITABLE_PATH`
|
||||
|
||||
If `WRITABLE_PATH` is missing, app startup can fail with:
|
||||
|
||||
`WRITABLE_PATH environment variable missing and/or unset`
|
||||
|
||||
Use a local writable test directory before starting app:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
export WRITABLE_PATH="$PWD/.test-writable"
|
||||
mkdir -p "$WRITABLE_PATH/files/attachments" "$WRITABLE_PATH/files/avatars"
|
||||
```
|
||||
|
||||
Then run app:
|
||||
|
||||
```bash
|
||||
/home/wekan/.meteor/meteor --port 3000
|
||||
```
|
||||
|
||||
### B) Playwright seed helpers require `mongosh`
|
||||
|
||||
Playwright fixtures seed test data via `tests/playwright/helpers/db.js`, which calls `mongosh`.
|
||||
If missing, tests fail early with:
|
||||
|
||||
`Error: spawnSync mongosh ENOENT`
|
||||
|
||||
Install `mongosh` as a dependency in Playwright test project:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
/home/wekan/.meteor/meteor npm --prefix tests/playwright install mongosh
|
||||
```
|
||||
|
||||
The helper now also searches `tests/playwright/node_modules/.bin` so this works in sandbox without system-wide mongosh.
|
||||
|
||||
### C) If repo-local `.tools/node...` does not exist
|
||||
|
||||
In some sandboxes the `.tools` Node path is missing. You can still run commands via Meteor wrapper:
|
||||
|
||||
```bash
|
||||
/home/wekan/.meteor/meteor npm -v
|
||||
/home/wekan/.meteor/meteor npm exec playwright --version
|
||||
```
|
||||
|
||||
### D) Proven working execution order
|
||||
|
||||
Terminal 1 (start app):
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
export WRITABLE_PATH="$PWD/.test-writable"
|
||||
mkdir -p "$WRITABLE_PATH/files/attachments" "$WRITABLE_PATH/files/avatars"
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
/home/wekan/.meteor/meteor --port 3000
|
||||
```
|
||||
|
||||
Terminal 2 (run tests):
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan/tests/playwright
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
export PLAYWRIGHT_BROWSERS_PATH="/home/wekan/.var/app/com.visualstudio.code/cache/ms-playwright"
|
||||
/home/wekan/.meteor/meteor npm exec playwright test -- --project=chromium
|
||||
```
|
||||
|
||||
Expected result from this validated setup:
|
||||
|
||||
- `105 passed` (Chromium project)
|
||||
|
||||
## 12) About `meteor test` in this sandbox
|
||||
|
||||
`meteor test --driver-package meteortesting:mocha` can start successfully but still report `0 passing` in this environment due to test discovery/driver behavior.
|
||||
|
||||
For reliable headless validation inside this sandbox, prefer Playwright run described above.
|
||||
|
||||
If you get `EROFS` errors writing to `~/.meteor/packages` (read-only home mount), run Meteor from the repo-local copy and point `HOME` to `.tools`:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
export HOME="$PWD/.tools"
|
||||
unset METEOR_WAREHOUSE_DIR
|
||||
export WRITABLE_PATH="$PWD/.test-writable"
|
||||
mkdir -p "$WRITABLE_PATH/files/attachments" "$WRITABLE_PATH/files/avatars"
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
|
||||
"$PWD/.tools/.meteor/meteor" test --once --driver-package meteortesting:mocha --full-app --test server/lib/tests/attachmentApi.authContext.tests.js
|
||||
```
|
||||
|
||||
## 13) Quick copy-paste: prepare + run Chromium tests
|
||||
|
||||
Use this when you want one command block that prepares required paths/dependencies and runs Playwright Chromium tests.
|
||||
|
||||
Note: this assumes Wekan app is already running at `http://localhost:3000` in another terminal.
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
|
||||
# Ensure test writable directories exist
|
||||
export WRITABLE_PATH="$PWD/.test-writable"
|
||||
mkdir -p "$WRITABLE_PATH/files/attachments" "$WRITABLE_PATH/files/avatars"
|
||||
|
||||
# Sandbox-safe browser env
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
export PLAYWRIGHT_BROWSERS_PATH="/home/wekan/.var/app/com.visualstudio.code/cache/ms-playwright"
|
||||
|
||||
# Ensure Playwright-side mongosh exists for DB seeding
|
||||
/home/wekan/.meteor/meteor npm --prefix tests/playwright install mongosh
|
||||
|
||||
# Run Chromium tests
|
||||
cd tests/playwright
|
||||
/home/wekan/.meteor/meteor npm exec playwright test -- --project=chromium
|
||||
```
|
||||
|
||||
If you also need to start app in a separate terminal:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
export WRITABLE_PATH="$PWD/.test-writable"
|
||||
mkdir -p "$WRITABLE_PATH/files/attachments" "$WRITABLE_PATH/files/avatars"
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
/home/wekan/.meteor/meteor --port 3000
|
||||
```
|
||||
|
||||
## 14) Minimal copy-paste only (no explanations)
|
||||
|
||||
Terminal 1:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
export WRITABLE_PATH="$PWD/.test-writable"
|
||||
mkdir -p "$WRITABLE_PATH/files/attachments" "$WRITABLE_PATH/files/avatars"
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
/home/wekan/.meteor/meteor --port 3000
|
||||
```
|
||||
|
||||
Terminal 2:
|
||||
|
||||
```bash
|
||||
cd /home/wekan/repos/wekan
|
||||
unset CHROME_DEVEL_SANDBOX
|
||||
export PLAYWRIGHT_BROWSERS_PATH="/home/wekan/.var/app/com.visualstudio.code/cache/ms-playwright"
|
||||
/home/wekan/.meteor/meteor npm --prefix tests/playwright install mongosh
|
||||
/home/wekan/.meteor/meteor npm --prefix tests/playwright exec playwright test -- --project=chromium
|
||||
```
|
||||
+1301
-1107
File diff suppressed because it is too large.
Load diff
+1866
-1672
File diff suppressed because it is too large.
Load diff
+1844
-1650
File diff suppressed because it is too large.
Load diff
+1759
-1565
File diff suppressed because it is too large.
Load diff
+1242
-1048
File diff suppressed because it is too large.
Load diff
+681
-487
File diff suppressed because it is too large.
Load diff
+1242
-1048
File diff suppressed because it is too large.
Load diff
+1528
-1334
File diff suppressed because it is too large.
Load diff
+1873
-1679
File diff suppressed because it is too large.
Load diff
+1873
-1679
File diff suppressed because it is too large.
Load diff
+1873
-1679
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1142
-948
File diff suppressed because it is too large.
Load diff
+1683
-1489
File diff suppressed because it is too large.
Load diff
Loaded 100 of 466 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user