mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 21:44:59 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e49fe2e5df | ||
|
|
56c18c66ba | ||
|
|
3e8cb8c6e1 | ||
|
|
e6365bbeeb | ||
|
|
8069f7072c | ||
|
|
8477e94f3b | ||
|
|
94611c4f38 | ||
|
|
0e6035f846 | ||
|
|
a8faeb2e5f | ||
|
|
118c9613dc | ||
|
|
b42ecb7948 | ||
|
|
e0398f01c8 | ||
|
|
b260d05a8b | ||
|
|
bbc68309af | ||
|
|
48ebc5f117 | ||
|
|
c04292e988 | ||
|
|
2203d46493 | ||
|
|
dae8033d61 | ||
|
|
b5c566615b | ||
|
|
d792033ffa | ||
|
|
63fa75819c | ||
|
|
f25e54a9f1 | ||
|
|
621efd59db | ||
|
|
5e36c188f9 |
No files matched your search
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-stage-3"
|
||||
],
|
||||
"env": {
|
||||
"COVERAGE": {
|
||||
"plugins": [
|
||||
"istanbul"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# Project-scoped Codex configuration.
|
||||
#
|
||||
# Claude has no non-default tool permissions or MCP servers configured for this
|
||||
# repository, so there are no settings to translate here. Project instructions
|
||||
# live in AGENTS.md and the nested AGENTS.md files.
|
||||
@@ -1,293 +0,0 @@
|
||||
FROM ubuntu:24.04
|
||||
LABEL maintainer="wekan"
|
||||
LABEL org.opencontainers.image.ref.name="ubuntu"
|
||||
LABEL org.opencontainers.image.version="24.04"
|
||||
LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
|
||||
# 2022-04-25:
|
||||
# - gyp does not yet work with Ubuntu 22.04 ubuntu:rolling,
|
||||
# so changing to 21.10. https://github.com/wekan/wekan/issues/4488
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg gosu wget bzip2 g++ iproute2 apt-transport-https libarchive-tools"
|
||||
ENV DEV_DEPS="curl python3 ca-certificates build-essential git"
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v14.21.4 \
|
||||
METEOR_RELEASE=METEOR@2.14 \
|
||||
USE_EDGE=false \
|
||||
METEOR_EDGE=1.5-beta.17 \
|
||||
NPM_VERSION=6.14.17 \
|
||||
FIBERS_VERSION=4.0.1 \
|
||||
ARCHITECTURE=linux-x64 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
RESULTS_PER_PAGE="" \
|
||||
DEFAULT_BOARD_ID="" \
|
||||
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE=3 \
|
||||
ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD=60 \
|
||||
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW=15 \
|
||||
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE=3 \
|
||||
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD=60 \
|
||||
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW=15 \
|
||||
ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90 \
|
||||
ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM="" \
|
||||
ATTACHMENTS_UPLOAD_MIME_TYPES="" \
|
||||
ATTACHMENTS_UPLOAD_MAX_SIZE=0 \
|
||||
AVATARS_UPLOAD_EXTERNAL_PROGRAM="" \
|
||||
AVATARS_UPLOAD_MIME_TYPES="" \
|
||||
AVATARS_UPLOAD_MAX_SIZE=0 \
|
||||
RICHER_CARD_COMMENT_EDITOR=false \
|
||||
CARD_OPENED_WEBHOOK_ENABLED=false \
|
||||
MAX_IMAGE_PIXEL="" \
|
||||
IMAGE_COMPRESS_RATIO="" \
|
||||
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
|
||||
BIGEVENTS_PATTERN=NONE \
|
||||
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
|
||||
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
|
||||
EMAIL_NOTIFICATION_TIMEOUT=30000 \
|
||||
MATOMO_ADDRESS="" \
|
||||
MATOMO_SITE_ID="" \
|
||||
MATOMO_DO_NOT_TRACK=true \
|
||||
MATOMO_WITH_USERNAME=false \
|
||||
METRICS_ALLOWED_IP_ADDRESSES="" \
|
||||
BROWSER_POLICY_ENABLED=true \
|
||||
TRUSTED_URL="" \
|
||||
WEBHOOKS_ATTRIBUTES="" \
|
||||
OAUTH2_ENABLED=false \
|
||||
OIDC_REDIRECTION_ENABLED=false \
|
||||
OAUTH2_CA_CERT="" \
|
||||
OAUTH2_ADFS_ENABLED=false \
|
||||
OAUTH2_B2C_ENABLED=false \
|
||||
OAUTH2_LOGIN_STYLE=redirect \
|
||||
OAUTH2_CLIENT_ID="" \
|
||||
OAUTH2_SECRET="" \
|
||||
OAUTH2_SERVER_URL="" \
|
||||
OAUTH2_AUTH_ENDPOINT="" \
|
||||
OAUTH2_USERINFO_ENDPOINT="" \
|
||||
OAUTH2_TOKEN_ENDPOINT="" \
|
||||
OAUTH2_ID_MAP="" \
|
||||
OAUTH2_USERNAME_MAP="" \
|
||||
OAUTH2_FULLNAME_MAP="" \
|
||||
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
|
||||
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
|
||||
OAUTH2_EMAIL_MAP="" \
|
||||
LDAP_ENABLE=false \
|
||||
LDAP_PORT=389 \
|
||||
LDAP_HOST="" \
|
||||
LDAP_AD_SIMPLE_AUTH="" \
|
||||
LDAP_USER_AUTHENTICATION=false \
|
||||
LDAP_USER_AUTHENTICATION_FIELD=uid \
|
||||
LDAP_BASEDN="" \
|
||||
LDAP_LOGIN_FALLBACK=false \
|
||||
LDAP_RECONNECT=true \
|
||||
LDAP_TIMEOUT=10000 \
|
||||
LDAP_IDLE_TIMEOUT=10000 \
|
||||
LDAP_CONNECT_TIMEOUT=10000 \
|
||||
LDAP_AUTHENTIFICATION=false \
|
||||
LDAP_AUTHENTIFICATION_USERDN="" \
|
||||
LDAP_AUTHENTIFICATION_PASSWORD="" \
|
||||
LDAP_LOG_ENABLED=false \
|
||||
LDAP_BACKGROUND_SYNC=false \
|
||||
LDAP_BACKGROUND_SYNC_INTERVAL="" \
|
||||
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
|
||||
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
|
||||
LDAP_ENCRYPTION=false \
|
||||
LDAP_CA_CERT="" \
|
||||
LDAP_REJECT_UNAUTHORIZED=false \
|
||||
LDAP_USER_SEARCH_FILTER="" \
|
||||
LDAP_USER_SEARCH_SCOPE="" \
|
||||
LDAP_USER_SEARCH_FIELD="" \
|
||||
LDAP_SEARCH_PAGE_SIZE=0 \
|
||||
LDAP_SEARCH_SIZE_LIMIT=0 \
|
||||
LDAP_GROUP_FILTER_ENABLE=false \
|
||||
LDAP_GROUP_FILTER_OBJECTCLASS="" \
|
||||
LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE="" \
|
||||
LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE="" \
|
||||
LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT="" \
|
||||
LDAP_GROUP_FILTER_GROUP_NAME="" \
|
||||
LDAP_UNIQUE_IDENTIFIER_FIELD="" \
|
||||
LDAP_UTF8_NAMES_SLUGIFY=true \
|
||||
LDAP_USERNAME_FIELD="" \
|
||||
LDAP_FULLNAME_FIELD="" \
|
||||
LDAP_MERGE_EXISTING_USERS=false \
|
||||
LDAP_EMAIL_FIELD="" \
|
||||
LDAP_EMAIL_MATCH_ENABLE=false \
|
||||
LDAP_EMAIL_MATCH_REQUIRE=false \
|
||||
LDAP_EMAIL_MATCH_VERIFIED=false \
|
||||
LDAP_SYNC_USER_DATA=false \
|
||||
LDAP_SYNC_USER_DATA_FIELDMAP="" \
|
||||
LDAP_SYNC_GROUP_ROLES="" \
|
||||
LDAP_DEFAULT_DOMAIN="" \
|
||||
LDAP_SYNC_ADMIN_STATUS="" \
|
||||
LDAP_SYNC_ADMIN_GROUPS="" \
|
||||
HEADER_LOGIN_ID="" \
|
||||
HEADER_LOGIN_FIRSTNAME="" \
|
||||
HEADER_LOGIN_LASTNAME="" \
|
||||
HEADER_LOGIN_EMAIL="" \
|
||||
HEADER_LOGIN_TRUSTED_IPS="" \
|
||||
HEADER_LOGIN_TRUSTED_PROXIES="" \
|
||||
LOGOUT_WITH_TIMER=false \
|
||||
LOGOUT_IN="" \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
LOGOUT_ON_MINUTES="" \
|
||||
CORS="" \
|
||||
CORS_ALLOW_HEADERS="" \
|
||||
CORS_EXPOSE_HEADERS="" \
|
||||
DEFAULT_AUTHENTICATION_METHOD="" \
|
||||
PASSWORD_LOGIN_ENABLED=true \
|
||||
CAS_ENABLED=false \
|
||||
CAS_BASE_URL="" \
|
||||
CAS_LOGIN_URL="" \
|
||||
CAS_VALIDATE_URL="" \
|
||||
SAML_ENABLED=false \
|
||||
SAML_PROVIDER="" \
|
||||
SAML_ENTRYPOINT="" \
|
||||
SAML_ISSUER="" \
|
||||
SAML_CERT="" \
|
||||
SAML_IDPSLO_REDIRECTURL="" \
|
||||
SAML_PRIVATE_KEYFILE="" \
|
||||
SAML_PUBLIC_CERTFILE="" \
|
||||
SAML_IDENTIFIER_FORMAT="" \
|
||||
SAML_LOCAL_PROFILE_MATCH_ATTRIBUTE="" \
|
||||
SAML_ATTRIBUTES="" \
|
||||
ORACLE_OIM_ENABLED=false \
|
||||
WAIT_SPINNER="" \
|
||||
WRITABLE_PATH=/data \
|
||||
S3=""
|
||||
|
||||
# NODE_OPTIONS="--max_old_space_size=4096"
|
||||
|
||||
#---------------------------------------------
|
||||
# == at docker-compose.yml: AUTOLOGIN WITH OIDC/OAUTH2 ====
|
||||
# https://github.com/wekan/wekan/wiki/autologin
|
||||
#- OIDC_REDIRECTION_ENABLED=true
|
||||
#---------------------------------------------------------------------
|
||||
|
||||
ENV PATH=$PATH:/home/wekan/.meteor/
|
||||
|
||||
RUN <<EOR
|
||||
echo "export PATH=$PATH" >> /etc/environment
|
||||
EOR
|
||||
|
||||
# Copy source dir
|
||||
RUN <<EOR
|
||||
set -o xtrace
|
||||
|
||||
mkdir -p /home/wekan/app/.meteor
|
||||
mkdir -p /home/wekan/app/packages
|
||||
EOR
|
||||
|
||||
COPY \
|
||||
.meteor/.finished-upgraders \
|
||||
.meteor/.id \
|
||||
.meteor/cordova-plugins \
|
||||
.meteor/packages \
|
||||
.meteor/platforms \
|
||||
.meteor/release \
|
||||
.meteor/versions \
|
||||
/home/wekan/app/.meteor/
|
||||
|
||||
COPY \
|
||||
package.json \
|
||||
settings.json \
|
||||
/home/wekan/app/
|
||||
|
||||
COPY \
|
||||
tests \
|
||||
/home/wekan/app/tests/
|
||||
|
||||
COPY \
|
||||
packages \
|
||||
/home/wekan/app/packages/
|
||||
|
||||
# Install OS
|
||||
RUN <<EOR
|
||||
set -o xtrace
|
||||
|
||||
# Add non-root user wekan
|
||||
useradd --user-group --system --home-dir /home/wekan wekan
|
||||
# OS dependencies
|
||||
apt-get update --assume-yes
|
||||
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS} ${DEV_DEPS}
|
||||
|
||||
# Meteor installer doesn't work with the default tar binary, so using bsdtar while installing.
|
||||
# https://github.com/coreos/bugs/issues/1095#issuecomment-350574389
|
||||
cp $(which tar) $(which tar)~
|
||||
ln -sf $(which bsdtar) $(which tar)
|
||||
|
||||
# Install NodeJS
|
||||
cd /tmp
|
||||
|
||||
# Download nodejs
|
||||
wget "https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz"
|
||||
wget "https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/SHASUMS256.txt"
|
||||
|
||||
# Verify nodejs authenticity
|
||||
grep "node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz" "SHASUMS256.txt" | shasum -a 256 -c -
|
||||
rm -f "SHASUMS256.txt"
|
||||
|
||||
# Install Node
|
||||
tar xzf "node-$NODE_VERSION-$ARCHITECTURE.tar.gz" -C /usr/local --strip-components=1 --no-same-owner
|
||||
rm "node-$NODE_VERSION-$ARCHITECTURE.tar.gz" "SHASUMS256.txt"
|
||||
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
|
||||
mkdir -p "/opt/nodejs/lib/node_modules/fibers/.node-gyp" "/root/.node-gyp/${NODE_VERSION} /home/wekan/.config"
|
||||
|
||||
# Install node dependencies
|
||||
npm install -g npm@${NPM_VERSION}
|
||||
chown --recursive wekan:wekan /home/wekan/.config
|
||||
|
||||
# Install Meteor
|
||||
cd /home/wekan
|
||||
chown --recursive wekan:wekan /home/wekan
|
||||
echo "Starting meteor ${METEOR_RELEASE} installation... \n"
|
||||
gosu wekan:wekan curl https://install.meteor.com/ | /bin/sh
|
||||
mv /root/.meteor /home/wekan/
|
||||
chown --recursive wekan:wekan /home/wekan/.meteor
|
||||
|
||||
# sed -i 's/api\.versionsFrom/\/\/api.versionsFrom/' /home/wekan/app/packages/meteor-useraccounts-core/package.js
|
||||
cd /home/wekan/.meteor
|
||||
gosu wekan:wekan /home/wekan/.meteor/meteor -- help
|
||||
|
||||
# Build app (Development)
|
||||
cd /home/wekan/app
|
||||
gosu wekan:wekan /home/wekan/.meteor/meteor add standard-minifier-js
|
||||
gosu wekan:wekan /home/wekan/.meteor/meteor npm install --no-audit
|
||||
|
||||
# Put back the original tar
|
||||
mv $(which tar)~ $(which tar)
|
||||
|
||||
# Cleanup
|
||||
apt-get remove --purge --assume-yes ${BUILD_DEPS}
|
||||
apt-get install --assume-yes --no-install-recommends build-essential
|
||||
apt-get autoremove --assume-yes
|
||||
apt-get clean --assume-yes
|
||||
rm -Rf /tmp/*
|
||||
rm -Rf /var/lib/apt/lists/*
|
||||
rm -Rf /var/cache/apt
|
||||
rm -Rf /var/lib/apt/lists
|
||||
rm -Rf /home/wekan/app_build
|
||||
|
||||
mkdir /data
|
||||
chown wekan --recursive /data
|
||||
EOR
|
||||
|
||||
USER wekan
|
||||
|
||||
ENV PORT=3000
|
||||
EXPOSE $PORT
|
||||
|
||||
STOPSIGNAL SIGKILL
|
||||
WORKDIR /home/wekan/app
|
||||
|
||||
#---------------------------------------------------------------------
|
||||
# https://github.com/wekan/wekan/issues/3585#issuecomment-1021522132
|
||||
# Add more Node heap:
|
||||
# NODE_OPTIONS="--max_old_space_size=4096"
|
||||
# Add more stack:
|
||||
# bash -c "ulimit -s 65500; exec node --stack-size=65500 main.js"
|
||||
#---------------------------------------------------------------------
|
||||
#
|
||||
|
||||
CMD ["/home/wekan/.meteor/meteor", "run", "--verbose", "--settings", "settings.json"]
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd /home/wekan/app
|
||||
rm -rf node_modules
|
||||
/home/wekan/.meteor/meteor npm install
|
||||
rm -rf .build
|
||||
/home/wekan/.meteor/meteor build .build --directory
|
||||
cp -f fix-download-unicode/cfs_access-point.txt .build/bundle/programs/server/packages/cfs_access-point.js
|
||||
cd .build/bundle/programs/server
|
||||
rm -rf node_modules
|
||||
/home/wekan/.meteor/meteor npm install
|
||||
cd node_modules/fibers
|
||||
node build.js
|
||||
cd /home/wekan/app
|
||||
@@ -1,17 +0,0 @@
|
||||
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
|
||||
{
|
||||
"dockerComposeFile": ["docker-compose.yml", "docker-compose.extend.yml"],
|
||||
"service": "wekan-dev",
|
||||
"workspaceFolder": "/home/wekan/app",
|
||||
"extensions": [
|
||||
"mutantdino.resourcemonitor",
|
||||
"editorconfig.editorconfig",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"codezombiech.gitignore",
|
||||
"eamodio.gitlens",
|
||||
"gruntfuggly.todo-tree",
|
||||
"dotjoshjohnson.xml",
|
||||
"redhat.vscode-yaml",
|
||||
"vuhrmeister.vscode-meteor"
|
||||
]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
services:
|
||||
|
||||
wekandb-dev:
|
||||
image: mongo:6
|
||||
container_name: wekan-dev-db
|
||||
restart: unless-stopped
|
||||
command: mongod --oplogSize 128
|
||||
networks:
|
||||
- wekan-dev-tier
|
||||
expose:
|
||||
- 27017
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ./volumes/wekan-db:/data/db
|
||||
- ./volumes/wekan-db-dump:/dump
|
||||
|
||||
wekan-dev:
|
||||
container_name: wekan-dev-app
|
||||
restart: always
|
||||
networks:
|
||||
- wekan-dev-tier
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 9229:9229
|
||||
environment:
|
||||
- MONGO_URL=mongodb://wekandb-dev:27017/wekan
|
||||
- ROOT_URL=http://localhost:3000
|
||||
- WITH_API=true
|
||||
- RICHER_CARD_COMMENT_EDITOR=true
|
||||
- BROWSER_POLICY_ENABLED=true
|
||||
- WRITABLE_PATH=/data
|
||||
depends_on:
|
||||
- wekandb-dev
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ./volumes/data:/data
|
||||
- ../client:/home/wekan/app/client
|
||||
- ../models:/home/wekan/app/models
|
||||
- ../config:/home/wekan/app/config
|
||||
- ../imports:/home/wekan/app/imports
|
||||
- ../server:/home/wekan/app/server
|
||||
- ../public:/home/wekan/app/public
|
||||
|
||||
networks:
|
||||
wekan-dev-tier:
|
||||
driver: bridge
|
||||
@@ -1,64 +0,0 @@
|
||||
*~
|
||||
*.swp
|
||||
.meteor-spk
|
||||
*.sublime-workspace
|
||||
tmp/
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.gitmodules
|
||||
.vscode/
|
||||
.idea/
|
||||
.build/*
|
||||
# rspack's build output on the HOST. The image runs its own build, so copying
|
||||
# this in only makes the context bigger and risks a stale bundle being used.
|
||||
_build
|
||||
_build-local-test
|
||||
**/parts/
|
||||
**/stage
|
||||
**/prime
|
||||
**/*.snap
|
||||
snap/.snapcraft/
|
||||
.idea
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
.build*
|
||||
*.browserify.js.cached
|
||||
*.browserify.js.map
|
||||
.build*
|
||||
versions.json
|
||||
.versions
|
||||
.npm
|
||||
.build*
|
||||
._*
|
||||
.Trashes
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
.eslintcache
|
||||
.meteor/local
|
||||
.devcontainer/docker-compose.extend.yml
|
||||
.devcontainer/volumes*/
|
||||
.git
|
||||
|
||||
# Not part of the image: documentation, packaging trees for OTHER targets, the
|
||||
# test suite and the editor/CI configuration. None of it is copied by the
|
||||
# Dockerfile, and everything in the build context is sent to the daemon whether
|
||||
# it is used or not.
|
||||
#
|
||||
# releases/ is NOT here: the Dockerfile COPYs several scripts out of it
|
||||
# (resolve-node-source.sh, bundle-trim.mjs, the ferretdb entrypoint, ...), so
|
||||
# excluding it would break the image build.
|
||||
docs/
|
||||
meta/
|
||||
old-CHANGELOG/
|
||||
openapi/
|
||||
sandstorm-src/
|
||||
snap-src/
|
||||
snap-base-debian/
|
||||
stacksmith/
|
||||
tests/
|
||||
secrets/
|
||||
.tools/
|
||||
.github/
|
||||
.claude/
|
||||
.codex/
|
||||
.tx/
|
||||
@@ -8,20 +8,3 @@ end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{js,html}]
|
||||
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_brace_style = 1TBS
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 80
|
||||
quote_type = auto
|
||||
spaces_around_operators = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,18 +0,0 @@
|
||||
packages/*
|
||||
.snap-meteor-1.8/*
|
||||
# Generated, not written: rspack compiles the whole app into _build/ and Meteor
|
||||
# reads its main modules from there (see .meteorignore). Linting a bundle
|
||||
# reports thousands of findings in code nobody can edit.
|
||||
_build/*
|
||||
_build-local-test/*
|
||||
.build/*
|
||||
# Not application source; see .meteorignore for the same list and why.
|
||||
docs/*
|
||||
old-CHANGELOG/*
|
||||
openapi/*
|
||||
meta/*
|
||||
sandstorm-src/*
|
||||
snap-src/*
|
||||
snap-base-debian/*
|
||||
stacksmith/*
|
||||
.tools/*
|
||||
+27
-73
@@ -1,59 +1,38 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:meteor/recommended",
|
||||
"prettier",
|
||||
"prettier/standard"
|
||||
],
|
||||
"extends": "eslint:recommended",
|
||||
"env": {
|
||||
"es6": true,
|
||||
"node": true,
|
||||
"browser": true,
|
||||
"meteor": true
|
||||
"browser": true
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module"
|
||||
"ecmaVersion": 2017,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"strict": 0,
|
||||
"no-undef": 0,
|
||||
"accessor-pairs": 2,
|
||||
"comma-dangle": [
|
||||
2,
|
||||
"always-multiline"
|
||||
],
|
||||
"comma-dangle": [2, "always-multiline"],
|
||||
"consistent-return": 2,
|
||||
"dot-notation": 2,
|
||||
"eqeqeq": 2,
|
||||
"indent": 0,
|
||||
"indent": [2, 2],
|
||||
"no-cond-assign": 2,
|
||||
"no-constant-condition": 2,
|
||||
"no-eval": 2,
|
||||
"no-inner-declarations": [
|
||||
0
|
||||
],
|
||||
"no-inner-declarations": [0],
|
||||
"no-unneeded-ternary": 2,
|
||||
"radix": 2,
|
||||
"semi": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"camelcase": [
|
||||
2,
|
||||
{
|
||||
"properties": "never"
|
||||
}
|
||||
],
|
||||
"semi": [2, "always"],
|
||||
"camelcase": [2, {"properties": "never"}],
|
||||
"comma-spacing": 2,
|
||||
"comma-style": 2,
|
||||
"eol-last": 2,
|
||||
"linebreak-style": [
|
||||
2,
|
||||
"unix"
|
||||
],
|
||||
"meteor/audit-argument-checks": 0,
|
||||
"linebreak-style": [2, "unix"],
|
||||
"new-parens": 2,
|
||||
"no-lonely-if": 2,
|
||||
"no-multiple-empty-lines": 2,
|
||||
@@ -61,15 +40,10 @@
|
||||
"no-spaced-func": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"operator-linebreak": 2,
|
||||
"quotes": [
|
||||
2,
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"quotes": [2, "single"],
|
||||
"semi-spacing": 2,
|
||||
"space-unary-ops": 2,
|
||||
"arrow-parens": 2,
|
||||
"arrow-spacing": 2,
|
||||
"no-class-assign": 2,
|
||||
"no-dupe-class-members": 2,
|
||||
@@ -78,35 +52,8 @@
|
||||
"prefer-const": 2,
|
||||
"prefer-spread": 2,
|
||||
"prefer-template": 2,
|
||||
"no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
],
|
||||
"meteor/no-session": 0
|
||||
"no-unused-vars" : "warn"
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"meteor": {
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"prettier",
|
||||
"meteor",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"globals": {
|
||||
"Meteor": false,
|
||||
"Session": false,
|
||||
@@ -123,31 +70,37 @@
|
||||
"Template": false,
|
||||
"$": false,
|
||||
"_": false,
|
||||
"autosize": false,
|
||||
"Avatar": true,
|
||||
"Avatars": true,
|
||||
"BlazeComponent": false,
|
||||
"BlazeLayout": false,
|
||||
"CollectionHooks": false,
|
||||
"DocHead": false,
|
||||
"ESSearchResults": false,
|
||||
"FastRender": false,
|
||||
"FlowRouter": false,
|
||||
"FS": false,
|
||||
"getSlug": false,
|
||||
"Migrations": false,
|
||||
"moment": false,
|
||||
"Mousetrap": false,
|
||||
"Picker": false,
|
||||
"Presence": true,
|
||||
"presences": true,
|
||||
"Ps": true,
|
||||
"ReactiveTabs": false,
|
||||
"Restivus": false,
|
||||
"SimpleSchema": false,
|
||||
"SubsManager": false,
|
||||
"T9n": false,
|
||||
"TAPi18n": false,
|
||||
"AccountsTemplates": true,
|
||||
"Activities": true,
|
||||
"Attachments": true,
|
||||
"Boards": true,
|
||||
"CardCommentReactions": true,
|
||||
"CardComments": true,
|
||||
"DatePicker": true,
|
||||
"DatePicker" : true,
|
||||
"Cards": true,
|
||||
"CustomFields": true,
|
||||
"Lists": true,
|
||||
@@ -156,11 +109,13 @@
|
||||
"CSSEvents": true,
|
||||
"EscapeActions": true,
|
||||
"Filter": true,
|
||||
"Mixins": true,
|
||||
"Modal": true,
|
||||
"MultiSelection": true,
|
||||
"Popup": true,
|
||||
"Sidebar": true,
|
||||
"Utils": true,
|
||||
"InlinedForm": true,
|
||||
"UnsavedEdits": true,
|
||||
"Notifications": true,
|
||||
"allowIsBoardAdmin": true,
|
||||
@@ -168,16 +123,15 @@
|
||||
"allowIsBoardMemberByCard": true,
|
||||
"allowIsBoardMemberCommentOnly": true,
|
||||
"allowIsBoardMemberNoComments": true,
|
||||
"allowIsBoardMemberWorker": true,
|
||||
"Emoji": true,
|
||||
"Checklists": true,
|
||||
"Settings": true,
|
||||
"InvitationCodes": true,
|
||||
"JsonRoutes": true,
|
||||
"Authentication": true,
|
||||
"Integrations": true,
|
||||
"HTTP": true,
|
||||
"AccountSettings": true,
|
||||
"TableVisibilityModeSettings": true,
|
||||
"Announcements": true,
|
||||
"Swimlanes": true,
|
||||
"ChecklistItems": true,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: wekan
|
||||
custom: ['https://wekan.fi/commercial-support/']
|
||||
@@ -0,0 +1,27 @@
|
||||
## Issue
|
||||
|
||||
Add these issues to elsewhere:
|
||||
- Snap: https://github.com/wekan/wekan-snap/issues
|
||||
|
||||
Other Wekan issues can be added here.
|
||||
|
||||
**Server Setup Information**:
|
||||
|
||||
* Did you test in newest Wekan?:
|
||||
* For new Wekan install, did you configure root-url correctly https://github.com/wekan/wekan/wiki/Settings ?
|
||||
* Wekan version:
|
||||
* If this is about old version of Wekan, what upgrade problem you have?:
|
||||
* Operating System:
|
||||
* Deployment Method(snap/docker/sandstorm/mongodb bundle/source):
|
||||
* Http frontend if any (Caddy, Nginx, Apache, see config examples from Wekan GitHub wiki first):
|
||||
* Node Version:
|
||||
* MongoDB Version:
|
||||
* ROOT_URL environment variable http(s)://(subdomain).example.com(/suburl):
|
||||
|
||||
**Problem description**:
|
||||
- *REQUIRED: Add recorded animated gif about how it works currently, and screenshot mockups how it should work. Use peek to record animgif in Linux https://github.com/phw/peek*
|
||||
- *Explain steps how to reproduce*
|
||||
- *In webbrowser, what does show Right Click / Inspect / Console ? Chrome shows more detailed info than Firefox.*
|
||||
- *If using Snap, what does show command `sudo snap logs wekan.wekan` ?*
|
||||
- *If using Docker, what does show command `sudo docker logs wekan-app` ?*
|
||||
- *If logs are very long, attach them in .zip file*
|
||||
@@ -1,42 +0,0 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Report a bug to help improve WeKan
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug! Please ensure you are using the [latest version of WeKan](https://wekan.fi/install) and [Upgrade](https://wekan.fi/upgrade) before submitting.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is. Mention versions of WeKan, Node.js, database name and version, frontend webserver version like Caddy etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we recreate this issue?
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: |
|
||||
- Please paste any relevant anonymized server logs or browser console errors here.
|
||||
- Snap: sudo snap logs wekan.wekan
|
||||
- Docker: sudo docker logs wekan-app
|
||||
- If logs are very long, attach them in .zip file
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, anonymized screenshots or GIF video about the bug, and screenshot mockups about how it should work.
|
||||
@@ -1,31 +0,0 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest a new feature for WeKan
|
||||
labels: ["Feature:Request"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your feature request related to a problem? Please describe.
|
||||
placeholder: I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, like anonymized screenshot mockups about how it should work.
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
name: 🛡️ Security Issue
|
||||
description: Report a security vulnerability
|
||||
labels: ["security", "critical"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## ⚠️ IMPORTANT: Please do not report security vulnerabilities via public issues.
|
||||
|
||||
To protect the WeKan community, we ask that you report security bugs privately. This allows us to fix the issue before it can be exploited by malicious actors.
|
||||
|
||||
### How to report:
|
||||
Please read our **[Security Policy (SECURITY.md)](https://github.com/wekan/wekan/blob/main/SECURITY.md)** for the official reporting process and contact information.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: 🗳️ Univention (UCS) Issue
|
||||
description: Problems specifically related to the WeKan app on Univention Corporate Server
|
||||
labels: ["UCS"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 🛑 Is this a UCS-specific issue?
|
||||
|
||||
If your issue is related to the **Univention Corporate Server (UCS) integration**, packaging, or installation via the Univention App Center, it should be reported in the dedicated Univention repository.
|
||||
|
||||
### ➡️ [Report UCS Issues Here](https://github.com/wekan/univention/issues)
|
||||
|
||||
---
|
||||
**Why?**
|
||||
Reporting there ensures that the maintainers specifically focused on the UCS environment see your request.
|
||||
|
||||
If you are certain this is a **core WeKan bug** that affects all platforms (Docker, Snap, etc.), please go back and use the standard [Bug Report](https://github.com/wekan/wekan/issues/new?template=bug-report.yml) template.
|
||||
- type: textarea
|
||||
id: ucs-details
|
||||
attributes:
|
||||
label: Brief Description (Optional)
|
||||
description: If you still wish to post here, please provide a brief summary of why this is a core Wekan issue and not a UCS-specific integration bug.
|
||||
@@ -1,43 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Build-time-only (devDependency) Rspack toolchain. These packages compile
|
||||
# the bundle on the developer / CI machine and are NOT shipped in the
|
||||
# deployed WeKan server or client bundle, so their advisories do not affect
|
||||
# a running WeKan instance. (For example webpack-dev-server / @rspack/dev-
|
||||
# server, pulled via @rspack/cli, and the socket.io/ws chain pulled via
|
||||
# @rsdoctor/rspack-plugin, never run in WeKan — it builds with `meteor run`,
|
||||
# not the Rspack CLI or dev-server.)
|
||||
#
|
||||
# They also cannot simply be removed or bumped:
|
||||
# * @meteorjs/rspack re-pins and re-installs @rspack/cli and
|
||||
# @rsdoctor/rspack-plugin into package.json on every build, so deleting
|
||||
# them does not stick.
|
||||
# * The only upgrades Dependabot/npm offer are build-breaking: a
|
||||
# @meteorjs/rspack downgrade, or @rspack/cli 2.0 / Rspack 2.0, which
|
||||
# needs Meteor 3.6 (see https://github.com/meteor/meteor/pull/14360).
|
||||
# WeKan currently runs METEOR@3.5-rc.2.
|
||||
#
|
||||
# So pin them here until Meteor 3.6 + Rspack 2.0 land, then revisit.
|
||||
- dependency-name: "@meteorjs/rspack"
|
||||
- dependency-name: "@rspack/core"
|
||||
- dependency-name: "@rspack/cli"
|
||||
- dependency-name: "@rsdoctor/rspack-plugin"
|
||||
- dependency-name: "css-loader"
|
||||
- dependency-name: "style-loader"
|
||||
# Playwright end-to-end test harness — pure test-only tooling that never ships in
|
||||
# WeKan. It now depends only on @playwright/test and the official `mongodb` driver
|
||||
# (the heavy, CVE-prone `mongosh` CLI was replaced by helpers/mongo-runner.js).
|
||||
# Watch this directory so those two stay patched.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tests/playwright"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,486 +0,0 @@
|
||||
name: AppImage
|
||||
|
||||
# Build WeKan as an AppImage for every CPU an AppImage runtime exists for, and
|
||||
# attach them to a WeKan release. The ordinary full-release path calls this
|
||||
# workflow from release-all.yml with the exact tag after its core bundles are
|
||||
# published; workflow_dispatch remains available for rebuilding an AppImage.
|
||||
#
|
||||
# WHAT IS INSIDE IS WHAT WEKAN ALREADY SHIPS. release-all.yml already produces a
|
||||
# bundle that runs offline with nothing installed: the Meteor server (main.js),
|
||||
# the bundled Node.js the release was built against, the wekan/FerretDB v1 fork
|
||||
# with its embedded pure-Go SQLite backend, the wekan/mongo-tools-patches binaries, the
|
||||
# cpu-exec/qemu-user shim from #6458, and start-wekan.sh to bring them up. That
|
||||
# bundle is already relocatable and already self-contained - it is the offline
|
||||
# zip people download today - so this workflow does NOT rebuild WeKan. It takes
|
||||
# the wekan-<version>-<arch>.zip off the release and wraps it, which means the
|
||||
# AppImage is byte-for-byte the same server, Node and FerretDB as the zip beside
|
||||
# it rather than a second build that might differ.
|
||||
#
|
||||
# So there is no MongoDB to install, no Node to install, and no database server
|
||||
# to run: FerretDB v1 keeps everything in SQLite files under the AppImage's data
|
||||
# directory. One download, one file, double-click.
|
||||
#
|
||||
# WHICH CPUs. An AppImage needs a runtime binary for its architecture, and those
|
||||
# exist for x86_64, aarch64, armhf and i686. WeKan publishes Linux bundles for
|
||||
# amd64 and arm64, so those are the two that can be wrapped: x86_64 from amd64,
|
||||
# aarch64 from arm64. Each is built on a runner of its own CPU, because
|
||||
# appimagetool has to run on the architecture it is packing for. If a bundle for
|
||||
# another architecture appears on a release, add it to the matrix and it will be
|
||||
# wrapped the same way.
|
||||
#
|
||||
# WHERE THE DATA GOES. A .deb or a container has somewhere obvious to put its
|
||||
# data; an AppImage is one file that may live on a USB stick, and the one place
|
||||
# it can always write is the user's home. WRITABLE_PATH defaults to
|
||||
# ~/.local/share/wekan here, so running the AppImage twice keeps the same boards,
|
||||
# and setting WRITABLE_PATH yourself still overrides it - the launcher's own
|
||||
# variables all still work.
|
||||
#
|
||||
# WHERE THE APPIMAGE ITSELF MOUNTS. Separately from WRITABLE_PATH, the
|
||||
# AppImage runtime mounts the squashfs this file carries under
|
||||
# $TMPDIR/.mount_<name>.<random> before AppRun ever runs - /tmp by default,
|
||||
# which is sometimes a small tmpfs. AppRun relocates every launch after the
|
||||
# first to WRITABLE_PATH/app instead (and sweeps out orphaned leftovers from
|
||||
# /tmp), so a small /tmp never fills up with WeKan's own mount. This is
|
||||
# AppImage-specific: no other WeKan platform (Docker, snap, .deb, the bundle
|
||||
# zip) uses FUSE-mounts-itself the way an AppImage does, so none of them are
|
||||
# touched by this.
|
||||
|
||||
on:
|
||||
# Callable, so release-all-missing.yml can ask for just the architectures
|
||||
# a release is short of. The build itself stays here - a second copy of the
|
||||
# appimages build would drift from this one.
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
only:
|
||||
description: 'Only build these architectures, as a JSON array (e.g. ["aarch64"]); empty = all'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to wrap and attach to (empty = newest release)'
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
description: 'Attach the AppImages to the release'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
only:
|
||||
description: 'Only build these architectures, as a JSON array (e.g. ["aarch64"]); empty = all'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: wekan-appimage-${{ inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: find the release
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
tag: ${{ steps.find.outputs.tag }}
|
||||
version: ${{ steps.find.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: find
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag='${{ inputs.tag }}'
|
||||
# --repo on every gh call, whether or not the job happens to have
|
||||
# checked out: a call that relies on an inferred remote breaks the day
|
||||
# somebody removes the checkout, and the error it gives - "fatal: not
|
||||
# a git repository" - names neither gh nor the workflow.
|
||||
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
|
||||
--limit 1 --json tagName --jq '.[0].tagName')"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::No release to wrap, and no tag was given."
|
||||
exit 1
|
||||
fi
|
||||
# The bundles are named wekan-10.57-amd64.zip against tag v10.57.
|
||||
version="${tag#v}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "Wrapping the bundles from $tag (version $version)"
|
||||
|
||||
build:
|
||||
name: AppImage ${{ matrix.appimage_arch }}
|
||||
needs: release
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
# A 32-bit job that cannot run on the runner it was given must not fail the
|
||||
# release for the architectures that built fine.
|
||||
continue-on-error: ${{ matrix.tolerate_failure || false }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- appimage_arch: x86_64
|
||||
bundle_arch: amd64
|
||||
runs_on: ubuntu-24.04
|
||||
- appimage_arch: aarch64
|
||||
bundle_arch: arm64
|
||||
runs_on: ubuntu-24.04-arm
|
||||
# The other two architectures an AppImage runtime EXISTS for. There
|
||||
# are four in total - x86_64, aarch64, i686, armhf - and that is the
|
||||
# whole limit: the runtime is the ELF the kernel executes, so an
|
||||
# architecture with no runtime cannot have an AppImage at all. That is
|
||||
# why ppc64le, s390x, riscv64 and loong64 are not here and never will
|
||||
# be; they are served by the bundle zip and the .deb.
|
||||
#
|
||||
# i686 builds on the x86_64 runner, which runs 32-bit x86 natively -
|
||||
# no emulation, no multiarch, and appimagetool-i686 runs as-is.
|
||||
- appimage_arch: i686
|
||||
bundle_arch: i386
|
||||
runs_on: ubuntu-24.04
|
||||
# armhf builds on the aarch64 runner, which runs 32-bit ARM only if
|
||||
# the kernel was built with CONFIG_COMPAT and the 32-bit loader is
|
||||
# installed. GitHub does not promise either, so this one is allowed to
|
||||
# fail without taking the release with it - the same treatment TSC
|
||||
# gives its emulated armhf job. If it turns out to work reliably,
|
||||
# drop the flag; if it never does, drop the entry.
|
||||
- appimage_arch: armhf
|
||||
bundle_arch: armhf
|
||||
runs_on: ubuntu-24.04-arm
|
||||
tolerate_failure: true
|
||||
# WHICH entries this run wants, decided here because it cannot be decided
|
||||
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
|
||||
# that context to runs-on, env, name, steps and a few others, but not to a
|
||||
# job's own condition - and a workflow that tries answers
|
||||
# Unrecognized named-value: 'matrix'
|
||||
# and refuses to load at all, taking every workflow that CALLS it with it.
|
||||
# A job-level env CAN see matrix, so the answer is computed once here and
|
||||
# every step below asks for it.
|
||||
env:
|
||||
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.appimage_arch) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
# An AppImage only WRAPS an existing release bundle
|
||||
# (wekan-<version>-<arch>.zip); it cannot build one. When this workflow is
|
||||
# asked to fill a gap for a release whose base bundle is not published yet
|
||||
# (release in progress, or it failed), `gh release download` finds "no
|
||||
# assets to download" and the job failed hard. Turn that into a graceful
|
||||
# skip: if the bundle this arch needs is not on the release, flip BUILD_THIS
|
||||
# off so every step below (all gated on it) is skipped with a notice. The
|
||||
# AppImage is built normally once the base bundle exists.
|
||||
- name: Skip this arch if its release bundle is not published yet
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
|
||||
if gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" \
|
||||
--json assets --jq '.assets[].name' 2>/dev/null | grep -qx "$zip"; then
|
||||
echo "OK: $zip is on ${{ needs.release.outputs.tag }}; building its AppImage."
|
||||
else
|
||||
echo "::notice::$zip is not on ${{ needs.release.outputs.tag }} yet - skipping this AppImage (it can only wrap a bundle that exists)."
|
||||
echo "BUILD_THIS=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Fetch the release bundle for this architecture
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
|
||||
gh release download '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --pattern "$zip" --clobber
|
||||
unzip -q "$zip"
|
||||
# A zip that exists is not a bundle that runs; these are the two files
|
||||
# the AppImage is built around.
|
||||
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
|
||||
if [ ! -e "$f" ]; then
|
||||
echo "::error::$zip has no $f - it is not a runnable WeKan bundle."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
chmod +x bundle/node bundle/ferretdb bundle/start-wekan.sh
|
||||
du -sh bundle
|
||||
|
||||
- name: Build the AppDir
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version='${{ needs.release.outputs.version }}'
|
||||
arch='${{ matrix.appimage_arch }}'
|
||||
rm -rf AppDir
|
||||
mkdir -p AppDir/usr/lib/wekan AppDir/usr/share/applications \
|
||||
AppDir/usr/share/icons/hicolor/512x512/apps
|
||||
cp -a bundle AppDir/usr/lib/wekan/bundle
|
||||
|
||||
# WeKan's own icon, from public/. 512x512 is what appimagetool wants
|
||||
# for the thumbnail a desktop shows.
|
||||
cp public/android-chrome-512x512.png \
|
||||
AppDir/usr/share/icons/hicolor/512x512/apps/wekan.png
|
||||
cp AppDir/usr/share/icons/hicolor/512x512/apps/wekan.png AppDir/wekan.png
|
||||
|
||||
cat > AppDir/usr/share/applications/wekan.desktop <<'DESKTOP'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=WeKan
|
||||
GenericName=Kanban board
|
||||
Comment=Open Source kanban board with FerretDB and Node.js included
|
||||
Exec=wekan
|
||||
Icon=wekan
|
||||
Terminal=true
|
||||
Categories=Office;ProjectManagement;
|
||||
Keywords=kanban;board;tasks;project;
|
||||
DESKTOP
|
||||
cp AppDir/usr/share/applications/wekan.desktop AppDir/wekan.desktop
|
||||
|
||||
# AppRun is what the AppImage runs. It does three things the launcher
|
||||
# cannot do for itself: pick a writable place for the data (an AppImage
|
||||
# is read-only and may sit on a USB stick), say where WeKan is once it
|
||||
# has started, and then hand over to start-wekan.sh, which is the same
|
||||
# launcher the offline zip uses - so every variable it documents
|
||||
# (WRITABLE_PATH, PORT, ROOT_URL, MONGO_URL, WEKAN_DB, ...) still works.
|
||||
cat > AppDir/AppRun <<'APPRUN'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
BUNDLE="$HERE/usr/lib/wekan/bundle"
|
||||
|
||||
# An AppImage is read-only, so the data cannot live beside it.
|
||||
: "${WRITABLE_PATH:=${XDG_DATA_HOME:-$HOME/.local/share}/wekan}"
|
||||
export WRITABLE_PATH
|
||||
|
||||
# The AppImage runtime mounts ITSELF (the squashfs this file carries)
|
||||
# under $TMPDIR/.mount_<name>.<random> - defaulting to /tmp when
|
||||
# TMPDIR is unset - before AppRun ever runs, so the FIRST mount of a
|
||||
# launch cannot be redirected from in here. What CAN be done: send
|
||||
# every launch from this point on to WRITABLE_PATH/app instead, and
|
||||
# clean up whatever an earlier, /tmp-mounted launch left behind.
|
||||
# Reported in production: checkmk warned
|
||||
# "/tmp/.mount_wekan.OhaGOG ... 100% used" because /tmp was a small
|
||||
# tmpfs and a read-only squashfs mount always reports itself as
|
||||
# full, regardless of size - that is normal for any squashfs mount,
|
||||
# but a small /tmp made it look like a leak and paged someone.
|
||||
#
|
||||
# Only when the caller did not already choose a TMPDIR (an explicit
|
||||
# administrator setting - even TMPDIR=/tmp - is left alone), and
|
||||
# only for a real mounted AppImage: the runtime exports APPIMAGE to
|
||||
# the mounted copy's absolute path, which is what re-execing here
|
||||
# needs and which running AppRun directly (e.g. during development)
|
||||
# never sets. WEKAN_APPIMAGE_RELOCATED marks the re-exec so this
|
||||
# runs exactly once per launch, not forever.
|
||||
if [ -z "${TMPDIR:-}" ] && [ -n "${APPIMAGE:-}" ] \
|
||||
&& [ "${WEKAN_APPIMAGE_RELOCATED:-}" != "1" ]; then
|
||||
APPIMAGE_TMP="$WRITABLE_PATH/app"
|
||||
mkdir -p "$APPIMAGE_TMP"
|
||||
|
||||
# Sweep WeKan's own leftover mount directories out of /tmp -
|
||||
# ORPHANED ones only. An entry /proc/mounts still lists as a live
|
||||
# mount belongs to a running instance (quite possibly the very
|
||||
# one this AppRun is executing out of right now) and must not be
|
||||
# touched; one no longer listed there is left over from an
|
||||
# earlier launch that was killed rather than shut down cleanly,
|
||||
# and is safe to remove.
|
||||
for d in /tmp/.mount_*[Ee]kan*; do
|
||||
[ -d "$d" ] || continue
|
||||
if ! awk -v d="$d" '$2==d{f=1} END{exit !f}' /proc/mounts 2>/dev/null; then
|
||||
rm -rf "$d" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
export TMPDIR="$APPIMAGE_TMP"
|
||||
export WEKAN_APPIMAGE_RELOCATED=1
|
||||
exec "$APPIMAGE" "$@"
|
||||
fi
|
||||
|
||||
: "${PORT:=8080}"
|
||||
export PORT
|
||||
: "${ROOT_URL:=http://localhost:$PORT}"
|
||||
export ROOT_URL
|
||||
|
||||
mkdir -p "$WRITABLE_PATH"
|
||||
|
||||
echo "WeKan is starting."
|
||||
echo " Open: $ROOT_URL"
|
||||
echo " Data: $WRITABLE_PATH"
|
||||
echo " Stop: Ctrl-C"
|
||||
echo
|
||||
echo "Set WRITABLE_PATH, PORT, ROOT_URL or MONGO_URL to change any of"
|
||||
echo "this; WEKAN_DB=mongodb uses your own database instead of the"
|
||||
echo "bundled FerretDB."
|
||||
echo
|
||||
|
||||
# A missing AppImage may be rebuilt around an ALREADY-PUBLISHED base
|
||||
# bundle. In that case its start-wekan.sh predates fixes in this
|
||||
# checkout. Keep the 32-bit V8 address-space ceiling in AppRun too,
|
||||
# so an i686/armhf AppImage made for an older release can start. An
|
||||
# administrator's explicit NODE_OPTIONS remains authoritative.
|
||||
case "@APPIMAGE_ARCH@" in
|
||||
i686|armhf)
|
||||
: "${NODE_OPTIONS:=--max-old-space-size=1024}"
|
||||
export NODE_OPTIONS
|
||||
;;
|
||||
esac
|
||||
|
||||
exec "$BUNDLE/start-wekan.sh" "$@"
|
||||
APPRUN
|
||||
sed -i "s/@APPIMAGE_ARCH@/$arch/" AppDir/AppRun
|
||||
chmod +x AppDir/AppRun
|
||||
|
||||
- name: Pack the AppImage
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version='${{ needs.release.outputs.version }}'
|
||||
arch='${{ matrix.appimage_arch }}'
|
||||
name="WeKan-${version}-${arch}"
|
||||
|
||||
curl -fSL --retry 5 --retry-delay 10 -o appimagetool \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||
chmod +x appimagetool
|
||||
|
||||
mkdir -p dist
|
||||
# A runner has no FUSE, so the tool cannot mount itself; this unpacks
|
||||
# it to a temporary directory and runs it from there instead.
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$arch" \
|
||||
./appimagetool AppDir "dist/${name}.AppImage"
|
||||
|
||||
chmod +x "dist/${name}.AppImage"
|
||||
# Checksum keeps the .AppImage extension in its name, matching the zip
|
||||
# convention (wekan-<v>-<arch>.zip.sha256sum) and what
|
||||
# releases/expected-assets.sh looks for. Naming it ${name}.sha256sum
|
||||
# (extension dropped) meant "what is missing" never found the checksum,
|
||||
# so every run rebuilt the AppImage even when it was already published.
|
||||
( cd dist
|
||||
md5sum "${name}.AppImage" > "${name}.AppImage.md5sum"
|
||||
sha256sum "${name}.AppImage" > "${name}.AppImage.sha256sum" )
|
||||
ls -lh dist
|
||||
|
||||
- name: Check that it starts
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version='${{ needs.release.outputs.version }}'
|
||||
app="dist/WeKan-${version}-${{ matrix.appimage_arch }}.AppImage"
|
||||
# An AppImage that packs is not an AppImage that runs. Start it, wait
|
||||
# for WeKan to answer on its port, then stop it - so a broken bundle
|
||||
# is caught here rather than by whoever downloads it.
|
||||
export WRITABLE_PATH="$PWD/smoke-data" PORT=8080
|
||||
|
||||
# A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK. Probe the INNER Node,
|
||||
# not the AppImage runtime: on the arm64 runner the armhf AppImage
|
||||
# runtime itself executes, while its bundled hard-float Node cannot
|
||||
# when /lib/ld-linux-armhf.so.3 is absent. Probing the outer wrapper
|
||||
# misclassified that missing loader as an application startup bug.
|
||||
# AppDir is the exact tree just packed, so this also avoids extracting
|
||||
# the large image merely to make the capability decision.
|
||||
if ! AppDir/usr/lib/wekan/bundle/node --version >/dev/null 2>&1; then
|
||||
echo "::warning::this runner cannot execute the bundled ${{ matrix.appimage_arch }} Node binary, so the AppImage was packed but not started. It is uploaded unchecked."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$app" > smoke.log 2>&1 &
|
||||
pid=$!
|
||||
ok=false
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -fsS -o /dev/null "http://localhost:8080/sign-in"; then ok=true; break; fi
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 2
|
||||
done
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
if [ "$ok" != true ]; then
|
||||
echo "::error::the AppImage did not answer on port 8080; its log follows"
|
||||
tail -n 100 smoke.log
|
||||
exit 1
|
||||
fi
|
||||
echo "WeKan answered on port 8080."
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: appimage-${{ matrix.appimage_arch }}
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
timeout-minutes: 30
|
||||
name: attach to the release
|
||||
needs: [release, build]
|
||||
if: ${{ !cancelled() && inputs.publish != false }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: appimage-*
|
||||
path: assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Say what was built, and what was not
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -lh assets 2>/dev/null || true
|
||||
if ls assets/*.AppImage >/dev/null 2>&1; then
|
||||
# Every architecture the matrix builds. A missing one WARNS rather
|
||||
# than fails: the 32-bit pair is allowed not to build, and an
|
||||
# architecture whose base bundle was never published has nothing to
|
||||
# wrap. What must not happen silently is one going missing unnoticed.
|
||||
for want in x86_64 aarch64 i686 armhf; do
|
||||
ls assets/*"-$want.AppImage" >/dev/null 2>&1 || \
|
||||
echo "::warning::no AppImage for $want - see that build job's log"
|
||||
done
|
||||
else
|
||||
# No AppImage was produced. That is only a FAILURE if there was a base
|
||||
# bundle to wrap: an AppImage cannot be built without one, and the
|
||||
# build jobs skip (not fail) when the base bundle is not published yet.
|
||||
published="$(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
base_present=false
|
||||
for arch in amd64 arm64 i386 armhf; do
|
||||
printf '%s\n' "$published" | grep -qx "wekan-${{ needs.release.outputs.version }}-${arch}.zip" && base_present=true
|
||||
done
|
||||
if [ "$base_present" = false ]; then
|
||||
echo "::notice::No base bundle (wekan-${{ needs.release.outputs.version }}-{amd64,arm64}.zip) is on ${{ needs.release.outputs.tag }} yet, so there is nothing to wrap - skipping. release-all.yml builds the AppImages once the base bundles are published."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::A base bundle is published but no architecture produced an AppImage - every build job failed. Their logs say why; nothing is uploaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload them
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! ls assets/*.AppImage >/dev/null 2>&1; then
|
||||
echo "Nothing to upload (no base bundle was published to wrap)."
|
||||
exit 0
|
||||
fi
|
||||
# --repo, because this job does NOT check the repository out - it only
|
||||
# downloads artifacts - so `gh` has no git remote to infer the repo
|
||||
# from and fails with "fatal: not a git repository". Every other gh
|
||||
# call in this workflow already passes it; these two did not, so the
|
||||
# AppImages were built, smoke-tested and uploaded as artifacts, and
|
||||
# then nothing was attached to the release.
|
||||
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' assets/* \
|
||||
--repo "${{ github.repository }}" --clobber
|
||||
echo "Uploaded to $(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --json url --jq .url)"
|
||||
@@ -1,436 +0,0 @@
|
||||
name: Flatpak
|
||||
|
||||
# Build WeKan as a Flatpak for every CPU the runtime exists for, attach the
|
||||
# single-file bundles to the newest WeKan release, and publish a Flatpak
|
||||
# REPOSITORY that can be added with `flatpak remote-add`.
|
||||
#
|
||||
# Like AppImage.yml, this does NOT rebuild WeKan. release-all.yml already
|
||||
# produces a bundle that runs offline with nothing installed - the Meteor server
|
||||
# (main.js), the bundled Node.js the release was built against, the
|
||||
# wekan/FerretDB v1 fork with its embedded pure-Go SQLite backend, the
|
||||
# wekan/mongo-tools-patches binaries and start-wekan.sh - so this takes the
|
||||
# wekan-<version>-<arch>.zip off the release and packages it. The Flatpak is
|
||||
# therefore the same server, Node and FerretDB as the zip and the AppImage
|
||||
# beside it, not a third build that might differ.
|
||||
#
|
||||
# A BUNDLE AND A REPOSITORY are two different things and both come out of one
|
||||
# build here. A .flatpak bundle is one file you install by hand and update by
|
||||
# downloading the next one; a repository is what makes `flatpak update` work,
|
||||
# and one repository serves every architecture at once. The bundles go on the
|
||||
# GitHub release; the repository is uploaded as an artifact of static files, so
|
||||
# it can be copied to a website with no server to run.
|
||||
#
|
||||
# WHICH CPUs. org.freedesktop.Platform//24.08 is built for x86_64 and aarch64,
|
||||
# and WeKan publishes Linux bundles for amd64 and arm64, so those are the two.
|
||||
# Each builds on a runner of its own CPU rather than under emulation.
|
||||
#
|
||||
# AND THAT IS THE WHOLE LIST - there is no third to add. A flatpak runs against
|
||||
# a RUNTIME, not against the host's libraries, so an architecture exists here
|
||||
# only if freedesktop.org publishes org.freedesktop.Platform for it. It does so
|
||||
# for x86_64 and aarch64; the i386 and arm runtimes were discontinued. That is
|
||||
# the difference from AppImage.yml, which was able to grow from two
|
||||
# architectures to four: an AppImage carries its own runtime binary, and
|
||||
# runtimes exist for i686 and armhf as well. WeKan's ppc64le, s390x, riscv64,
|
||||
# loong64 and 32-bit bundles are served by the zip and the .deb.
|
||||
#
|
||||
# THE SANDBOX. WeKan is a server you reach in a browser, so it needs the network
|
||||
# and a place to keep its data, and nothing else - no X11, no audio, no access
|
||||
# to your files. --share=network and --persist=.wekan are the whole permission
|
||||
# list, and FerretDB keeps everything in SQLite inside that one directory.
|
||||
#
|
||||
# THE REPOSITORY IS UNSIGNED. Signing needs a GPG key, which is a secret rather
|
||||
# than something a workflow can invent. The README written into the repository
|
||||
# says exactly how to sign it when a key exists.
|
||||
|
||||
on:
|
||||
# Callable, so release-all-missing.yml can ask for just the architectures
|
||||
# a release is short of. The build itself stays here - a second copy of the
|
||||
# flatpaks build would drift from this one.
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
only:
|
||||
description: 'Only build these architectures, as a JSON array (e.g. ["x86_64"]); empty = all'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to wrap and attach to (empty = newest release)'
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
description: 'Attach the bundles to the release'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
publish_repo:
|
||||
description: 'Build the Flatpak repository for the website'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
only:
|
||||
description: 'Only build these architectures, as a JSON array (e.g. ["x86_64"]); empty = all'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: wekan-flatpak-${{ inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
timeout-minutes: 15
|
||||
name: find the release
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
tag: ${{ steps.find.outputs.tag }}
|
||||
version: ${{ steps.find.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: find
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag='${{ inputs.tag }}'
|
||||
# --repo on every gh call, whether or not the job happens to have
|
||||
# checked out: one that relies on an inferred remote breaks the day
|
||||
# somebody removes the checkout.
|
||||
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
|
||||
--limit 1 --json tagName --jq '.[0].tagName')"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::No release to wrap, and no tag was given."
|
||||
exit 1
|
||||
fi
|
||||
version="${tag#v}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "Wrapping the bundles from $tag (version $version)"
|
||||
|
||||
build:
|
||||
name: flatpak ${{ matrix.flatpak_arch }}
|
||||
needs: release
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- flatpak_arch: x86_64
|
||||
bundle_arch: amd64
|
||||
runs_on: ubuntu-24.04
|
||||
- flatpak_arch: aarch64
|
||||
bundle_arch: arm64
|
||||
runs_on: ubuntu-24.04-arm
|
||||
# WHICH entries this run wants, decided here because it cannot be decided
|
||||
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
|
||||
# that context to runs-on, env, name, steps and a few others, but not to a
|
||||
# job's own condition - and a workflow that tries answers
|
||||
# Unrecognized named-value: 'matrix'
|
||||
# and refuses to load at all, taking every workflow that CALLS it with it.
|
||||
# A job-level env CAN see matrix, so the answer is computed once here and
|
||||
# every step below asks for it.
|
||||
env:
|
||||
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.flatpak_arch) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
- name: Install flatpak
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" --no-install-recommends flatpak ostree zip
|
||||
flatpak remote-add --if-not-exists --user flathub \
|
||||
https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install --user -y --noninteractive flathub \
|
||||
org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Fetch the release bundle for this architecture
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
|
||||
gh release download '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --pattern "$zip" --clobber
|
||||
unzip -q "$zip"
|
||||
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
|
||||
if [ ! -e "$f" ]; then
|
||||
echo "::error::$zip has no $f - it is not a runnable WeKan bundle."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
chmod +x bundle/node bundle/ferretdb bundle/start-wekan.sh
|
||||
du -sh bundle
|
||||
|
||||
# flatpak-builder is for building from source. The bundle IS the build, so
|
||||
# this uses `flatpak build` directly: make a build directory from the
|
||||
# runtime, copy the bundle into it, finish it with its permissions, and
|
||||
# export it into a repository.
|
||||
- name: Build the Flatpak
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version='${{ needs.release.outputs.version }}'
|
||||
arch='${{ matrix.flatpak_arch }}'
|
||||
|
||||
rm -rf build-dir repo
|
||||
flatpak build-init --arch="$arch" build-dir \
|
||||
io.github.wekan.WeKan org.freedesktop.Sdk org.freedesktop.Platform 24.08
|
||||
|
||||
mkdir -p build-dir/files/lib/wekan build-dir/files/bin \
|
||||
build-dir/files/share/applications \
|
||||
build-dir/files/share/icons/hicolor/512x512/apps \
|
||||
build-dir/files/share/metainfo
|
||||
cp -a bundle build-dir/files/lib/wekan/bundle
|
||||
cp public/android-chrome-512x512.png \
|
||||
build-dir/files/share/icons/hicolor/512x512/apps/io.github.wekan.WeKan.png
|
||||
|
||||
# The command the Flatpak runs. --persist=.wekan gives the sandbox one
|
||||
# writable directory that survives restarts, and that is where
|
||||
# WRITABLE_PATH points, so FerretDB's SQLite files and the attachments
|
||||
# live there. Everything start-wekan.sh documents still overrides.
|
||||
cat > build-dir/files/bin/wekan <<'SH'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
BUNDLE=/app/lib/wekan/bundle
|
||||
: "${WRITABLE_PATH:=$HOME/.wekan}"
|
||||
export WRITABLE_PATH
|
||||
: "${PORT:=8080}"
|
||||
export PORT
|
||||
: "${ROOT_URL:=http://localhost:$PORT}"
|
||||
export ROOT_URL
|
||||
mkdir -p "$WRITABLE_PATH"
|
||||
echo "WeKan is starting."
|
||||
echo " Open: $ROOT_URL"
|
||||
echo " Data: $WRITABLE_PATH (inside the Flatpak sandbox)"
|
||||
echo " Stop: Ctrl-C"
|
||||
echo
|
||||
exec "$BUNDLE/start-wekan.sh" "$@"
|
||||
SH
|
||||
chmod +x build-dir/files/bin/wekan
|
||||
|
||||
cat > build-dir/files/share/applications/io.github.wekan.WeKan.desktop <<'DESKTOP'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=WeKan
|
||||
GenericName=Kanban board
|
||||
Comment=Open Source kanban board with FerretDB and Node.js included
|
||||
Exec=wekan
|
||||
Icon=io.github.wekan.WeKan
|
||||
Terminal=true
|
||||
Categories=Office;ProjectManagement;
|
||||
Keywords=kanban;board;tasks;project;
|
||||
DESKTOP
|
||||
|
||||
cat > build-dir/files/share/metainfo/io.github.wekan.WeKan.metainfo.xml <<XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.wekan.WeKan</id>
|
||||
<name>WeKan</name>
|
||||
<summary>Open Source kanban board</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<description>
|
||||
<p>
|
||||
WeKan is an Open Source kanban board. This build carries everything
|
||||
it needs to run: the WeKan server, Node.js, and FerretDB v1 with an
|
||||
embedded SQLite backend, so no separate database is required.
|
||||
</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">io.github.wekan.WeKan.desktop</launchable>
|
||||
<url type="homepage">https://wekan.fi</url>
|
||||
<releases><release version="${version}"/></releases>
|
||||
</component>
|
||||
XML
|
||||
|
||||
# A server reached in a browser needs the network and its own data
|
||||
# directory, and nothing else.
|
||||
flatpak build-finish build-dir \
|
||||
--share=network \
|
||||
--persist=.wekan \
|
||||
--command=wekan
|
||||
|
||||
flatpak build-export --arch="$arch" repo build-dir stable
|
||||
|
||||
- name: Make the single-file bundle
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version='${{ needs.release.outputs.version }}'
|
||||
name="WeKan-${version}-${{ matrix.flatpak_arch }}"
|
||||
mkdir -p dist
|
||||
flatpak build-bundle repo "dist/${name}.flatpak" \
|
||||
--arch='${{ matrix.flatpak_arch }}' io.github.wekan.WeKan stable
|
||||
# Checksum keeps the .flatpak extension in its name, matching the zip
|
||||
# convention and what releases/expected-assets.sh looks for; dropping it
|
||||
# meant "what is missing" never found the checksum and rebuilt the
|
||||
# Flatpak every run even when already published.
|
||||
( cd dist
|
||||
md5sum "${name}.flatpak" > "${name}.flatpak.md5sum"
|
||||
sha256sum "${name}.flatpak" > "${name}.flatpak.sha256sum" )
|
||||
ls -lh dist
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: flatpak-${{ matrix.flatpak_arch }}
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: flatpak-repo-${{ matrix.flatpak_arch }}
|
||||
path: repo/
|
||||
if-no-files-found: warn
|
||||
|
||||
publish:
|
||||
timeout-minutes: 60
|
||||
name: attach to the release, and build the repository
|
||||
needs: [release, build]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
# FIRST: this job runs releases/apt-install.sh, and actions/checkout cleans
|
||||
# the workspace - so it has to happen before the artifacts are downloaded
|
||||
# into it. Without the checkout the step fails with "No such file or
|
||||
# directory", which is how the v10.88 run ended.
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: flatpak-[0-9a-z]*
|
||||
path: assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Say what was built, and what was not
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -lh assets 2>/dev/null || true
|
||||
if ! ls assets/*.flatpak >/dev/null 2>&1; then
|
||||
echo "::error::No architecture produced a Flatpak bundle; nothing is uploaded."
|
||||
exit 1
|
||||
fi
|
||||
for want in x86_64 aarch64; do
|
||||
ls assets/*"-$want.flatpak" >/dev/null 2>&1 || \
|
||||
echo "::warning::no Flatpak for $want - see that build job's log"
|
||||
done
|
||||
|
||||
- name: Upload the bundles
|
||||
if: ${{ inputs.publish != false }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# --repo, because this job does NOT check the repository out - it
|
||||
# only downloads artifacts - so `gh` has no git remote to infer the
|
||||
# repo from and fails with "fatal: not a git repository". The same
|
||||
# line was wrong in AppImage.yml for the same reason.
|
||||
#
|
||||
# And the bundles BY NAME, not assets/*: the artifacts also carry the
|
||||
# ostree repository the flatpak was exported through - config,
|
||||
# objects/, refs/, summaries/, summary, summary.idx - which is build
|
||||
# scaffolding, not something to attach to a release. TSC hit this
|
||||
# exact thing ("upload only the bundle files, not the ostree repo").
|
||||
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' \
|
||||
assets/*.flatpak assets/*.flatpak.md5sum assets/*.flatpak.sha256sum \
|
||||
--repo "${{ github.repository }}" --clobber
|
||||
echo "Uploaded to $(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --json url --jq .url)"
|
||||
|
||||
# ---- the repository -------------------------------------------------
|
||||
- uses: actions/download-artifact@v8
|
||||
if: ${{ inputs.publish_repo != false }}
|
||||
with:
|
||||
pattern: flatpak-repo-*
|
||||
path: repos
|
||||
|
||||
- name: Build the multi-architecture repository
|
||||
if: ${{ inputs.publish_repo != false }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" --no-install-recommends flatpak ostree
|
||||
shopt -s nullglob
|
||||
parts=(repos/flatpak-repo-*)
|
||||
if [ ${#parts[@]} -eq 0 ]; then
|
||||
echo "::warning::no repository halves were produced; skipping"
|
||||
exit 0
|
||||
fi
|
||||
ostree init --repo=flatpak-repo --mode=archive-z2
|
||||
for part in "${parts[@]}"; do
|
||||
name="$(basename "$part")"
|
||||
echo "Pulling $name"
|
||||
ostree --repo=flatpak-repo remote add --no-gpg-verify --if-not-exists \
|
||||
"$name" "file://$PWD/$part"
|
||||
ostree --repo=flatpak-repo pull --mirror "$name" || true
|
||||
done
|
||||
flatpak build-update-repo --prune flatpak-repo
|
||||
|
||||
cat > flatpak-repo/wekan.flatpakrepo <<'REPO'
|
||||
[Flatpak Repo]
|
||||
Title=WeKan
|
||||
Url=https://wekan.fi/flatpak/
|
||||
Homepage=https://wekan.fi
|
||||
Comment=WeKan releases, built by the Flatpak workflow in wekan/wekan
|
||||
Description=Flatpak repository for WeKan, the Open Source kanban board
|
||||
REPO
|
||||
|
||||
cat > flatpak-repo/README.txt <<'TXT'
|
||||
Flatpak repository for WeKan.
|
||||
|
||||
Copy this whole directory to the website under /flatpak/, then:
|
||||
|
||||
flatpak remote-add --if-not-exists wekan \
|
||||
https://wekan.fi/flatpak/wekan.flatpakrepo
|
||||
flatpak install wekan io.github.wekan.WeKan
|
||||
flatpak run io.github.wekan.WeKan
|
||||
|
||||
Then open http://localhost:8080 . Everything is inside: the WeKan
|
||||
server, Node.js, and FerretDB v1 with an embedded SQLite backend, so
|
||||
there is no database to install. The data lives in the sandbox's
|
||||
persistent .wekan directory.
|
||||
|
||||
THIS REPOSITORY IS NOT SIGNED. Signing needs a GPG key, which is a
|
||||
secret rather than something a workflow can invent. To sign it:
|
||||
|
||||
1. Make a key, once, on a machine you trust:
|
||||
gpg --quick-generate-key "WeKan <security@wekan.fi>" default default never
|
||||
gpg --export KEYID > wekan-flatpak.gpg # the public half
|
||||
gpg --export-secret-keys --armor KEYID # the private half
|
||||
2. Put the private half in the repository's Actions secrets as
|
||||
GPG_PRIVATE_KEY.
|
||||
3. In Flatpak.yml, import it before the export and add the two flags:
|
||||
flatpak build-export --gpg-sign=KEYID ...
|
||||
flatpak build-update-repo --gpg-sign=KEYID ...
|
||||
4. Put wekan-flatpak.gpg beside this file on the website and add
|
||||
GPGKey=<base64 of wekan-flatpak.gpg>
|
||||
to wekan.flatpakrepo.
|
||||
|
||||
Until then flatpak will say the remote is unsigned, and installing
|
||||
from it trusts whoever serves the files.
|
||||
TXT
|
||||
du -sh flatpak-repo
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ inputs.publish_repo != false }}
|
||||
with:
|
||||
name: flatpak-repository
|
||||
path: flatpak-repo/
|
||||
if-no-files-found: warn
|
||||
@@ -1,15 +0,0 @@
|
||||
name: 'Dependency Review'
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v7
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v5
|
||||
@@ -1,94 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '28 23 * * *'
|
||||
push:
|
||||
branches: [ main ]
|
||||
# Publish semver tags as releases.
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 180
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# The Dockerfile DOWNLOADS the prebuilt release zip that release-all.yml
|
||||
# publishes (and `exit 8`s if it is missing), so this image can only be
|
||||
# built once the release for the version pinned in the Dockerfile is up. On
|
||||
# a tag push (the tag is created early in the release, ~30 min before the
|
||||
# zips are attached), during the release window, or if the release failed,
|
||||
# the asset 404s and this job failed hard. Check first and SKIP the build
|
||||
# (a notice, green run) when the release is not published yet; the release
|
||||
# image itself is built + pushed by release-all.yml's docker job.
|
||||
- name: Is the release this Dockerfile needs published yet?
|
||||
id: rel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(sed -n 's/^ARG VERSION=//p' Dockerfile | head -1)"
|
||||
asset="wekan-${VERSION}-amd64.zip"
|
||||
if gh release view "v${VERSION}" --repo "${{ github.repository }}" \
|
||||
--json assets --jq '.assets[].name' 2>/dev/null | grep -qx "$asset"; then
|
||||
echo "build=yes" >> "$GITHUB_OUTPUT"
|
||||
echo "OK: $asset is on the v${VERSION} release; building the image."
|
||||
else
|
||||
echo "build=no" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$asset is not on the v${VERSION} release yet - skipping. release-all.yml builds and pushes the image once the release is published."
|
||||
fi
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
|
||||
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
|
||||
if: steps.rel.outputs.build == 'yes'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Full build provenance (SLSA), not the minimal default that BuildKit
|
||||
# attaches on its own. See release-all.yml for what it is and why the
|
||||
# manifest list gains an `unknown/unknown` entry per platform.
|
||||
provenance: mode=max
|
||||
@@ -1,160 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Publish a variant Docker image — MANUALLY, never as part of a release.
|
||||
#
|
||||
# wekanteam/wekan-gantt-gpl (and wekanteam/wekan-ondra) are the same WeKan as
|
||||
# wekanteam/wekan: the variant repositories are byte-identical to wekan/wekan
|
||||
# apart from the snap name in snapcraft.yaml, so the image content is the same
|
||||
# image. This workflow therefore does not rebuild anything - it points a variant
|
||||
# tag at the multi-arch manifest the release already built and verified, with
|
||||
# `docker buildx imagetools create`. Same digests, all five architectures, no
|
||||
# half-hour emulated build, and no way for the variant image to drift from the
|
||||
# release it claims to be.
|
||||
#
|
||||
# A RELEASE NO LONGER NEEDS THIS. release-all.yml's docker job tags both variants
|
||||
# on all three registries in its own `docker buildx build --push`, so every
|
||||
# release publishes them with the release's own digests. What this file is for now
|
||||
# is everything outside a release: publishing a variant for a version whose
|
||||
# release predates that change, repairing a tag, or pointing a variant at a
|
||||
# different version deliberately.
|
||||
#
|
||||
# `workflow_dispatch` ONLY, on purpose: nothing here runs on a push, a tag or a
|
||||
# schedule.
|
||||
#
|
||||
# If a variant repository ever stops being identical to wekan/wekan, this
|
||||
# retag becomes wrong: build that repository's own Dockerfile instead.
|
||||
#
|
||||
# Local equivalent, same behaviour: releases/docker-publish-variant.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Publish variant Docker image (manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image:
|
||||
description: 'Docker Hub repository under wekanteam/ to publish'
|
||||
required: true
|
||||
default: 'wekan-gantt-gpl'
|
||||
type: choice
|
||||
options:
|
||||
- wekan-gantt-gpl
|
||||
- wekan-ondra
|
||||
version:
|
||||
description: 'WeKan version to publish, without the v (e.g. 10.49). Empty = newest GitHub release.'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
also_latest:
|
||||
description: 'Also move the :latest tag of the variant image'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
retag:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# First step on purpose: a missing or wrong credential is then ONE named
|
||||
# line at the top of the log, not a failure deep inside a push.
|
||||
- name: Check DOCKERHUB_AUTH is configured and works
|
||||
env:
|
||||
DOCKERHUB_AUTH: ${{ secrets.DOCKERHUB_AUTH }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${DOCKERHUB_AUTH:-}" ]; then
|
||||
echo "::error::docker-variant: DOCKERHUB_AUTH is not set - the image cannot be pushed. It is base64 of 'username:token' for docker.io."
|
||||
exit 1
|
||||
fi
|
||||
if ! creds="$(printf '%s' "$DOCKERHUB_AUTH" | base64 -d 2>/dev/null)"; then
|
||||
echo "::error::docker-variant: DOCKERHUB_AUTH is not valid base64. Set it with: printf '%s' 'user:token' | base64 -w0"
|
||||
exit 1
|
||||
fi
|
||||
case "$creds" in
|
||||
*:*) ;;
|
||||
*) echo "::error::docker-variant: DOCKERHUB_AUTH decodes to something without a ':' - it must be base64 of 'username:token'."; exit 1 ;;
|
||||
esac
|
||||
user="${creds%%:*}"
|
||||
token="${creds#*:}"
|
||||
if ! printf '%s' "$token" | docker login docker.io -u "$user" --password-stdin; then
|
||||
echo "::error::docker-variant: DOCKERHUB_AUTH was REJECTED by docker.io (login failed as user '$user'). The token is wrong, expired, or lacks push rights for wekanteam/${{ inputs.image }}."
|
||||
exit 1
|
||||
fi
|
||||
echo "docker-variant: DOCKERHUB_AUTH works for docker.io (user '$user')."
|
||||
|
||||
- name: Work out which version to publish
|
||||
id: v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ inputs.version }}"
|
||||
if [ -z "$version" ]; then
|
||||
# The newest release of wekan/wekan, asked of GitHub rather than guessed.
|
||||
version="$(curl -sSL -H 'Accept: application/vnd.github+json' \
|
||||
https://api.github.com/repos/wekan/wekan/releases/latest \
|
||||
| sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -1)"
|
||||
fi
|
||||
version="${version#v}"
|
||||
if [ -z "$version" ]; then
|
||||
echo "::error::docker-variant: no version given and the newest release could not be read from the GitHub API."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing wekanteam/${{ inputs.image }} from wekanteam/wekan:v$version"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Retag wekanteam/wekan as wekanteam/${{ inputs.image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="wekanteam/wekan:v${{ steps.v.outputs.version }}"
|
||||
dst="wekanteam/${{ inputs.image }}:v${{ steps.v.outputs.version }}"
|
||||
|
||||
# The source must exist and be the full multi-arch manifest, or the
|
||||
# variant tag would quietly cover fewer architectures than wekan does.
|
||||
if ! platforms="$(docker buildx imagetools inspect "$src" \
|
||||
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}' 2>&1)"; then
|
||||
echo "::error::docker-variant: $src cannot be inspected - is that version released and pushed? ($platforms)"
|
||||
exit 1
|
||||
fi
|
||||
echo "$src covers: $platforms"
|
||||
for want in linux/amd64 linux/arm64 linux/ppc64le linux/s390x linux/riscv64; do
|
||||
case " $platforms " in
|
||||
*" $want "*) ;;
|
||||
*) echo "::error::docker-variant: $src has no $want, so the variant image would be missing it too."; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
tags=(-t "$dst")
|
||||
if [ "${{ inputs.also_latest }}" = "true" ]; then
|
||||
tags+=(-t "wekanteam/${{ inputs.image }}:latest")
|
||||
fi
|
||||
# imagetools create copies the MANIFEST, so the variant tag points at the
|
||||
# very digests the release built and verified. Nothing is rebuilt.
|
||||
docker buildx imagetools create "${tags[@]}" "$src"
|
||||
|
||||
# Pushed is not the same as pushed correctly: ask Docker Hub what it has.
|
||||
got="$(docker buildx imagetools inspect "$dst" \
|
||||
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}')"
|
||||
echo "$dst covers: $got"
|
||||
for want in linux/amd64 linux/arm64 linux/ppc64le linux/s390x linux/riscv64; do
|
||||
case " $got " in
|
||||
*" $want "*) ;;
|
||||
*) echo "::error::docker-variant: $dst is missing $want after the push."; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
echo "OK: $dst published (same digests as $src)."
|
||||
|
||||
- name: Job result
|
||||
if: always()
|
||||
run: |
|
||||
case "${{ job.status }}" in
|
||||
success) mark='OK ' ;;
|
||||
failure) mark='FAILED ' ;;
|
||||
cancelled) mark='CANCELLED' ;;
|
||||
*) mark='${{ job.status }}' ;;
|
||||
esac
|
||||
echo "$mark docker-variant wekanteam/${{ inputs.image }}"
|
||||
echo "$mark docker-variant wekanteam/${{ inputs.image }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
timeout-minutes: 180
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# The Dockerfile does not build WeKan from source — it DOWNLOADS the prebuilt
|
||||
# release zip (wekan-<VERSION>-amd64.zip) that release-all.yml publishes, and
|
||||
# `exit 8`s if it is missing. So this image can only be built once the release
|
||||
# for the version pinned in the Dockerfile is up. On a push during the release
|
||||
# window (the version was just bumped but the release job has not attached the
|
||||
# zip yet), or if the release failed, the asset 404s and this job failed hard
|
||||
# even though nothing here is wrong. Check first and SKIP (a notice, green run)
|
||||
# when the release is not published yet; the real image is built by
|
||||
# release-all.yml's docker job once the release is up.
|
||||
- name: Is the release this Dockerfile needs published yet?
|
||||
id: rel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(sed -n 's/^ARG VERSION=//p' Dockerfile | head -1)"
|
||||
asset="wekan-${VERSION}-amd64.zip"
|
||||
if gh release view "v${VERSION}" --repo "${{ github.repository }}" \
|
||||
--json assets --jq '.assets[].name' 2>/dev/null | grep -qx "$asset"; then
|
||||
echo "build=yes" >> "$GITHUB_OUTPUT"
|
||||
echo "OK: $asset is on the v${VERSION} release; building the image."
|
||||
else
|
||||
echo "build=no" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$asset is not on the v${VERSION} release yet - skipping. release-all.yml builds the image once the release is published."
|
||||
fi
|
||||
|
||||
- name: Build the Docker image
|
||||
if: steps.rel.outputs.build == 'yes'
|
||||
run: docker build . --file Dockerfile --tag wekan:$(date +%s)
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Build meteor-spk bundle
|
||||
|
||||
# Manually triggered. Assembles WeKan's modernized meteor-spk (the Sandstorm packaging
|
||||
# tool + its meteor-spk.deps: Node 24, FerretDB, the Mongo 3.x CLIs and the modern
|
||||
# sandstorm-http-bridge) and zips it as meteor-spk-YYYY-MM-DD_HH_MM_SS.zip — the same kind
|
||||
# of self-contained meteor-spk bundle that https://github.com/sandstorm-io/meteor-spk
|
||||
# releases, but with WeKan's Node 24 / FerretDB deps baked in. Download it to `meteor-spk
|
||||
# pack` WeKan (or any Meteor 3.x app) without re-running build-deps.sh each time.
|
||||
#
|
||||
# gh workflow run meteor-spk.yml
|
||||
#
|
||||
# This only assembles + zips the tool; it does NOT pack or sign a WeKan .spk, so it needs
|
||||
# no SANDSTORM_KEYRING. The .spk itself is built by sandstorm.yml; the Sandstorm App
|
||||
# Market publish is done by sandstorm-store.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch, tag or SHA to build the meteor-spk deps from (default: main)'
|
||||
required: false
|
||||
default: 'main'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
meteor-spk:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || 'main' }}
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
|
||||
# Same as sandstorm.yml: Ubuntu 24.04's AppArmor blocks the unprivileged user
|
||||
# namespaces the Sandstorm install relies on; relax it so the install (whose
|
||||
# sandstorm-http-bridge build-deps.sh copies in) can run on the runner.
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git zip
|
||||
|
||||
- name: Install Sandstorm (dev, unattended)
|
||||
# build-deps.sh's [bridge] step copies the modern
|
||||
# /opt/sandstorm/latest/bin/sandstorm-http-bridge into the deps, so Sandstorm
|
||||
# must be installed first — same ordering as sandstorm.yml.
|
||||
run: curl https://install.sandstorm.io | sudo bash -s -- -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (Node 24 + FerretDB + Mongo 3.x CLIs)
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io; build-deps.sh swaps in
|
||||
# Node 24, FerretDB, the Mongo 3.x CLIs, the migration launcher/importer, keeps
|
||||
# niscud, and swaps in the modern sandstorm-http-bridge. Produces
|
||||
# $HOME/projects/meteor-spk/meteor-spk-0.6.0 (the meteor-spk tool + deps).
|
||||
run: bash sandstorm-src/build-deps.sh
|
||||
|
||||
- name: Zip the meteor-spk bundle
|
||||
id: zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STAMP="$(date +'%Y-%m-%d_%H_%M_%S')"
|
||||
ZIP="meteor-spk-${STAMP}.zip"
|
||||
BUNDLE_DIR="$HOME/projects/meteor-spk"
|
||||
if [ ! -d "$BUNDLE_DIR/meteor-spk-0.6.0" ]; then
|
||||
echo "::error::Expected meteor-spk bundle at $BUNDLE_DIR/meteor-spk-0.6.0 was not produced by build-deps.sh."
|
||||
exit 1
|
||||
fi
|
||||
# Zip so it extracts to a top-level meteor-spk-0.6.0/ directory, like the
|
||||
# upstream meteor-spk release archives. Exclude the base tarball build-deps.sh
|
||||
# downloaded next to it.
|
||||
( cd "$BUNDLE_DIR" && zip -r -y -q "$GITHUB_WORKSPACE/$ZIP" meteor-spk-0.6.0 -x '*.tar.xz' )
|
||||
ZIP_BYTES=$(stat -c%s "$GITHUB_WORKSPACE/$ZIP")
|
||||
echo "Packed $ZIP: $((ZIP_BYTES/1024/1024)) MiB ($ZIP_BYTES bytes)"
|
||||
ls -la "$GITHUB_WORKSPACE/$ZIP"
|
||||
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.zip.outputs.zip }}
|
||||
path: ${{ steps.zip.outputs.zip }}
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload the meteor-spk bundle to the newest WeKan release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
|
||||
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
|
||||
echo "::warning::No existing GitHub Release found; the meteor-spk bundle is available only as a workflow artifact."
|
||||
exit 0
|
||||
fi
|
||||
echo "Uploading ${{ steps.zip.outputs.zip }} to the newest release: $TAG"
|
||||
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${{ steps.zip.outputs.zip }}" --clobber
|
||||
@@ -1,298 +0,0 @@
|
||||
name: Playwright E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: playwright-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Meteor unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-unit-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-unit-
|
||||
|
||||
- name: Install Meteor
|
||||
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
|
||||
# NOTE: do not set TEST_WATCH here. meteortesting:mocha computes
|
||||
# `testWatch: TEST_WATCH || …`, and the string '0' is truthy in JS, so
|
||||
# TEST_WATCH=0 turns ON watch mode and the process never exits — that is
|
||||
# what made this job hang until the 45-minute timeout. With TEST_WATCH
|
||||
# unset, `meteor test --once` runs the server tests and exits with the
|
||||
# correct status (matching the local build.sh run).
|
||||
run: meteor test --once --driver-package meteortesting:mocha
|
||||
|
||||
e2e-tests:
|
||||
name: Playwright E2E (${{ matrix.project }} ${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Playwright bundles Chromium, Firefox and WebKit builds that all run
|
||||
# headless on the Linux runner (installed per-job below with --with-deps).
|
||||
project: [chromium, firefox, webkit]
|
||||
# Split each browser's ~245 specs across 2 parallel shards (6 jobs total)
|
||||
# to cut wall-clock time. Each shard job starts its own WeKan + Mongo, so
|
||||
# there is no shared-server contention between shards. Raise the shard
|
||||
# count (here and in the --shard flag below) once the baseline is green
|
||||
# and/or once E2E runs against a prebuilt bundle (cheaper per-job startup).
|
||||
shard: [1, 2]
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
ports: ['27017:27017']
|
||||
options: >-
|
||||
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-e2e-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-e2e-
|
||||
|
||||
- name: Install Meteor
|
||||
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
|
||||
|
||||
- name: Install Playwright test dependencies
|
||||
run: npm ci
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps ${{ matrix.project }}
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Build and start WeKan (production bundle)
|
||||
run: |
|
||||
# Run E2E against a PRODUCTION build instead of `meteor run` (dev mode).
|
||||
# In dev the client JS is served by the rspack dev server, which did not
|
||||
# serve the bundle reliably in headless CI: the browser received HTML for
|
||||
# the bundle ("Unexpected token '<'"), Meteor never initialised, and every
|
||||
# spec timed out. A production build bakes the client JS into the bundle,
|
||||
# served statically by the Meteor server at :3000.
|
||||
meteor build ./output --directory
|
||||
( cd output/bundle/programs/server && npm install )
|
||||
# WeKan's server/00checkStartup.js refuses to start unless WRITABLE_PATH
|
||||
# (used for attachment/file storage) is set and writable.
|
||||
mkdir -p "$PWD/wekan-writable"
|
||||
# Match the env the dev runner (build.sh) and the specs expect:
|
||||
# WITH_API enables the REST API (specs 17/23/24/28), comment-editor
|
||||
# flag matches dev.
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_test \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
WRITABLE_PATH="$PWD/wekan-writable" \
|
||||
WITH_API=true \
|
||||
RICHER_CARD_COMMENT_EDITOR=false \
|
||||
node output/bundle/main.js > wekan-run.log 2>&1 &
|
||||
# The production server serves the client JS statically, so once :3000
|
||||
# responds the bundle is being served. Wait up to 10 minutes.
|
||||
for i in $(seq 1 60); 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/60"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 250 log lines:"
|
||||
tail -n 250 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
TOOL_NODE_FLAGS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.project }} shard ${{ matrix.shard }}/2)
|
||||
run: |
|
||||
npx playwright test \
|
||||
--project=${{ matrix.project }} \
|
||||
--shard=${{ matrix.shard }}/2 \
|
||||
--reporter=github
|
||||
working-directory: tests/playwright
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
|
||||
# Make the firefox and webkit projects available to --project
|
||||
# (playwright.config.js only defines them when this is set).
|
||||
WEKAN_PLAYWRIGHT_ALL: '1'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report-${{ matrix.project }}-shard${{ matrix.shard }}
|
||||
path: tests/playwright/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload test results (screenshots / videos)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-${{ matrix.project }}-shard${{ matrix.shard }}
|
||||
path: tests/playwright/test-results/
|
||||
retention-days: 7
|
||||
|
||||
e2e-regression:
|
||||
name: Puppeteer regression suite
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
ports: ['27017:27017']
|
||||
options: >-
|
||||
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-regression-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-regression-
|
||||
|
||||
- name: Install Meteor
|
||||
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: Install Chromium
|
||||
run: sudo apt-get install -y chromium-browser
|
||||
|
||||
- name: Build and start WeKan (production bundle)
|
||||
run: |
|
||||
# Run a PRODUCTION build, not `meteor run` (dev): exactly like the
|
||||
# Playwright e2e job, because the rspack dev server does not serve the
|
||||
# client JS bundle in headless CI, so Meteor never initialises and the
|
||||
# puppeteer regression test times out in waitForMeteorGlobals(). The
|
||||
# production bundle serves the client statically. WRITABLE_PATH is
|
||||
# required by server/00checkStartup.js; WITH_API mirrors the dev runner.
|
||||
meteor build ./output --directory
|
||||
( cd output/bundle/programs/server && npm install )
|
||||
mkdir -p "$PWD/wekan-writable"
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_regression \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
WRITABLE_PATH="$PWD/wekan-writable" \
|
||||
WITH_API=true \
|
||||
RICHER_CARD_COMMENT_EDITOR=false \
|
||||
node output/bundle/main.js > wekan-run.log 2>&1 &
|
||||
for i in $(seq 1 60); 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/60"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 250 log lines:"
|
||||
tail -n 250 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
TOOL_NODE_FLAGS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run list-regressions e2e
|
||||
run: node tests/e2e/list-regressions.js
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_regression
|
||||
CHROMIUM_PATH: /usr/bin/chromium-browser
|
||||
HEADLESS: 'true'
|
||||
@@ -1,437 +0,0 @@
|
||||
name: Release all missing
|
||||
|
||||
# Build only the bundles a release does not already carry.
|
||||
#
|
||||
# WHY: release-all.yml is one run that bumps versions, tags, builds every
|
||||
# platform, publishes Docker images, snaps, the website and the charts. It is
|
||||
# the right thing for MAKING a release and the wrong thing for FINISHING one.
|
||||
# When the v10.57 run ended with the six extra architectures missing - four of
|
||||
# them on a shell-quoting bug and two on a base image - there was no way to get
|
||||
# those six without running the whole thing again, version bump and all.
|
||||
#
|
||||
# This finishes a release that already exists. It never bumps a version, never
|
||||
# tags, and never touches Docker, snap or the website.
|
||||
#
|
||||
# It DOES publish the Helm chart, because that is a thing a release can be
|
||||
# missing like any other: the chart entry is written once, during the release it
|
||||
# belongs to, so a release whose charts job did not run leaves a hole that the
|
||||
# next release never goes back for - v10.80 is one of those, and it was not
|
||||
# alone. See the charts job at the end.
|
||||
#
|
||||
# WHAT IT CAN BUILD, and why that is not everything. Only build-amd64 runs
|
||||
# Meteor; every other bundle WeKan ships is that same bundle repacked. This job
|
||||
# downloads the PUBLISHED wekan-<v>-amd64.zip and repacks it, so it can rebuild:
|
||||
#
|
||||
# the six extra architectures s390x ppc64le riscv64 i386 armhf loong64
|
||||
# the AppImages and Flatpaks by calling their own workflows
|
||||
#
|
||||
# It cannot rebuild amd64 itself (that is the Meteor build), nor arm64, win64
|
||||
# and mac-arm64 (each needs its own kind of runner and its own steps), nor the
|
||||
# Sandstorm .spk. When one of those is missing it says so and names
|
||||
# release-all.yml, rather than pretending.
|
||||
#
|
||||
# The repack is releases/repack-bundle-for-arch.sh - the SAME script
|
||||
# release-all.yml's build-extra-arches runs, so a bundle added to a release
|
||||
# months later is built exactly like the ones already on it.
|
||||
#
|
||||
# What a complete release looks like is releases/expected-assets.sh, in one
|
||||
# place. An asset counts as present only when its .sha256sum is there too; the
|
||||
# Sandstorm .spk is signed rather than checksummed and is marked nosums.
|
||||
#
|
||||
# Usage:
|
||||
# gh workflow run release-all-missing.yml -f version=10.58
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release to complete, without the v (e.g. 10.58). Empty = the newest release.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
# Must match release-all.yml: the Node.js major the bundles carry.
|
||||
NODE_VERSION: '24'
|
||||
|
||||
# Never at the same time as a full release run, which would be uploading to the
|
||||
# same assets.
|
||||
concurrency:
|
||||
group: wekan-release-${{ inputs.version || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
timeout-minutes: 15
|
||||
name: what the release is missing
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.plan.outputs.version }}
|
||||
extra: ${{ steps.plan.outputs.extra }}
|
||||
appimage: ${{ steps.plan.outputs.appimage }}
|
||||
flatpak: ${{ steps.plan.outputs.flatpak }}
|
||||
windows: ${{ steps.plan.outputs.windows }}
|
||||
manual: ${{ steps.plan.outputs.manual }}
|
||||
total: ${{ steps.plan.outputs.total }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: plan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
V='${{ inputs.version }}'
|
||||
V="${V#v}"
|
||||
if [ -z "$V" ]; then
|
||||
V="$(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" --json tagName --jq .tagName)"
|
||||
V="${V#v}"
|
||||
echo "No version given; the newest release is v$V."
|
||||
fi
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v$V" >/dev/null 2>&1; then
|
||||
gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v$V" --json assets --jq '.assets[].name' | sort > existing.txt
|
||||
else
|
||||
echo "::error::There is no release v$V to complete. This workflow FINISHES a release; release-all.yml makes one."
|
||||
exit 1
|
||||
fi
|
||||
echo "v$V carries $(wc -l < existing.txt | tr -d ' ') asset(s)."
|
||||
|
||||
bash releases/expected-assets.sh "$V" > expected.txt
|
||||
|
||||
: > missing.txt
|
||||
present=0
|
||||
while read -r kind selector asset sums; do
|
||||
need="$asset"
|
||||
[ "$sums" = "sums" ] && need="$need ${asset}.sha256sum"
|
||||
have=yes
|
||||
for n in $need; do grep -qxF "$n" existing.txt || have=no; done
|
||||
if [ "$have" = yes ]; then present=$((present + 1)); else echo "$kind $selector" >> missing.txt; fi
|
||||
done < expected.txt
|
||||
|
||||
total="$(wc -l < missing.txt | tr -d ' ')"
|
||||
echo "total=$total" >> "$GITHUB_OUTPUT"
|
||||
|
||||
for kind in extra appimage flatpak windows; do
|
||||
sels="$(awk -v k="$kind" '$1 == k { print $2 }' missing.txt)"
|
||||
json="$(printf '%s\n' $sels | sed '/^$/d' | jq -R . | jq -sc .)"
|
||||
[ -n "$json" ] || json='[]'
|
||||
echo "$kind=$json" >> "$GITHUB_OUTPUT"
|
||||
echo " $kind: $json"
|
||||
done
|
||||
|
||||
# The ones this workflow cannot make. Named, not silently skipped.
|
||||
manual="$(awk '$1 == "meteor" || $1 == "repack" || $1 == "sandstorm" { printf "%s/%s ", $1, $2 }' missing.txt)"
|
||||
echo "manual=${manual% }" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "### v$V"
|
||||
echo ""
|
||||
echo "$present of $((present + total)) assets already published."
|
||||
echo ""
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "Nothing is missing - the release is complete."
|
||||
else
|
||||
echo "Missing:"
|
||||
echo ""
|
||||
echo '```'
|
||||
cat missing.txt
|
||||
echo '```'
|
||||
if [ -n "$manual" ]; then
|
||||
echo ""
|
||||
echo "Not buildable here (run release-all.yml): \`$manual\`"
|
||||
fi
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Missing $total. Needs release-all.yml: ${manual:-none}"
|
||||
|
||||
- name: Say plainly what this run will not do
|
||||
if: ${{ steps.plan.outputs.manual != '' }}
|
||||
run: |
|
||||
echo "::warning::v${{ steps.plan.outputs.version }} is missing ${{ steps.plan.outputs.manual }}, which this workflow cannot build: amd64 is the Meteor build, arm64/win64/mac-arm64 each need their own runner, and the Sandstorm .spk is signed. Run release-all.yml for those."
|
||||
|
||||
# The six architectures nobody else builds for, repacked from the PUBLISHED
|
||||
# amd64 bundle - which is what makes this possible without a Meteor build.
|
||||
extra-arches:
|
||||
name: ${{ matrix.arch }}
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.extra != '[]' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 300
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The same table as release-all.yml's build-extra-arches, and it has
|
||||
# to stay the same: `node_arch` is what the Node.js download is
|
||||
# called (Node says x86 where we say i386, armv7l where we say
|
||||
# armhf), `ferretdb_arch` is the asset name in the wekan/FerretDB
|
||||
# release, and `image` must be a base image that PUBLISHES this
|
||||
# platform - ubuntu:26.04 does not build 386, debian:trixie does.
|
||||
- { arch: s390x, platform: linux/s390x, image: debian:trixie, node_arch: s390x, ferretdb_arch: s390x }
|
||||
- { arch: ppc64le, platform: linux/ppc64le, image: debian:trixie, node_arch: ppc64le, ferretdb_arch: ppc64le }
|
||||
- { arch: riscv64, platform: linux/riscv64, image: debian:trixie, node_arch: riscv64, ferretdb_arch: riscv64 }
|
||||
- { arch: i386, platform: linux/386, image: debian:trixie, node_arch: x86, ferretdb_arch: i386 }
|
||||
- { arch: armhf, platform: linux/arm/v7, image: debian:trixie, node_arch: armv7l, ferretdb_arch: armhf }
|
||||
# loong64 has NO base image on Docker Hub at any tag, so there is no
|
||||
# loong64 userland to rebuild the native modules in. Its Node.js and
|
||||
# its FerretDB both exist; the container does not. The preflight
|
||||
# inside the script stops it saying exactly that, and it stays in the
|
||||
# matrix so it is visible rather than quietly absent.
|
||||
- { arch: loong64, platform: linux/loong64, image: debian:trixie, node_arch: loong64, ferretdb_arch: loong64 }
|
||||
env:
|
||||
VERSION: ${{ needs.plan.outputs.version }}
|
||||
steps:
|
||||
- name: Skip the architectures this release already has
|
||||
id: want
|
||||
run: |
|
||||
if printf '%s' '${{ needs.plan.outputs.extra }}' | jq -e --arg a '${{ matrix.arch }}' 'index($a)' >/dev/null; then
|
||||
echo "build=yes" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "build=no" >> "$GITHUB_OUTPUT"
|
||||
echo "v${VERSION} already has ${{ matrix.arch }}; nothing to do."
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
run: |
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" curl unzip zip
|
||||
|
||||
- name: Set up QEMU for ${{ matrix.arch }} emulation
|
||||
if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
# Before the download, because actions/checkout cleans the workspace. if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
|
||||
# THE DIFFERENCE from release-all.yml, and the whole point of this
|
||||
# workflow: the amd64 bundle comes from the RELEASE, not from an artifact
|
||||
# of a build that is not running. Its checksum is verified when the
|
||||
# release has one - a bundle every other architecture is cut from is worth
|
||||
# checking before it is cut.
|
||||
- name: Download the published amd64 bundle
|
||||
if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip="wekan-${VERSION}-amd64.zip"
|
||||
gh release download "v${VERSION}" --pattern "$zip" --clobber
|
||||
if gh release download "v${VERSION}" --pattern "${zip}.sha256sum" --clobber 2>/dev/null; then
|
||||
sha256sum -c "${zip}.sha256sum"
|
||||
echo "amd64 bundle checksum verified."
|
||||
else
|
||||
echo "::warning::v${VERSION} publishes no ${zip}.sha256sum, so the bundle every other architecture is cut from could not be verified."
|
||||
fi
|
||||
unzip -q "$zip"
|
||||
[ -d bundle ] || { echo "::error::$zip did not contain a bundle/ directory."; exit 1; }
|
||||
rm -f "$zip" "${zip}.sha256sum"
|
||||
|
||||
- name: Repack it for ${{ matrix.arch }}
|
||||
if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
NODE_ARCH: ${{ matrix.node_arch }}
|
||||
FERRETDB_ARCH: ${{ matrix.ferretdb_arch }}
|
||||
IMAGE: ${{ matrix.image }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: bash releases/repack-bundle-for-arch.sh
|
||||
|
||||
- name: Attach ${{ matrix.arch }} to the release
|
||||
if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset="wekan-${VERSION}-${{ matrix.arch }}.zip"
|
||||
# No --clobber: this architecture was absent, so an upload that would
|
||||
# overwrite something means the release changed under this run.
|
||||
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v${VERSION}" "$asset" "${asset}.sha256sum"
|
||||
# Confirm from the other side. An upload that reports success and
|
||||
# leaves no asset behind is the failure nobody notices until a
|
||||
# download 404s.
|
||||
gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v${VERSION}" --json assets --jq '.assets[].name' > release-assets.txt
|
||||
grep -qxF "$asset" release-assets.txt || {
|
||||
echo "::error::$asset is not on release v${VERSION} although the upload reported success."
|
||||
exit 1
|
||||
}
|
||||
echo "OK: $asset attached ($(($(wc -c < "$asset")/1024/1024)) MiB)."
|
||||
|
||||
appimage:
|
||||
name: AppImages
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.appimage != '[]' }}
|
||||
uses: ./.github/workflows/AppImage.yml
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
tag: v${{ needs.plan.outputs.version }}
|
||||
publish: true
|
||||
only: ${{ needs.plan.outputs.appimage }}
|
||||
|
||||
flatpak:
|
||||
name: Flatpaks
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.flatpak != '[]' }}
|
||||
uses: ./.github/workflows/Flatpak.yml
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
tag: v${{ needs.plan.outputs.version }}
|
||||
publish: true
|
||||
only: ${{ needs.plan.outputs.flatpak }}
|
||||
|
||||
windows-exe:
|
||||
name: Windows single EXE
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.windows != '[]' }}
|
||||
uses: ./.github/workflows/windows.yml
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
tag: v${{ needs.plan.outputs.version }}
|
||||
publish: true
|
||||
|
||||
|
||||
# ─── The Helm chart, for a release that never got one ───────────────────────
|
||||
#
|
||||
# Everything else here rebuilds a missing BUNDLE; this publishes a missing
|
||||
# CHART, which goes missing the same way and for the same reason. A chart is
|
||||
# written once, by release-charts.sh, during the release it belongs to, so a
|
||||
# release whose charts job did not run has no chart and nothing ever goes back
|
||||
# for it. That is not rare - 146 of the packages already on gh-pages had no
|
||||
# index entry, and v10.80 has no package at all, because a cancelled
|
||||
# build-mac-x64 cancelled its run before the charts job started.
|
||||
#
|
||||
# It is the one part of this workflow that PUSHES to another repository, so it
|
||||
# is guarded twice: it does nothing unless a token that can push to
|
||||
# wekan/charts is configured, and nothing if the chart for this version is
|
||||
# already published. Re-publishing an existing version would re-tar the package
|
||||
# and change the digest of a chart people have already pulled.
|
||||
charts:
|
||||
timeout-minutes: 30
|
||||
name: Helm chart
|
||||
needs: plan
|
||||
if: ${{ !cancelled() && needs.plan.outputs.version != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan (for releases/release-charts.sh)
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: wekan
|
||||
|
||||
- name: Is a chart already published for this version?
|
||||
id: check
|
||||
env:
|
||||
V: ${{ needs.plan.outputs.version }}
|
||||
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${REPO_TOKEN:-}" ]; then
|
||||
echo "::warning::No WEKAN_REPO_TOKEN, so the Helm chart cannot be published. Add a token that can push to wekan/charts to have this finish the chart too."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# The published index is the authority on what exists, not this
|
||||
# checkout. An entry's own version is at EXACTLY four spaces; the
|
||||
# mongodb subchart's version sits deeper and would match a loose grep.
|
||||
bash wekan/releases/fetch.sh -o index.yaml https://wekan.github.io/charts/index.yaml || {
|
||||
echo "::warning::Could not read the published chart index; not touching the charts repo."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
# -F, a FIXED string: the dots in a version are regex wildcards
|
||||
# otherwise, so "10.80" would also match a line reading 10x80. -x so it
|
||||
# is the whole line, and the four-space indent is what makes it an
|
||||
# entry's own version rather than the mongodb subchart's, which sits
|
||||
# deeper.
|
||||
if grep -qxF " version: ${V}.0" index.yaml; then
|
||||
echo "::notice::v$V already has a published Helm chart (${V}.0); nothing to do."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "v$V has no published chart; publishing ${V}.0."
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
|
||||
- name: Checkout wekan/charts (main + gh-pages)
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/charts
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
fetch-depth: 0
|
||||
path: charts
|
||||
|
||||
- name: Install Helm
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: azure/setup-helm@v5
|
||||
|
||||
- name: Publish the chart for this release
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
CHARTS_DIR: ${{ github.workspace }}/charts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
git checkout main
|
||||
cd ..
|
||||
./wekan/releases/release-charts.sh "${{ needs.plan.outputs.version }}"
|
||||
|
||||
- name: Job result
|
||||
if: always()
|
||||
run: |
|
||||
case "${{ job.status }}" in
|
||||
success) mark='OK ' ;;
|
||||
failure) mark='FAILED ' ;;
|
||||
cancelled) mark='CANCELLED' ;;
|
||||
*) mark='${{ job.status }}' ;;
|
||||
esac
|
||||
echo "$mark charts" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
done:
|
||||
timeout-minutes: 15
|
||||
name: what is still missing
|
||||
needs: [plan, extra-arches, appimage, flatpak, charts]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
V: ${{ needs.plan.outputs.version }}
|
||||
BEFORE: ${{ needs.plan.outputs.total }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BEFORE" = "0" ]; then
|
||||
echo "::notice::v$V was already complete - nothing was built."
|
||||
exit 0
|
||||
fi
|
||||
gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v$V" --json assets --jq '.assets[].name' | sort > now.txt
|
||||
bash releases/expected-assets.sh "$V" > expected.txt
|
||||
still=""
|
||||
while read -r kind selector asset sums; do
|
||||
need="$asset"
|
||||
[ "$sums" = "sums" ] && need="$need ${asset}.sha256sum"
|
||||
for n in $need; do
|
||||
grep -qxF "$n" now.txt || { still="$still $kind/$selector"; break; }
|
||||
done
|
||||
done < expected.txt
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -z "$still" ]; then
|
||||
echo "v$V is complete."
|
||||
echo "**v$V is complete.**" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
# A warning, not a failure: what is left may be the parts this
|
||||
# workflow never claimed to build.
|
||||
echo "::warning::v$V is still missing:${still}"
|
||||
echo "Still missing:\`${still# }\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,31 +0,0 @@
|
||||
name: Release Charts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write # for helm/chart-releaser-action to push chart release and create a release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -1,144 +0,0 @@
|
||||
name: Publish .spk to Sandstorm App Market
|
||||
|
||||
# Manually triggered. Takes the Sandstorm .spk that sandstorm.yml already built and
|
||||
# attached to a WeKan GitHub Release, renames it to wekan-<VERSION>.spk (VERSION is the
|
||||
# WeKan release version, e.g. 9.88), and publishes it to the Sandstorm App Market with
|
||||
# spk publish wekan-<VERSION>.spk
|
||||
# exactly like releases/release-sandstorm.sh does locally.
|
||||
#
|
||||
# gh workflow run sandstorm-store.yml
|
||||
# gh workflow run sandstorm-store.yml -f version=9.88
|
||||
# gh workflow run sandstorm-store.yml -f version=9.88 -f tag=v9.88
|
||||
#
|
||||
# Needs SANDSTORM_KEYRING (base64 of ~/.sandstorm-keyring) — `spk publish` proves app
|
||||
# ownership with the WeKan Sandstorm app's private key from that keyring — and installs
|
||||
# Sandstorm + assembles meteor-spk the same way sandstorm.yml does, so `spk` is present.
|
||||
# It does NOT rebuild the .spk; it publishes the one already on the release.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'WeKan version for the store filename wekan-<VERSION>.spk (e.g. 9.88). Default: derived from the release tag.'
|
||||
required: false
|
||||
default: ''
|
||||
tag:
|
||||
description: 'Release tag whose .spk to publish (default: newest release).'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
sandstorm-store:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git
|
||||
|
||||
- name: Install Sandstorm (dev, unattended)
|
||||
# Provides the `spk` command used to publish, and the sandstorm-http-bridge that
|
||||
# build-deps.sh copies into the meteor-spk deps.
|
||||
run: curl https://install.sandstorm.io | sudo bash -s -- -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (same as sandstorm.yml)
|
||||
run: |
|
||||
bash sandstorm-src/build-deps.sh
|
||||
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Restore the Sandstorm signing keyring
|
||||
env:
|
||||
SANDSTORM_KEYRING: ${{ secrets.SANDSTORM_KEYRING }}
|
||||
run: |
|
||||
if [ -n "${SANDSTORM_KEYRING:-}" ]; then
|
||||
# spk publish reads the app signing key from ~/.sandstorm-keyring (a FILE in
|
||||
# $HOME), same location meteor-spk pack uses.
|
||||
printf '%s' "$SANDSTORM_KEYRING" | base64 -d > "$HOME/.sandstorm-keyring"
|
||||
chmod 600 "$HOME/.sandstorm-keyring"
|
||||
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
|
||||
else
|
||||
echo "::error::SANDSTORM_KEYRING secret is not set. 'spk publish' proves ownership of the WeKan Sandstorm app with the private key from this keyring, so publishing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download the newest .spk from the WeKan release and name it wekan-<VERSION>.spk
|
||||
id: fetch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
IN_TAG: ${{ github.event.inputs.tag }}
|
||||
IN_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) Which release to publish from.
|
||||
TAG="${IN_TAG:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
|
||||
echo "::error::No release found to publish the .spk from."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) VERSION for the store filename wekan-<VERSION>.spk. Use the input, else
|
||||
# derive it from the tag (strip a leading 'v', e.g. v9.88 -> 9.88).
|
||||
VERSION="${IN_VERSION:-}"
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${TAG#v}"
|
||||
fi
|
||||
echo "Release: $TAG -> store filename: wekan-${VERSION}.spk"
|
||||
|
||||
# 3) Download the newest .spk asset on that release. sandstorm.yml attaches it
|
||||
# as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk; if several exist, take the
|
||||
# lexicographically-newest (the timestamp sorts chronologically).
|
||||
ASSET="$(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" --json assets \
|
||||
--jq '[.assets[].name | select(endswith(".spk"))] | sort | last' 2>/dev/null || true)"
|
||||
if [ -z "${ASSET:-}" ] || [ "$ASSET" = "null" ]; then
|
||||
echo "::error::Release $TAG has no .spk asset. Run sandstorm.yml first to build and attach one."
|
||||
exit 1
|
||||
fi
|
||||
echo "Downloading .spk asset: $ASSET"
|
||||
gh release download "$TAG" --pattern "$ASSET" --dir . --clobber
|
||||
|
||||
SPK="wekan-${VERSION}.spk"
|
||||
mv -f "$ASSET" "$SPK"
|
||||
ls -la "$SPK"
|
||||
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to the Sandstorm App Market
|
||||
env:
|
||||
SPK: ${{ steps.fetch.outputs.spk }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Resolve the spk binary: prefer PATH, then the Sandstorm install location.
|
||||
SPK_BIN="$(command -v spk || true)"
|
||||
if [ -z "$SPK_BIN" ]; then
|
||||
for c in /opt/sandstorm/latest/bin/spk /opt/sandstorm/spk /usr/local/bin/spk; do
|
||||
[ -x "$c" ] && SPK_BIN="$c" && break
|
||||
done
|
||||
fi
|
||||
if [ -z "$SPK_BIN" ]; then
|
||||
echo "::error::Could not find the 'spk' command from the Sandstorm install."
|
||||
exit 1
|
||||
fi
|
||||
echo "Publishing $SPK with $SPK_BIN ..."
|
||||
# Same command as releases/release-sandstorm.sh: spk publish wekan-<VERSION>.spk
|
||||
"$SPK_BIN" publish "$SPK"
|
||||
@@ -1,143 +0,0 @@
|
||||
name: Build Sandstorm .spk
|
||||
|
||||
# Manually triggered. Builds ONLY the Sandstorm .spk package (nothing else) and
|
||||
# uploads it as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk to the NEWEST WeKan GitHub
|
||||
# Release as an attachment, so you can download it, test whether the Sandstorm
|
||||
# build works, and see any errors — without running a full release.
|
||||
#
|
||||
# gh workflow run sandstorm.yml
|
||||
#
|
||||
# EXPERIMENTAL in CI (same caveats as the build-sandstorm job in release-all.yml):
|
||||
# * Sandstorm needs unprivileged user namespaces; GitHub's Ubuntu runners are VMs
|
||||
# that allow them, but this is the fragile part.
|
||||
# * meteor-spk SIGNS the .spk with the app private key from the Sandstorm keyring;
|
||||
# provide it as the SANDSTORM_KEYRING secret (base64 of ~/.sandstorm keyring).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch, tag or SHA to build the .spk from (default: main)'
|
||||
required: false
|
||||
default: 'main'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
sandstorm:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Build the dispatched ref so a fix branch can be tested before it is
|
||||
# merged to main; defaults to main for a plain `gh workflow run`.
|
||||
ref: ${{ github.event.inputs.ref || 'main' }}
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
|
||||
# Ubuntu 24.04's AppArmor defaults kernel.apparmor_restrict_unprivileged_userns=1,
|
||||
# which blocks the unprivileged user namespaces that the Sandstorm install and
|
||||
# the spk supervisor rely on (the "fragile part" noted above). Relax it so
|
||||
# `install.sandstorm.io` and meteor-spk can create sandboxes on the runner.
|
||||
# `|| true`: on kernels/runners without these knobs, setting them is a no-op.
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
|
||||
- name: Install system dependencies and Meteor
|
||||
run: |
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git qemu-user-static
|
||||
if [ ! -f "$HOME/.meteor/meteor" ]; then curl https://install.meteor.com/ | sh; fi
|
||||
echo "$HOME/.meteor" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Sandstorm (dev, unattended)
|
||||
# Installed BEFORE assembling the deps: build-deps.sh's [bridge] step copies
|
||||
# the modern /opt/sandstorm/latest/bin/sandstorm-http-bridge over the ancient
|
||||
# bundled one, so the Sandstorm install must be in place first.
|
||||
run: curl https://install.sandstorm.io | sudo bash -s -- -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (Node 24 + FerretDB + Mongo 3.x CLIs)
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
|
||||
# niscud). build-deps.sh swaps in Node 24, adds FerretDB + the Mongo 3.x
|
||||
# CLIs + the migration launcher/importer, keeps niscud, and swaps in the
|
||||
# modern sandstorm-http-bridge from the Sandstorm install above. No dependency
|
||||
# on the retired releases.wekan.team or the old projects.7z.
|
||||
run: |
|
||||
bash sandstorm-src/build-deps.sh
|
||||
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Restore the Sandstorm signing keyring
|
||||
env:
|
||||
SANDSTORM_KEYRING: ${{ secrets.SANDSTORM_KEYRING }}
|
||||
run: |
|
||||
if [ -n "${SANDSTORM_KEYRING:-}" ]; then
|
||||
# meteor-spk / spk read the app signing key from ~/.sandstorm-keyring —
|
||||
# a FILE directly in $HOME (see the pack crash path
|
||||
# "/home/runner/.sandstorm-keyring"). Write it exactly there, NOT to
|
||||
# ~/.sandstorm/sandstorm-keyring, or `meteor-spk pack` aborts with
|
||||
# "open(~/.sandstorm-keyring): No such file or directory".
|
||||
printf '%s' "$SANDSTORM_KEYRING" | base64 -d > "$HOME/.sandstorm-keyring"
|
||||
chmod 600 "$HOME/.sandstorm-keyring"
|
||||
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
|
||||
else
|
||||
echo "::error::SANDSTORM_KEYRING secret is not set. meteor-spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install Meteor npm dependencies
|
||||
# PUPPETEER_SKIP_DOWNLOAD: puppeteer is a devDependency used only by tests,
|
||||
# not by the server, so it never belongs in the packed .spk. Skipping its
|
||||
# Chromium download (~150 MB) avoids any risk of it bloating the build and
|
||||
# speeds up CI.
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: '1'
|
||||
run: |
|
||||
(meteor update --npm 2>/dev/null || true) && meteor npm install
|
||||
|
||||
- name: Build the Sandstorm .spk
|
||||
id: build
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: '1'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STAMP="$(date +'%Y_%m_%d-%H_%M_%S')"
|
||||
SPK="wekan-sandstorm-${STAMP}.spk"
|
||||
meteor-spk pack "$SPK"
|
||||
# Report the packed size and warn if it exceeds Cloudflare's 100 MB upload
|
||||
# cap (a proxied browser upload to a Cloudflare-fronted Sandstorm 413s above
|
||||
# this; install via a DNS-only host / SSH tunnel, or trim further).
|
||||
SPK_BYTES=$(stat -c%s "$SPK")
|
||||
echo "Packed $SPK: $((SPK_BYTES/1024/1024)) MiB ($SPK_BYTES bytes)"
|
||||
if [ "$SPK_BYTES" -gt $((100*1024*1024)) ]; then
|
||||
echo "::warning::$SPK is $((SPK_BYTES/1024/1024)) MiB — over Cloudflare's 100 MB upload limit. Upload via a DNS-only host / SSH tunnel to Sandstorm, or trim the bundle further."
|
||||
fi
|
||||
ls -la "$SPK"
|
||||
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.build.outputs.spk }}
|
||||
path: ${{ steps.build.outputs.spk }}
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload the .spk to the newest WeKan release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
|
||||
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
|
||||
echo "::error::No existing GitHub Release found to attach the .spk to."
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploading ${{ steps.build.outputs.spk }} to the newest release: $TAG"
|
||||
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${{ steps.build.outputs.spk }}" --clobber
|
||||
@@ -1,73 +0,0 @@
|
||||
name: Update Ondra and Gantt repositories
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: WeKan branch, tag, or commit to copy
|
||||
required: false
|
||||
type: string
|
||||
default: main
|
||||
secrets:
|
||||
WEKAN_REPO_TOKEN:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: WeKan branch, tag, or commit to copy
|
||||
required: false
|
||||
type: string
|
||||
default: main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- repository: wekan-ondra
|
||||
package: wekan-ondra
|
||||
title: Wekan Ondra
|
||||
- repository: wekan-gantt-gpl
|
||||
package: wekan-gantt-gpl
|
||||
title: Wekan Gantt GPL
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout newest WeKan content
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/wekan
|
||||
ref: ${{ inputs.source_ref }}
|
||||
path: source
|
||||
|
||||
- name: Checkout wekan/${{ matrix.repository }}
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/${{ matrix.repository }}
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
ref: main
|
||||
path: target
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy WeKan and restore ${{ matrix.package }} package identities
|
||||
run: |
|
||||
source/releases/prepare-variant-repo.sh \
|
||||
source target "${{ matrix.package }}" "${{ matrix.title }}"
|
||||
|
||||
- name: Commit and push wekan/${{ matrix.repository }}
|
||||
working-directory: target
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name 'wekan-release-bot'
|
||||
git config user.email 'release@wekan.team'
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "wekan/${{ matrix.repository }} already matches ${{ inputs.source_ref }}."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Sync newest WeKan as ${{ matrix.package }}"
|
||||
git push origin HEAD:main
|
||||
@@ -1,343 +0,0 @@
|
||||
name: Windows single EXE
|
||||
|
||||
# Wrap the already-published self-contained win64 ZIP as one Windows executable.
|
||||
# The ZIP remains the source of truth: this workflow appends that exact ZIP -
|
||||
# with its Node.js, FerretDB, MongoDB Database Tools and Meteor server - to a
|
||||
# small native launcher, and writes an 80-byte trailer saying where the payload
|
||||
# is and what its SHA-256 must be.
|
||||
#
|
||||
# The bundle is NOT unpacked. On its first run the EXE verifies that SHA-256 and
|
||||
# unpacks only what cannot be virtual - the executables, the native addons,
|
||||
# main.js, start-wekan.bat and wekan-vfs.cjs, about thirty files. The other
|
||||
# ~39,000 stay inside the EXE and are read from it in the server process by
|
||||
# releases/single-exe/wekan-vfs.cjs, which node.exe preloads. Persistent data
|
||||
# stays in a real wekan-files/ directory beside the EXE, so the EXE and its data
|
||||
# can be moved together and a newer version never touches them.
|
||||
#
|
||||
# It used to be packed with Enigma Virtual Box, whose virtual filesystem served
|
||||
# all 44,401 files from inside the EXE. That is what broke WeKan 11.48: once
|
||||
# Node.js had loaded the native bcrypt addon out of it, the next read -
|
||||
# promises.js, whose bytes sit immediately after that addon - came back as the
|
||||
# addon's own PE bytes, and the server died in a restart loop with
|
||||
# "SyntaxError: Invalid or unexpected token". The idea was right and the
|
||||
# closed-source implementation was wrong, so the mounting is ours now.
|
||||
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to wrap and attach to (empty = newest release)'
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
description: 'Attach the single EXE to the release'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: wekan-windows-exe-${{ inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: find the release
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
tag: ${{ steps.find.outputs.tag }}
|
||||
version: ${{ steps.find.outputs.version }}
|
||||
available: ${{ steps.find.outputs.available }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: find
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag='${{ inputs.tag }}'
|
||||
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
|
||||
--limit 1 --json tagName --jq '.[0].tagName')"
|
||||
[ -n "$tag" ] || { echo "::error::No release to wrap."; exit 1; }
|
||||
version="${tag#v}"
|
||||
available=false
|
||||
if gh release view "$tag" --repo "${{ github.repository }}" --json assets \
|
||||
--jq '.assets[].name' | grep -qx "wekan-${version}-win64.zip"; then
|
||||
available=true
|
||||
else
|
||||
echo "::notice::wekan-${version}-win64.zip is not published; there is no Windows bundle to wrap."
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "available=$available" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: self-extracting win64 EXE
|
||||
needs: release
|
||||
if: ${{ needs.release.outputs.available == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch and verify the published win64 bundle
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$zip = 'wekan-${{ needs.release.outputs.version }}-win64.zip'
|
||||
$assets = gh release view --repo '${{ github.repository }}' `
|
||||
'${{ needs.release.outputs.tag }}' --json assets --jq '.assets[].name'
|
||||
if ($assets -notcontains $zip) {
|
||||
throw "$zip is not on ${{ needs.release.outputs.tag }}. The single EXE can only wrap a published win64 bundle."
|
||||
}
|
||||
gh release download '${{ needs.release.outputs.tag }}' `
|
||||
--repo '${{ github.repository }}' --pattern "$zip*" --clobber
|
||||
# The ZIP is the payload, so it is checked as a whole against the
|
||||
# checksum published beside it rather than unpacked here.
|
||||
if (Test-Path "$zip.sha256sum" -PathType Leaf) {
|
||||
$published = ((Get-Content "$zip.sha256sum" -Raw).Trim() -split '\s+')[0].ToLowerInvariant()
|
||||
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $published) {
|
||||
throw "$zip does not match its published SHA256: expected $published, got $actual."
|
||||
}
|
||||
Write-Host "$zip matches its published SHA256 $actual."
|
||||
} else {
|
||||
Write-Host "::warning::$zip has no published .sha256sum on this release, so the payload is unverified."
|
||||
}
|
||||
# Read the archive's index only - the payload is appended whole, so
|
||||
# unpacking 700 MB here would only prove what the launcher proves.
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip).Path)
|
||||
try {
|
||||
$names = $archive.Entries.FullName
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
foreach ($file in @('main.js', 'node.exe', 'ferretdb.exe', 'start-wekan.bat',
|
||||
'wekan-vfs.cjs')) {
|
||||
if ($names -notcontains "bundle/$file") {
|
||||
throw "$zip has no bundle/$file; it is not a self-contained WeKan bundle. (wekan-vfs.cjs is the in-process archive mount; a bundle built before it existed cannot be wrapped as a single EXE.)"
|
||||
}
|
||||
}
|
||||
Write-Host "$zip carries $($names.Count) entries."
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Work out which members cannot be virtual
|
||||
shell: pwsh
|
||||
# The launcher unpacks these and nothing else. The list is computed from
|
||||
# the archive, not written by hand, so a bundle that gains an addon or a
|
||||
# database tool does not silently lose it at runtime.
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
node releases/append-windows-payload.mjs `
|
||||
--manifest "wekan-${{ needs.release.outputs.version }}-win64.zip" `
|
||||
--header wekan-real-files.h
|
||||
|
||||
- uses: ilammy/msvc-dev-cmd@v1
|
||||
|
||||
- name: Compile the native launcher
|
||||
shell: cmd
|
||||
# /I. so the generated wekan-real-files.h is found; it is written to the
|
||||
# workspace root rather than beside the .c file, which stays clean.
|
||||
run: >-
|
||||
cl /nologo /O2 /MT /W4 /DUNICODE /D_UNICODE /I.
|
||||
releases\windows-single-exe-launcher.c /Fe:wekan-launcher.exe
|
||||
|
||||
- name: Append the bundle and its trailer to the launcher
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = '${{ needs.release.outputs.version }}'
|
||||
New-Item -ItemType Directory -Force dist | Out-Null
|
||||
node releases/append-windows-payload.mjs `
|
||||
--launcher wekan-launcher.exe `
|
||||
--payload "wekan-$version-win64.zip" `
|
||||
--output "dist/WeKan-$version-win64.exe" `
|
||||
--version $version
|
||||
$output = "dist/WeKan-$version-win64.exe"
|
||||
if ((Get-Item $output).Length -lt 10MB) { throw 'The EXE is too small to contain WeKan.' }
|
||||
node releases/append-windows-payload.mjs --verify $output
|
||||
$hash = (Get-FileHash $output -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash WeKan-$version-win64.exe" |
|
||||
Out-File "$output.sha256sum" -Encoding ascii
|
||||
Get-ChildItem dist | Format-Table Name,Length
|
||||
|
||||
- name: Free ports used by the packaged EXE before smoke test
|
||||
shell: pwsh
|
||||
run: |
|
||||
# The GitHub Actions Windows runner ships MongoDB 7 as a running
|
||||
# service. Its mongod holds 27017, and the pre-installed mongosh /
|
||||
# MongoDB tools also register port 8088 in some images. Both ports
|
||||
# are needed by the embedded FerretDB inside the packaged EXE, so we
|
||||
# must release them before the smoke test or FerretDB cannot bind and
|
||||
# WeKan never starts.
|
||||
foreach ($svc in @('MongoDB', 'mongod')) {
|
||||
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
|
||||
if ($s -and $s.Status -ne 'Stopped') {
|
||||
Write-Host "Stopping service: $svc"
|
||||
Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
# Kill any remaining process that holds the ports we need.
|
||||
foreach ($port in @(27017, 8088)) {
|
||||
$pids = (netstat -ano | Select-String ":$port\s") |
|
||||
ForEach-Object { ($_ -split '\s+')[-1] } |
|
||||
Where-Object { $_ -match '^\d+$' } |
|
||||
Sort-Object -Unique
|
||||
# Not $pid: that is PowerShell's own read-only process id.
|
||||
foreach ($owner in $pids) {
|
||||
Write-Host "Killing PID $owner (held port $port)"
|
||||
taskkill /PID $owner /F 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
# taskkill.exe (unlike a PowerShell cmdlet) sets $LASTEXITCODE, and
|
||||
# pwsh -Command exits with whatever it last was when the script
|
||||
# never calls `exit` itself. This foreach's taskkill is the LAST
|
||||
# thing this step runs, so if the very last PID it tried had
|
||||
# already exited between netstat and taskkill (a real, harmless
|
||||
# race - the port is free either way), this whole step failed with
|
||||
# no error message at all.
|
||||
$global:LASTEXITCODE = 0
|
||||
|
||||
- name: Check that the single EXE starts, twice, without ever restarting
|
||||
shell: pwsh
|
||||
# Twice, because the two runs are different code paths: the first
|
||||
# unpacks the payload into wekan-app, the second must find it already
|
||||
# there and start straight away.
|
||||
#
|
||||
# "Without ever restarting" is the part 11.48 needed. start-wekan.bat
|
||||
# restarts WeKan every 3 seconds when it dies, so an EXE in a crash
|
||||
# loop still answers on port 8080 eventually and a poll-until-ready
|
||||
# check calls it healthy. This one fails on the restart line and on
|
||||
# a SyntaxError, so a bundle Node.js cannot load can no longer ship.
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = '${{ needs.release.outputs.version }}'
|
||||
# Run from its own directory: the EXE unpacks ~700 MB beside itself
|
||||
# and dist/ is what gets uploaded as the release artifact.
|
||||
New-Item -ItemType Directory -Force smoke | Out-Null
|
||||
Copy-Item "dist/WeKan-$version-win64.exe" smoke/
|
||||
$exe = (Resolve-Path "smoke/WeKan-$version-win64.exe").Path
|
||||
$env:PORT = '8080'
|
||||
$env:ROOT_URL = 'http://localhost:8080'
|
||||
|
||||
function Start-Once([string]$label, [int]$attempts) {
|
||||
$out = "smoke-$label.log"
|
||||
$err = "smoke-$label-error.log"
|
||||
$process = Start-Process $exe -PassThru -RedirectStandardOutput $out `
|
||||
-RedirectStandardError $err
|
||||
$ready = $false
|
||||
try {
|
||||
for ($i = 0; $i -lt $attempts; $i++) {
|
||||
if ($process.HasExited) { break }
|
||||
try {
|
||||
Invoke-WebRequest http://localhost:8080/sign-in -UseBasicParsing -TimeoutSec 3 | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch {}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
} finally {
|
||||
if (-not $process.HasExited) { taskkill /PID $process.Id /T /F | Out-Null }
|
||||
# taskkill.exe sets $LASTEXITCODE, which $ErrorActionPreference
|
||||
# does not touch (that only governs PowerShell errors, not a
|
||||
# plain exe's own exit code) - and pwsh -Command exits with
|
||||
# whatever $LASTEXITCODE last was when the script itself never
|
||||
# calls `exit`. 'second' is the LAST thing this whole step
|
||||
# runs, so a nonzero code surviving past HERE fails the step
|
||||
# with no error message at all: exactly what happened when
|
||||
# taskkill found nothing to kill (0 is "killed it", 128 is
|
||||
# "no such process" - both harmless, this step does not care
|
||||
# which).
|
||||
$global:LASTEXITCODE = 0
|
||||
}
|
||||
# start-wekan.bat starts ferretdb.exe with `start /b`; make sure
|
||||
# 27017 and 8080 are free again before the next run reuses them.
|
||||
taskkill /IM ferretdb.exe /F 2>$null | Out-Null
|
||||
$global:LASTEXITCODE = 0
|
||||
Start-Sleep -Seconds 5
|
||||
$log = (Get-Content $out, $err -ErrorAction SilentlyContinue) -join "`n"
|
||||
if (-not $ready) {
|
||||
Write-Host $log
|
||||
throw "The packaged EXE did not answer on port 8080 on the $label run."
|
||||
}
|
||||
foreach ($bad in @('WeKan exited; restarting', 'SyntaxError')) {
|
||||
if ($log -match [regex]::Escape($bad)) {
|
||||
Write-Host $log
|
||||
throw "The packaged EXE logged '$bad' on the $label run: it is starting only because start-wekan.bat keeps retrying."
|
||||
}
|
||||
}
|
||||
Write-Host "$label run: WeKan answered on port 8080 on its first attempt."
|
||||
}
|
||||
|
||||
# The first run unpacks ~700 MB before WeKan even starts.
|
||||
$env:WRITABLE_PATH = "$pwd\smoke-data"
|
||||
Start-Once 'first' 240
|
||||
if (-not (Test-Path 'smoke/wekan-app/start-wekan.bat' -PathType Leaf)) {
|
||||
throw 'The first run did not unpack the bundle into wekan-app.'
|
||||
}
|
||||
|
||||
# The point of the whole design: the bundle stays in the EXE. A change
|
||||
# that quietly went back to unpacking everything would still pass the
|
||||
# two start checks above, so count what actually reached the disk.
|
||||
$unpacked = (Get-ChildItem smoke/wekan-app -Recurse -File).Count
|
||||
Write-Host "unpacked $unpacked files into wekan-app"
|
||||
if ($unpacked -gt 200) {
|
||||
throw "The EXE unpacked $unpacked files; it should unpack only the executables, the native addons and a few loader files. The rest must be served from inside the EXE."
|
||||
}
|
||||
foreach ($served in @('programs/server/boot.js',
|
||||
'programs/server/packages/webapp.js')) {
|
||||
if (Test-Path "smoke/wekan-app/$served") {
|
||||
throw "smoke/wekan-app/$served is on disk; it should be read from inside the EXE."
|
||||
}
|
||||
}
|
||||
|
||||
Start-Once 'second' 120
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-single-exe-win64
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: attach to the release
|
||||
needs: [release, build]
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' && inputs.publish != false }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: windows-single-exe-win64
|
||||
path: assets
|
||||
|
||||
- name: Upload the EXE and checksum
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload --repo '${{ github.repository }}' \
|
||||
'${{ needs.release.outputs.tag }}' assets/* --clobber
|
||||
gh release view --repo '${{ github.repository }}' \
|
||||
'${{ needs.release.outputs.tag }}' --json url --jq .url
|
||||
+2
-99
@@ -1,19 +1,14 @@
|
||||
*~
|
||||
/*.log
|
||||
# vim swap files (.swp, .swo, .swn) - but NOT .swcrc, which is the SWC transpiler
|
||||
# configuration and has to be committed. Without this exception `git add .swcrc`
|
||||
# is silently a no-op and the fix for #6557 never leaves the working tree.
|
||||
*.sw*
|
||||
!.swcrc
|
||||
*.swp
|
||||
.meteor-spk
|
||||
*.sublime-workspace
|
||||
tmp/
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.gitmodules
|
||||
.vscode/
|
||||
.idea/
|
||||
.build/*
|
||||
package-lock.json
|
||||
**/parts/
|
||||
**/stage
|
||||
**/prime
|
||||
@@ -34,95 +29,3 @@ versions.json
|
||||
.Trashes
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
.eslintcache
|
||||
.meteor/local
|
||||
.meteor/local-test
|
||||
.devcontainer/docker-compose.extend.yml
|
||||
.devcontainer/volumes*/
|
||||
.coverage
|
||||
|
||||
# Rspack build artifacts
|
||||
_build/
|
||||
public/build-assets/
|
||||
public/build-chunks/
|
||||
private/build-assets/
|
||||
private/build-chunks/
|
||||
public/client-rspack.js.map
|
||||
|
||||
# Helm chart
|
||||
# Chart dependencies
|
||||
/helm/wekan/**/*.tgz
|
||||
/helm/wekan/charts
|
||||
|
||||
# Meteor Modern-Tools build context directories
|
||||
_build
|
||||
*/build-assets
|
||||
*/build-chunks
|
||||
.rsdoctor
|
||||
|
||||
# Tests
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Claude AI
|
||||
.claude/
|
||||
|
||||
# Companion repositories and tools, cloned INSIDE this checkout so they work in a
|
||||
# Flatpak sandbox that shares only this directory. One ignored directory for all
|
||||
# of them - it replaced an entry per clone (/FerretDB/, /node/, /mongo-tools/,
|
||||
# /mongosh/, /sandstorm/, /projects/, and the WeKan variants), which had to be
|
||||
# extended by hand for every new one. build.sh's ensure_tool_repo clones what a
|
||||
# test run needs into here: .tools/FerretDB, .tools/node-patches,
|
||||
# .tools/mongo-tools-patches. Nothing in here is WeKan's source, and Meteor is
|
||||
# told the same by /.tools/ in .meteorignore.
|
||||
.tools/
|
||||
|
||||
# Meteor custom local directory (METEOR_LOCAL_DIR)
|
||||
_build-local-test
|
||||
*/build-assets-local-test
|
||||
*/build-chunks-local-test
|
||||
|
||||
# Test and build logs, when they land INSIDE the repo. build.sh writes to
|
||||
# ../log/<datetime>/ by preference - one level up, so a test run does not show
|
||||
# up in git status - but the parent of the repo is not writable in a Flatpak
|
||||
# sandbox, which shares only the repository. There it falls back to ./log/,
|
||||
# which must not be committed.
|
||||
/log/
|
||||
|
||||
# V8 heap snapshots from WEKAN_BUILD_HEAP_SNAPSHOT=1. Named
|
||||
# Heap.<date>.<pid>.<n>.heapsnapshot by Node, written into the repo root
|
||||
# because that is the build's working directory, and as large as the heap that
|
||||
# produced them - gigabytes.
|
||||
Heap.*.heapsnapshot
|
||||
|
||||
# Linux core dumps (core.<pid>), e.g. from a crashing Playwright/WebKit browser
|
||||
# process. Written into the crashing process CWD (tests/playwright/) by
|
||||
# kernel.core_pattern; never source files.
|
||||
core.[0-9]*
|
||||
|
||||
# The two variant repositories (wekan/wekan-ondra, wekan/wekan-gantt-gpl) when
|
||||
# they are cloned INSIDE this working copy to be synced by hand. They are whole
|
||||
# git repositories of their own; git would not commit them into this one, but
|
||||
# they should not show up as untracked noise either.
|
||||
/wekan-ondra/
|
||||
/wekan-gantt-gpl/
|
||||
|
||||
# node-patches: a SEPARATE repo (github.com/wekan/node-patches) checked out here.
|
||||
node-patches/
|
||||
|
||||
# mongo-tools-patches: a SEPARATE repo (github.com/wekan/mongo-tools-patches)
|
||||
# checked out here. Same story as node-patches: the PATCHES to the upstream
|
||||
# MongoDB Database Tools that WeKan's bundled bsondump/mongodump/... are built
|
||||
# from, replacing the wekan/mongo-tools source fork.
|
||||
mongo-tools-patches/
|
||||
|
||||
# Meteor Modern-Tools build context directories
|
||||
*/build-assets*
|
||||
*/build-chunks*
|
||||
|
||||
# Meteor custom local directory (METEOR_LOCAL_DIR)
|
||||
*/build-assets-local-test*
|
||||
*/build-chunks-local-test*
|
||||
|
||||
# Generated on the Windows single-EXE runner (releases/append-windows-payload.mjs --manifest)
|
||||
wekan-real-files.h
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
FROM gitpod/workspace-mongodb
|
||||
|
||||
USER gitpod
|
||||
|
||||
# Install custom tools, runtime, etc. using apt-get
|
||||
# For example, the command below would install "bastet" - a command line tetris clone:
|
||||
#
|
||||
# RUN sudo apt-get -q update && # sudo apt-get install -yq bastet && # sudo rm -rf /var/lib/apt/lists/*
|
||||
#
|
||||
# More information: https://www.gitpod.io/docs/config-docker/
|
||||
@@ -1,4 +0,0 @@
|
||||
tasks:
|
||||
- init: npm install
|
||||
image:
|
||||
file: .gitpod.Dockerfile
|
||||
@@ -16,5 +16,3 @@ notices-for-facebook-graph-api-2
|
||||
1.4.1-add-shell-server-package
|
||||
1.4.3-split-account-service-packages
|
||||
1.5-add-dynamic-import-package
|
||||
1.7-split-underscore-from-meteor-base
|
||||
1.8.3-split-jquery-from-blaze
|
||||
+73
-44
@@ -3,63 +3,92 @@
|
||||
# 'meteor add' and 'meteor remove' will edit this file for you,
|
||||
# but you can also edit it by hand.
|
||||
|
||||
meteor-base@1.5.2
|
||||
meteor-base@1.2.0
|
||||
|
||||
# Build system
|
||||
ecmascript@0.19.1
|
||||
standard-minifier-js@3.2.0
|
||||
rspack@1.3.0
|
||||
# Declared explicitly because server/modernBrowsers.js imports it, to say that
|
||||
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
|
||||
modern-browsers@0.2.3
|
||||
ecmascript
|
||||
stylus@2.513.13
|
||||
standard-minifier-css@1.3.5
|
||||
standard-minifier-js@2.2.0
|
||||
mquandalle:jade
|
||||
|
||||
blaze@3.0.2
|
||||
# Polyfills
|
||||
es5-shim@4.6.15
|
||||
|
||||
# Collections
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.5.1
|
||||
cfs:standard-packages
|
||||
cottz:publish-relations
|
||||
dburles:collection-helpers
|
||||
idmontie:migrations
|
||||
matb33:collection-hooks
|
||||
matteodem:easy-search
|
||||
mongo@1.3.1
|
||||
mquandalle:collection-mutations
|
||||
|
||||
# Account system
|
||||
accounts-password@3.3.1
|
||||
accounts-express@1.0.0
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
communitypackages:flow-routing
|
||||
# Routing
|
||||
ostrio:flow-router-extra
|
||||
kenton:accounts-sandstorm
|
||||
service-configuration@1.0.11
|
||||
useraccounts:unstyled
|
||||
useraccounts:flow-routing
|
||||
wekan-ldap
|
||||
wekan-accounts-cas
|
||||
wekan-accounts-sandstorm
|
||||
wekan-accounts-lockout
|
||||
wekan-oidc
|
||||
wekan-accounts-oidc
|
||||
|
||||
# Utilities
|
||||
check@1.5.0
|
||||
random@1.2.2
|
||||
reactive-dict@1.3.2
|
||||
session@1.2.3
|
||||
tracker@1.3.4
|
||||
audit-argument-checks@1.0.8
|
||||
check@1.2.5
|
||||
jquery@1.11.10
|
||||
random@1.0.10
|
||||
reactive-dict@1.2.0
|
||||
session@1.1.7
|
||||
tracker@1.1.3
|
||||
underscore@1.0.10
|
||||
3stack:presence
|
||||
alethes:pages
|
||||
arillo:flow-router-helpers
|
||||
audit-argument-checks@1.0.7
|
||||
kadira:blaze-layout
|
||||
kadira:dochead
|
||||
meteorhacks:picker
|
||||
meteorhacks:subs-manager
|
||||
mquandalle:autofocus
|
||||
ongoworks:speakingurl
|
||||
raix:handlebar-helpers
|
||||
tap:i18n
|
||||
http@1.3.0
|
||||
|
||||
# UI components
|
||||
ostrio:i18n
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.0
|
||||
email@3.2.0
|
||||
dynamic-import@0.7.4
|
||||
blaze
|
||||
reactive-var@1.0.11
|
||||
fortawesome:fontawesome
|
||||
mousetrap:mousetrap
|
||||
mquandalle:jquery-textcomplete
|
||||
mquandalle:jquery-ui-drag-drop-sort
|
||||
mquandalle:mousetrap-bindglobal
|
||||
peerlibrary:blaze-components@=0.15.1
|
||||
templates:tabs
|
||||
verron:autosize
|
||||
simple:json-routes
|
||||
rajit:bootstrap3-datepicker
|
||||
shell-server@0.3.0
|
||||
simple:rest-accounts-password
|
||||
useraccounts:core
|
||||
email@1.2.3
|
||||
horka:swipebox
|
||||
dynamic-import@0.2.0
|
||||
staringatlights:fast-render
|
||||
|
||||
mixmax:smart-disconnect
|
||||
accounts-password@1.5.0
|
||||
cfs:gridfs
|
||||
rzymek:fullcalendar
|
||||
momentjs:moment@2.22.2
|
||||
browser-policy-framing
|
||||
mquandalle:moment
|
||||
msavin:usercache
|
||||
wekan-scrollbar
|
||||
mquandalle:perfect-scrollbar
|
||||
mdg:meteor-apm-agent
|
||||
meteorhacks:unblock
|
||||
lucasantoniassi:accounts-lockout
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
ostrio:cookies
|
||||
ostrio:files
|
||||
# lmieulet:meteor-coverage
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration@1.3.5
|
||||
minifier-css@2.0.2
|
||||
ejson@1.2.0
|
||||
logging@1.3.6
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.5.2
|
||||
METEOR@1.6.0.1
|
||||
+187
-117
@@ -1,118 +1,188 @@
|
||||
accounts-base@3.3.1
|
||||
accounts-express@1.0.0
|
||||
accounts-oauth@1.4.7
|
||||
accounts-password@3.3.1
|
||||
aldeed:collection2@4.2.2
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.0
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.1
|
||||
babel-compiler@7.15.1
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.14
|
||||
binary-heap@1.0.13
|
||||
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
|
||||
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.4.1
|
||||
ddp-common@1.4.5
|
||||
ddp-rate-limiter@1.3.0
|
||||
ddp-server@3.4.0
|
||||
diff-sequence@1.1.3
|
||||
dynamic-import@0.7.4
|
||||
ecmascript@0.19.1
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.2.0
|
||||
email@3.2.0
|
||||
es5-shim@4.8.1
|
||||
facts-base@1.0.2
|
||||
fetch@0.2.0
|
||||
geojson-utils@1.0.12
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.1
|
||||
htmljs@2.0.2
|
||||
id-map@1.2.0
|
||||
inter-process-messaging@0.1.3
|
||||
jquery@3.0.2
|
||||
lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.6
|
||||
matb33:collection-hooks@2.1.0
|
||||
meteor@2.3.1
|
||||
meteor-base@1.5.2
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.4.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2
|
||||
minifier-js@3.3.1
|
||||
minimongo@2.2.0
|
||||
modern-browsers@0.2.3
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.5.1
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.9
|
||||
npm-mongo@6.16.3
|
||||
oauth@3.0.4
|
||||
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.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
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.2
|
||||
reactive-var@1.0.13
|
||||
reload@1.3.2
|
||||
retry@1.1.1
|
||||
reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.2
|
||||
rspack@1.3.0
|
||||
service-configuration@1.3.5
|
||||
session@1.2.3
|
||||
sha@1.0.10
|
||||
shell-server@0.7.0
|
||||
socket-stream-client@0.7.1
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.2.0
|
||||
templating@1.4.5
|
||||
templating-compiler@2.0.1
|
||||
templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.3.0
|
||||
tracker@1.3.4
|
||||
typescript@5.11.1
|
||||
url@1.3.6
|
||||
webapp@2.3.0
|
||||
webapp-hashing@1.1.2
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
3stack:presence@1.1.2
|
||||
accounts-base@1.4.0
|
||||
accounts-oauth@1.1.15
|
||||
accounts-password@1.5.0
|
||||
aldeed:collection2@2.10.0
|
||||
aldeed:collection2-core@1.2.0
|
||||
aldeed:schema-deny@1.1.0
|
||||
aldeed:schema-index@1.1.1
|
||||
aldeed:simple-schema@1.5.3
|
||||
alethes:pages@1.8.6
|
||||
allow-deny@1.1.0
|
||||
arillo:flow-router-helpers@0.5.2
|
||||
audit-argument-checks@1.0.7
|
||||
autoupdate@1.3.12
|
||||
babel-compiler@6.24.7
|
||||
babel-runtime@1.1.1
|
||||
base64@1.0.10
|
||||
binary-heap@1.0.10
|
||||
blaze@2.3.2
|
||||
blaze-tools@1.0.10
|
||||
boilerplate-generator@1.3.1
|
||||
browser-policy-common@1.0.11
|
||||
browser-policy-framing@1.1.0
|
||||
caching-compiler@1.1.9
|
||||
caching-html-compiler@1.1.2
|
||||
callback-hook@1.0.10
|
||||
cfs:access-point@0.1.49
|
||||
cfs:base-package@0.0.30
|
||||
cfs:collection@0.5.5
|
||||
cfs:collection-filters@0.2.4
|
||||
cfs:data-man@0.0.6
|
||||
cfs:file@0.1.17
|
||||
cfs:gridfs@0.0.34
|
||||
cfs:http-methods@0.0.32
|
||||
cfs:http-publish@0.0.13
|
||||
cfs:power-queue@0.9.11
|
||||
cfs:reactive-list@0.0.9
|
||||
cfs:reactive-property@0.0.4
|
||||
cfs:standard-packages@0.5.9
|
||||
cfs:storage-adapter@0.2.3
|
||||
cfs:tempstore@0.1.5
|
||||
cfs:upload-http@0.0.20
|
||||
cfs:worker@0.1.4
|
||||
check@1.2.5
|
||||
chuangbo:cookie@1.1.0
|
||||
coffeescript@1.12.7_3
|
||||
coffeescript-compiler@1.12.7_3
|
||||
cottz:publish-relations@2.0.8
|
||||
dburles:collection-helpers@1.1.0
|
||||
ddp@1.4.0
|
||||
ddp-client@2.2.0
|
||||
ddp-common@1.3.0
|
||||
ddp-rate-limiter@1.0.7
|
||||
ddp-server@2.1.1
|
||||
deps@1.0.12
|
||||
diff-sequence@1.0.7
|
||||
dynamic-import@0.2.1
|
||||
ecmascript@0.9.0
|
||||
ecmascript-runtime@0.5.0
|
||||
ecmascript-runtime-client@0.5.0
|
||||
ecmascript-runtime-server@0.5.0
|
||||
ejson@1.1.0
|
||||
email@1.2.3
|
||||
es5-shim@4.6.15
|
||||
fastclick@1.0.13
|
||||
fortawesome:fontawesome@4.7.0
|
||||
geojson-utils@1.0.10
|
||||
horka:swipebox@1.0.2
|
||||
hot-code-push@1.0.4
|
||||
html-tools@1.0.11
|
||||
htmljs@1.0.11
|
||||
http@1.3.0
|
||||
id-map@1.0.9
|
||||
idmontie:migrations@1.0.3
|
||||
jquery@1.11.10
|
||||
kadira:blaze-layout@2.3.0
|
||||
kadira:dochead@1.5.0
|
||||
kadira:flow-router@2.12.1
|
||||
kenton:accounts-sandstorm@0.7.0
|
||||
launch-screen@1.1.1
|
||||
livedata@1.0.18
|
||||
localstorage@1.2.0
|
||||
logging@1.1.19
|
||||
lucasantoniassi:accounts-lockout@1.0.0
|
||||
matb33:collection-hooks@0.8.4
|
||||
matteodem:easy-search@1.6.4
|
||||
mdg:meteor-apm-agent@3.1.2
|
||||
mdg:validation-error@0.5.1
|
||||
meteor@1.8.2
|
||||
meteor-base@1.2.0
|
||||
meteor-platform@1.2.6
|
||||
meteorhacks:aggregate@1.3.0
|
||||
meteorhacks:collection-utils@1.2.0
|
||||
meteorhacks:meteorx@1.4.1
|
||||
meteorhacks:picker@1.0.3
|
||||
meteorhacks:subs-manager@1.6.4
|
||||
meteorhacks:unblock@1.1.0
|
||||
meteorspark:util@0.2.0
|
||||
minifier-css@1.2.16
|
||||
minifier-js@2.2.2
|
||||
minifiers@1.1.8-faster-rebuild.0
|
||||
minimongo@1.4.3
|
||||
mixmax:smart-disconnect@0.0.4
|
||||
mobile-status-bar@1.0.14
|
||||
modules@0.11.0
|
||||
modules-runtime@0.9.1
|
||||
momentjs:moment@2.22.2
|
||||
mongo@1.3.1
|
||||
mongo-dev-server@1.1.0
|
||||
mongo-id@1.0.6
|
||||
mongo-livedata@1.0.12
|
||||
mousetrap:mousetrap@1.4.6_1
|
||||
mquandalle:autofocus@1.0.0
|
||||
mquandalle:collection-mutations@0.1.0
|
||||
mquandalle:jade@0.4.9
|
||||
mquandalle:jade-compiler@0.4.5
|
||||
mquandalle:jquery-textcomplete@0.8.0_1
|
||||
mquandalle:jquery-ui-drag-drop-sort@0.2.0
|
||||
mquandalle:moment@1.0.1
|
||||
mquandalle:mousetrap-bindglobal@0.0.1
|
||||
mquandalle:perfect-scrollbar@0.6.5_2
|
||||
msavin:usercache@1.0.0
|
||||
npm-bcrypt@0.9.3
|
||||
npm-mongo@2.2.33
|
||||
oauth@1.2.1
|
||||
oauth2@1.2.0
|
||||
observe-sequence@1.0.16
|
||||
ongoworks:speakingurl@1.1.0
|
||||
ordered-dict@1.0.9
|
||||
peerlibrary:assert@0.2.5
|
||||
peerlibrary:base-component@0.16.0
|
||||
peerlibrary:blaze-components@0.15.1
|
||||
peerlibrary:computed-field@0.7.0
|
||||
peerlibrary:reactive-field@0.3.0
|
||||
promise@0.10.0
|
||||
raix:eventemitter@0.1.3
|
||||
raix:handlebar-helpers@0.2.5
|
||||
rajit:bootstrap3-datepicker@1.7.1
|
||||
random@1.0.10
|
||||
rate-limit@1.0.8
|
||||
reactive-dict@1.2.0
|
||||
reactive-var@1.0.11
|
||||
reload@1.1.11
|
||||
retry@1.0.9
|
||||
routepolicy@1.0.12
|
||||
rzymek:fullcalendar@3.8.0
|
||||
service-configuration@1.0.11
|
||||
session@1.1.7
|
||||
sha@1.0.9
|
||||
shell-server@0.3.1
|
||||
simple:authenticate-user-by-token@1.0.1
|
||||
simple:json-routes@2.1.0
|
||||
simple:rest-accounts-password@1.1.2
|
||||
simple:rest-bearer-token-parser@1.0.1
|
||||
simple:rest-json-error-handler@1.0.1
|
||||
softwarerero:accounts-t9n@1.3.11
|
||||
spacebars@1.0.15
|
||||
spacebars-compiler@1.1.3
|
||||
srp@1.0.10
|
||||
standard-minifier-css@1.3.5
|
||||
standard-minifier-js@2.2.3
|
||||
staringatlights:fast-render@2.16.5
|
||||
staringatlights:inject-data@2.0.5
|
||||
stylus@2.513.13
|
||||
tap:i18n@1.8.2
|
||||
templates:tabs@2.3.0
|
||||
templating@1.3.2
|
||||
templating-compiler@1.3.3
|
||||
templating-runtime@1.3.2
|
||||
templating-tools@1.1.2
|
||||
tracker@1.1.3
|
||||
ui@1.0.13
|
||||
underscore@1.0.10
|
||||
url@1.1.0
|
||||
useraccounts:core@1.14.2
|
||||
useraccounts:flow-routing@1.14.2
|
||||
useraccounts:unstyled@1.14.2
|
||||
verron:autosize@3.0.8
|
||||
webapp@1.4.0
|
||||
webapp-hashing@1.0.9
|
||||
wekan-accounts-oidc@1.0.10
|
||||
wekan-accounts-sandstorm@0.9.0
|
||||
wekan-fontawesome@6.4.2
|
||||
wekan-fullcalendar@5.11.5
|
||||
wekan-ldap@0.1.0
|
||||
wekan-markdown@1.0.9
|
||||
wekan-oidc@1.1.0
|
||||
zodern:types@1.0.13
|
||||
wekan-markdown@1.0.7
|
||||
wekan-oidc@1.0.12
|
||||
wekan-scrollbar@3.1.3
|
||||
wekan-accounts-cas@0.1.0
|
||||
wekan-ldap@0.0.2
|
||||
yasaricli:slugify@0.0.7
|
||||
zimme:active-route@2.3.2
|
||||
@@ -1,96 +0,0 @@
|
||||
npm-packages/
|
||||
# Root tests/ holds Playwright specs, Node E2E and node-based unit tests - none
|
||||
# of it is Meteor code, and Meteor's file watcher otherwise churns on the
|
||||
# tests/playwright/test-results/ tree (inotify errors). The leading slash anchors
|
||||
# this to the repo root only, so it does NOT match server/lib/tests/ or
|
||||
# client/lib/tests/ (the Mocha suites, loaded via meteor.testModule in
|
||||
# package.json). A naked "tests/" would match a dir named tests at any depth and
|
||||
# would hide those suites, making `meteor test` report "0 passing".
|
||||
/tests/
|
||||
|
||||
# Build/tool scratch space: a full Node 24 install (headers for EVERY openssl arch),
|
||||
# a Go toolchain with its module/build caches, build logs and repro dirs. 20,535
|
||||
# DIRECTORIES, none of it app code. Meteor's watcher takes one inotify watch per
|
||||
# directory, and the per-user limit (fs.inotify.max_user_watches, 65536 here) is
|
||||
# shared with every other watcher the user runs - editors included. Watching this
|
||||
# tree is what made `meteor` fail with
|
||||
# inotify_add_watch on '.tools/node-.../openssl/archs/BSD-x86_64/asm_avx2'
|
||||
# failed: No space left on device
|
||||
# ENOSPC from inotify_add_watch means "watch limit reached", NOT a full disk.
|
||||
/.tools/
|
||||
|
||||
# DO NOT ADD /_build/ OR /_build-local-test/ HERE. They look like build output
|
||||
# that should be ignored - they are gitignored, and they are the first two
|
||||
# entries of the 296 KB pattern list above - and excluding them breaks the
|
||||
# build outright:
|
||||
#
|
||||
# error: Could not find mainModule for 'os' architecture:
|
||||
# _build/main-prod/server-meteor.js
|
||||
# Check the "meteor" section of your package.json file?
|
||||
#
|
||||
# They are not leftovers, they are the HANDOFF. rspack compiles the app into
|
||||
# _build/main-prod/ (and _build/test/ for a test run), and Meteor then reads
|
||||
# _build/main-prod/server-meteor.js and client-meteor.js as the application's
|
||||
# main modules - package.json names client/main.js and server/main.js, and the
|
||||
# rspack integration repoints mainModule at its own output. Ignoring them hides
|
||||
# the files Meteor is about to be handed. They cost 3 directories each, so there
|
||||
# was nothing to win here anyway.
|
||||
|
||||
# Local build and test logs. WEKAN_LOG_ROOT prefers ../log/<datetime>/, but
|
||||
# falls back to ./log when the parent is not writable - which is exactly the
|
||||
# case inside the Flatpak sandbox, so this directory does appear here.
|
||||
/log/
|
||||
|
||||
# Everything below is NOT application source: no file in client/, server/,
|
||||
# models/, imports/, config/ or packages/ imports anything from any of them
|
||||
# (checked, not assumed). Meteor watches what it does not ignore, one inotify
|
||||
# watch per directory, and the per-user limit is shared with every other watcher
|
||||
# on the machine - which is the same limit .tools/ above was added for. Ignoring
|
||||
# them costs nothing and stops the docs tree, the packaging trees and the
|
||||
# release tooling being scanned on every rebuild.
|
||||
#
|
||||
# NOT ignored here, deliberately, and each for a reason:
|
||||
# config/ imported by the app (9 places)
|
||||
# private/ public/ Meteor's own asset directories
|
||||
# packages/ the local Meteor packages
|
||||
# _build/ rspack's handoff - see the note above; ignoring it breaks
|
||||
# the build outright
|
||||
# The RELEASE bundle. `meteor build .build --directory` writes it inside the
|
||||
# source tree, and Meteor says so itself on every release build:
|
||||
#
|
||||
# WARNING: The output directory is under your source tree.
|
||||
# Your generated files may get interpreted as source code!
|
||||
#
|
||||
# This entry does NOT silence that warning, and nothing here can: the warning is
|
||||
# a plain path comparison on the output ARGUMENT, done before any file is
|
||||
# written - tools/cli/commands.js asks whether pathRelative(appDir, outputPath)
|
||||
# starts with '..', and prints it when it does not. It fires on every build into
|
||||
# .build/ whatever this file says. Silencing it would mean building outside the
|
||||
# tree (`meteor build ../output`), which every caller in build.sh, build.bat and
|
||||
# the release workflows would have to agree on.
|
||||
#
|
||||
# What this entry does is the SECOND half of the warning - "may get interpreted
|
||||
# as source code". The next `meteor run` would otherwise scan a whole bundled
|
||||
# copy of the app, so ignoring it is worth doing on its own. Unlike _build/
|
||||
# above, nothing hands these files back to Meteor: .build/bundle is the finished
|
||||
# output, read by the deploy and test steps, never by the compiler.
|
||||
/.build/
|
||||
|
||||
/docs/
|
||||
/meta/
|
||||
/old-CHANGELOG/
|
||||
/openapi/
|
||||
/sandstorm-src/
|
||||
/snap-src/
|
||||
/snap-base-debian/
|
||||
/stacksmith/
|
||||
/releases/
|
||||
/scripts/
|
||||
/tools/
|
||||
/secrets/
|
||||
/.github/
|
||||
/.vscode/
|
||||
/.devcontainer/
|
||||
/.claude/
|
||||
/.codex/
|
||||
/.tx/
|
||||
@@ -1,13 +0,0 @@
|
||||
dependencies:
|
||||
- libreadline-dev
|
||||
- libssl-dev
|
||||
- bsdtar
|
||||
targets:
|
||||
ubuntu-18.04:
|
||||
ubuntu-16.04:
|
||||
ubuntu-14.04:
|
||||
centos-6:
|
||||
centos-7:
|
||||
debian-10:
|
||||
sles-12:
|
||||
sles-11:
|
||||
@@ -1,23 +0,0 @@
|
||||
packages/
|
||||
node_modules/
|
||||
.build/
|
||||
# rspack's compiled output; formatting a generated bundle changes nothing that
|
||||
# is ever read and hides the source it came from.
|
||||
_build/
|
||||
_build-local-test/
|
||||
.meteor/
|
||||
.vscode/
|
||||
.tx/
|
||||
.github/
|
||||
.snap-meteor-1.8/
|
||||
# Not application source; see .meteorignore for the same list and why.
|
||||
docs/
|
||||
old-CHANGELOG/
|
||||
openapi/
|
||||
meta/
|
||||
sandstorm-src/
|
||||
snap-src/
|
||||
snap-base-debian/
|
||||
stacksmith/
|
||||
secrets/
|
||||
.tools/
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
dist: focal
|
||||
dist: trusty
|
||||
sudo: required
|
||||
|
||||
env:
|
||||
TRAVIS_DOCKER_COMPOSE_VERSION: 1.24.0
|
||||
TRAVIS_NODE_VERSION: 14.21.3
|
||||
TRAVIS_NPM_VERSION: latest
|
||||
TRAVIS_DOCKER_COMPOSE_VERSION: 1.17.0
|
||||
TRAVIS_NODE_VERSION: 8.9.3
|
||||
TRAVIS_NPM_VERSION: 5.5.1
|
||||
|
||||
before_install:
|
||||
- sudo apt-get update -y
|
||||
|
||||
+53
-7
@@ -1,9 +1,55 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
lang_map = te_IN: te-IN, es_AR: es-AR, es_419: es-LA, es_TX: es-TX, he_IL: he-IL, zh_CN: zh-CN, ar_EG: ar-EG, cs_CZ: cs-CZ, fa_IR: fa-IR, ms_MY: ms-MY, nl_NL: nl-NL, de_CH: de-CH, en_IT: en-IT, uz_UZ: uz-UZ, fr_CH: fr-CH, hi_IN: hi-IN, et_EE: et-EE, es_PE: es-PE, es_MX: es-MX, gl_ES: gl-ES, mn_MN: mn, zh_TW: zh-TW, ast_ES: ast-ES, es_CL: es-CL, ja_JP: ja-JP, lv_LV: lv, ro_RO: ro-RO, az_AZ: az-AZ, cy_GB: cy-GB, gu_IN: gu-IN, pl_PL: pl-PL, vep: ve-PP, en_BR: en-BR, en@ysv: en-YS, hu_HU: hu, ko_KR: ko-KR, pt_BR: pt-BR, zh_HK: zh-HK, zu_ZA: zu-ZA, en_MY: en-MY, ja-Hira: ja-HI, fi_FI: fi, vec: ve-CC, vi_VN: vi-VN, fr_FR: fr-FR, id_ID: id, zh_Hans: zh-Hans, en_DE: en-DE, en_GB: en-GB, el_GR: el-GR, uk_UA: uk-UA, az@latin: az-LA, de_AT: de-AT, uz@Latn: uz-LA, vls: vl-SS, ar_DZ: ar-DZ, bg_BG: bg, es_PY: es-PY, fy_NL: fy-NL, uz@Arab: uz-AR, ru_UA: ru-UA, war: wa-RR, zh_CN.GB2312: zh-GB
|
||||
# This is the configuration of the Transifex tool that we use to manage the
|
||||
# translations on Wekan. Documentation at: http://docs.transifex.com/client.
|
||||
#
|
||||
# Push
|
||||
# ====
|
||||
#
|
||||
# It is recommended that contributors use the Transifex web UI to create and
|
||||
# edit translated strings. However in case a contributor has directly jumped
|
||||
# into the code and made its translations in the corresponding i18n.json file
|
||||
# we can push it using
|
||||
#
|
||||
# > tx push -t -l ar
|
||||
#
|
||||
# Where `ar` is the language identifier. In addition, the project maintainer
|
||||
# should push the English source file to Transifex at least before each release
|
||||
# candidate using:
|
||||
#
|
||||
# > tx push -s
|
||||
#
|
||||
# Pull
|
||||
# ====
|
||||
#
|
||||
# The set of accepted language is directly managed in Transifex, the only
|
||||
# restriction we define to bundle a new language in the application, is that its
|
||||
# completion is at least at 75%.
|
||||
#
|
||||
# We use:
|
||||
#
|
||||
# > tx pull
|
||||
#
|
||||
# to download new versions of existing translations, and
|
||||
#
|
||||
# > tx pull -a --minimum-perc=75
|
||||
#
|
||||
# to download new sufficiently advanced translations.
|
||||
|
||||
[o:wekan:p:wekan:r:application]
|
||||
file_filter = imports/i18n/data/<lang>.i18n.json
|
||||
source_file = imports/i18n/data/en.i18n.json
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
# tap:i18n requires us to use `-` separator in the language identifiers whereas
|
||||
# Transifex uses a `_` separator, without an option to customize it on one side
|
||||
# or the other, so we need to do a Manual mapping.
|
||||
lang_map = bg_BG:bg, en_GB:en-GB, es_AR:es-AR, el_GR:el, fi_FI:fi, hu_HU:hu, id_ID:id, mn_MN:mn, no:nb, lv_LV:lv, pt_BR:pt-BR, ro_RO:ro, zh_CN:zh-CN, zh_TW:zh-TW, zh_HK:zh-HK
|
||||
|
||||
[wekan.application]
|
||||
file_filter = i18n/<lang>.i18n.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
type = KEYVALUEJSON
|
||||
|
||||
# We might have a dedicated second resource later to translate the “Welcome
|
||||
# Board” data.
|
||||
#
|
||||
# [wekan.welcomeBoard]
|
||||
# file_filter = private/welcomeBoard/<lang>.json
|
||||
# source_lang = en
|
||||
# type = KEYVALUEJSON
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Meteor: Node",
|
||||
"runtimeExecutable": "meteor",
|
||||
"runtimeArgs": [
|
||||
"--port=4000",
|
||||
"--exclude-archs=web.browser.legacy,web.cordova",
|
||||
"--raw-logs"
|
||||
],
|
||||
"env": {
|
||||
"WRITABLE_PATH": "/tmp/uploads",
|
||||
},
|
||||
"outputCapture": "std",
|
||||
"restart": true,
|
||||
"timeout": 60000
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Meteor: Chrome",
|
||||
"url": "http://localhost:4000",
|
||||
"sourceMapPathOverrides": {
|
||||
"meteor://💻app/*": "${workspaceFolder}/*"
|
||||
},
|
||||
"userDataDir": "${env:HOME}/.vscode/chrome"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Test: Node",
|
||||
"runtimeExecutable": "meteor",
|
||||
"runtimeArgs": [
|
||||
"test",
|
||||
"--port=4040",
|
||||
"--exclude-archs=web.browser.legacy,web.cordova",
|
||||
"--driver-package=meteortesting:mocha",
|
||||
"--settings=settings.json",
|
||||
"--raw-logs"
|
||||
],
|
||||
"env": {
|
||||
"TEST_WATCH": "1"
|
||||
},
|
||||
"outputCapture": "std",
|
||||
"timeout": 60000
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Meteor: All",
|
||||
"configurations": ["Meteor: Node", "Meteor: Chrome"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+10
-15
@@ -1,17 +1,12 @@
|
||||
{
|
||||
"terminal.integrated.env.linux": {
|
||||
"TERM": "xterm-256color",
|
||||
"SSH_AUTH_SOCK": null
|
||||
},
|
||||
"terminal.integrated.env.osx": {
|
||||
"TERM": "xterm-256color"
|
||||
},
|
||||
"terminal.integrated.env.windows": {
|
||||
"TERM": "xterm-256color"
|
||||
},
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"terminal.integrated.shellArgs.linux": [
|
||||
"-l"
|
||||
],
|
||||
"files.simpleDialog.enable": true
|
||||
"editor.tabSize": 2,
|
||||
"editor.rulers": [80],
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.DS_Store": true,
|
||||
"node_modules": true,
|
||||
".meteor/local": true
|
||||
}
|
||||
}
|
||||
@@ -1,977 +0,0 @@
|
||||
# AGENTS.md — instructions Codex reads first
|
||||
|
||||
Codex reads this file at the repo root before doing work here. Follow it.
|
||||
|
||||
## Non-negotiable AI git boundary: commit locally, never push
|
||||
|
||||
An AI assistant may prepare changes and create local commits when the rules below
|
||||
authorize committing. **An AI assistant must never push anything to any remote.** This
|
||||
prohibition has no exception, even when the user asks the AI to release, publish, merge,
|
||||
or "finish" work, and even when credentials or an SSH agent are available. In particular:
|
||||
|
||||
- never run `git push`, including `--force`, `--force-with-lease`, tags, or deletion;
|
||||
- never update remote refs through an API, GitHub CLI, IDE action, or another tool;
|
||||
- never run a script, build-menu option, release command, or alias that performs a push;
|
||||
- never use or probe an SSH agent, credential helper, token, or private key for a push.
|
||||
|
||||
Stop after the local commit and report its hash and the exact human-run push command.
|
||||
All push, merge, release, and publishing instructions elsewhere in this file are for a
|
||||
human maintainer only; they do not authorize an AI assistant to execute remote writes.
|
||||
|
||||
## Where this repository is, and which branch commits go on
|
||||
|
||||
**Which branch depends on WHO is committing, and it is one of exactly two answers.**
|
||||
Work out which you are from the git identity (the next section says how), then:
|
||||
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
contributor — that offer is the mistake, not the rule.
|
||||
|
||||
The maintainer's half is the release flow, not taste. Releases here are frequent —
|
||||
several a day when a fault is being chased — and `releases/release-all.sh` cuts one
|
||||
from whatever is on `main`, taking the version from the `# Upcoming WeKan ® release`
|
||||
section of `CHANGELOG.md`. Work parked on a branch is work that is not in the next
|
||||
release, and `tests/changelogEntriesBelongToTheirRelease.test.cjs` fails when a
|
||||
released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
```
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
For the maintainer, anything but `main` is a reason to stop and say so rather than
|
||||
commit where you are. A side task that was given its own git worktree cannot be on
|
||||
`main` — git allows one checkout per branch — so its work belongs back on `main` in
|
||||
the checkout below before it is committed, not on the worktree's branch.
|
||||
|
||||
Either way the boundary at the top of this file still holds: an AI assistant makes
|
||||
the local commit and stops. Pushing a branch, opening the pull request, and every
|
||||
release step are the human's.
|
||||
|
||||
The checkout is at a fixed place on each operating system:
|
||||
|
||||
| OS | WeKan repository |
|
||||
| --- | --- |
|
||||
| Linux | `~/repos/wekan` |
|
||||
| macOS | `~/Documents/repos/wekan` |
|
||||
| Windows | `Downloads\repos\wekan` (under the user's profile, e.g. `C:\Users\<name>\Downloads\repos\wekan`) |
|
||||
|
||||
The companion repositories under `.tools/` follow the same rule on their OWN default
|
||||
branch, which is not always called `main`: `main-v1` for the FerretDB fork, `devel`
|
||||
for TSC (see the table further down). Committing directly is not permission to push
|
||||
— the boundary above still holds, for every one of them.
|
||||
|
||||
## First: who maintains this, and who is committing?
|
||||
|
||||
**WeKan, the `wekan/` repositories cloned under `.tools/`, and
|
||||
[Secretchronicles/TSC](https://github.com/Secretchronicles/TSC) are all maintained by
|
||||
Lauri Ojansivu (xet7) `<x@xet7.org>`** — [wekan/wekan](https://github.com/wekan/wekan),
|
||||
[wekan/FerretDB](https://github.com/wekan/FerretDB),
|
||||
[wekan/node-patches](https://github.com/wekan/node-patches),
|
||||
[wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches) and TSC,
|
||||
which is under the **Secretchronicles** organisation rather than **wekan** and is his
|
||||
all the same — his GitHub profile, [xet7](https://github.com/xet7), says exactly that:
|
||||
*"WeKan and TSC maintainer"*. Work done on the maintainer's behalf is committed as
|
||||
**`Lauri Ojansivu <x@xet7.org>`** — that author, in every one of those repositories,
|
||||
every time. Two rules follow from it and neither has an exception:
|
||||
|
||||
- **Name the actual participant — and an AI is only ever the participant when no
|
||||
human is behind the change.** One rule, two halves, and the test is simply whether
|
||||
a person made this change or a tool did it on its own:
|
||||
- **A human's AI is not a participant. It is invisible.** When the maintainer or a
|
||||
contributor uses Claude Code, Codex, Copilot as an assistant or any other model
|
||||
to do the work, that person is the author and the tool is not mentioned at all:
|
||||
no `Co-Authored-By:` trailer, no "Generated with", no assistant or model name —
|
||||
not in the commit message, not in a pull-request body, not in the CHANGELOG.
|
||||
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) is where this comes from: *"For pull
|
||||
requests, mention only those participants that are **human**."* A
|
||||
`Thanks to ... and xet7 !` line credits people — the issue reporter and xet7 —
|
||||
never the tool that helped.
|
||||
|
||||
It IS acknowledged, once, in exactly ONE place: the sponsors page,
|
||||
<https://wekan.fi/sponsors>, under *"AI donated by. All code and PRs verified by
|
||||
xet7"*, where **Claude**, **Codex** and **GitHub Copilot** are listed alongside
|
||||
the people and companies that donate hosting, servers, grants and testing to
|
||||
WeKan. That is the whole of the credit and it is deliberate. Attributing it per
|
||||
commit instead put the same fact on thousands of lines and drowned out the
|
||||
humans the entries exist to name, so it was moved to the one page that says who
|
||||
gives WeKan what. Acknowledging it anywhere else is not extra politeness; it is
|
||||
undoing that.
|
||||
- **An AI that raised the pull request ITSELF is the participant, and is named.**
|
||||
GitHub CodeQL filing a security fix, Copilot Autofix, Dependabot raising a
|
||||
dependency bump: nobody wrote those, so crediting a human would be false and
|
||||
crediting nobody would leave the change unattributed. Name the bot, the way the
|
||||
dependency sections below already close with `Thanks to dependabot.`
|
||||
So the same words — "Copilot", say — are forbidden in one commit and required in
|
||||
another, and what decides it is not the word but whether a person is behind the
|
||||
change.
|
||||
- **If the git identity is missing or wrong in one of these checkouts, set it; do not
|
||||
commit under something else.** The `.tools/` clones are made by `build.sh` inside
|
||||
this checkout and can come up with no `user.name`/`user.email` of their own, which
|
||||
would silently author a commit as whatever the machine's default is:
|
||||
|
||||
```
|
||||
git -C .tools/<repo> config user.name 'Lauri Ojansivu'
|
||||
git -C .tools/<repo> config user.email 'x@xet7.org'
|
||||
```
|
||||
|
||||
Check the current git identity before committing or releasing:
|
||||
|
||||
```
|
||||
git config user.name && git config user.email
|
||||
```
|
||||
|
||||
- **Maintainer mode** — the identity is `Lauri Ojansivu <x@xet7.org>` (name
|
||||
`Lauri Ojansivu`, email `x@xet7.org`), or it is unset in a checkout of one of the
|
||||
repositories above, which means it is to be SET to that as above rather than worked
|
||||
around. Then the AI ITSELF makes the commit, and makes it **directly on `main`**
|
||||
(see the section above), authored `Lauri Ojansivu <x@xet7.org>` — not as a
|
||||
co-author, not as a second author, but as that author and no other — with no pull
|
||||
request and with nothing anywhere in the commit message, the CHANGELOG entry or
|
||||
anything else it writes that says an AI was involved. Working for the maintainer
|
||||
is the invisible half of the attribution rule above — the acknowledgement for it
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
- **A person contributing, with an AI helping them.** The person is the author and
|
||||
the tool is not mentioned — the pull request stays free of AI attribution, body
|
||||
and commits alike. Pushing the branch and opening the pull request are that
|
||||
person's own steps; the never-push boundary above means their AI stops at the
|
||||
local commit on the branch.
|
||||
- **An AI contributing on its own** — GitHub CodeQL raising a fix for something it
|
||||
found, Copilot Autofix, Dependabot. There is no human in that pull request at
|
||||
all, so it is named as the contributor it is. This is the one place an AI is
|
||||
credited by name in this project, and it is credited because it is the author,
|
||||
not because it assisted one.
|
||||
|
||||
Maintainer mode covers TSC as well: commit directly to its `devel` branch, no pull
|
||||
request, same author and no AI attribution. What it does NOT bring along is WeKan's
|
||||
house style — TSC keeps its own **GNU ChangeLog** `CHANGELOG` and its own release
|
||||
process, because a project is read on its own terms (see the CHANGELOG section below).
|
||||
The one repository under `.tools/` that is somebody else's is `sandstorm-io/sandstorm`,
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
|
||||
When asked to fix an open issue (one issue at a time):
|
||||
|
||||
1. **Read the whole issue** — the description AND all comments (e.g.
|
||||
`gh issue view <n> --comments`, or the GitHub API `.../issues/<n>/comments`).
|
||||
2. **If it is a bug, first check whether it is already fixed** in the current WeKan
|
||||
AND FerretDB source code (read the actual code, not just changelogs). It may have
|
||||
been fixed already.
|
||||
3. **If it is NOT fixed yet, fix it**, using the newest documentation and issues of
|
||||
the dependencies involved, checking where the newest MAINTAINED dependency lives and
|
||||
reading its source and issues:
|
||||
- Meteor 3 docs — https://docs.meteor.com/
|
||||
- Meteor 3 source + issues — https://github.com/meteor/meteor/issues
|
||||
- other Meteor software docs/code (e.g. Rocket.Chat) for how they solved it
|
||||
- Meteor forums — https://forums.meteor.com
|
||||
- AtmosphereJS — https://atmospherejs.com/ ; Packosphere — https://packosphere.com/
|
||||
- Meteor Community Packages — https://github.com/meteor-community-packages
|
||||
- Meteor-Files — https://github.com/veliovgroup/Meteor-Files
|
||||
- npm — https://www.npmjs.com
|
||||
4. **After fixing, add tests** — unit tests, negative tests, and UI tests where
|
||||
appropriate — and **run the new tests to verify they pass** (see Tests / the sandbox
|
||||
build+test instructions in `docs/Security/Sandboxes/vscodium/README.md`).
|
||||
5. **Commit** (maintainer only — when the current git user is `Lauri Ojansivu
|
||||
<x@xet7.org>`), with no "Co-Authored" / AI trailer, a message body ending:
|
||||
|
||||
```
|
||||
Thanks to (GitHub nickname of the issue creator) and xet7 !
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
Fix from source and test — do not guess. If the environment cannot run the relevant
|
||||
test (e.g. Docker is unavailable in this sandbox), reproduce as closely as possible
|
||||
from source and say clearly what was and was not verified.
|
||||
|
||||
## Translations (Transifex ↔ direct LLM fill, no external service)
|
||||
|
||||
WeKan translations live in `imports/i18n/data/<lang>.i18n.json` (flat
|
||||
`key -> string`, 2-space indent, key order matches `en.i18n.json`). Transifex holds
|
||||
the human translations. **The policy is: never overwrite a human translation in the
|
||||
correct language with a filled (or English) one, but always take the newest correct-
|
||||
language translations from Transifex. Text in another language is not protected: the
|
||||
locale tag is authoritative, and wrong-language or mixed-language values must be
|
||||
replaced with the language named by that tag.**
|
||||
|
||||
- Pull with `releases/translations/pull-translations.sh`. It runs `tx pull -a -f`
|
||||
(which fills every string that is UNtranslated on Transifex with the English source)
|
||||
and then a **per-key merge** (`releases/translations/merge-translations.mjs`) that,
|
||||
for every language file and every key:
|
||||
- Transifex has a real translation in the locale's declared language (pulled value
|
||||
differs from English) → **keep it** (the newest correct-language human translation
|
||||
always wins);
|
||||
- the pull returned English but the pre-pull local file has a translation →
|
||||
**restore the local translation as the fallback**. It may be human or a direct
|
||||
machine/LLM fill; either way it remains local, while a real correct-language
|
||||
Transifex translation takes precedence;
|
||||
- **no translation anywhere** (untranslated on Transifex AND never committed) → leave
|
||||
the English source as a placeholder. **This is the only case a non-human value is
|
||||
used.** A separate fill step may fill *only* these English placeholders, so a filled
|
||||
string can never overwrite a human translation.
|
||||
- After the merge, audit for mixed or wrong-language values. A value that differs from
|
||||
English can still be wrong for its locale; replace it directly as described below.
|
||||
- **The pull workflow never pushes translations to Transifex.** It cannot distinguish a
|
||||
restored human translation from a committed direct fill, so automatic push-back would
|
||||
misrepresent machine/LLM translations as human. Push only separately reviewed,
|
||||
provenance-known human translations with an explicit push command.
|
||||
|
||||
### Filling the remaining untranslated strings — directly, no translation service
|
||||
|
||||
The strings still equal to the English source after the merge are the ones untranslated
|
||||
**everywhere** (Transifex + git). Translate these **directly** — the maintainer or the
|
||||
assistant (an LLM) writes the translation itself, using that language's **existing
|
||||
translations** and general **kanban terminology** for the language as the reference.
|
||||
**Do NOT wire up any external translation service, API, endpoint, key or password** — an
|
||||
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
|
||||
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
|
||||
|
||||
- **EVERY language gets translated — including the ones nobody has volunteered for.**
|
||||
Klingon, Volapük, Wolof, Venda, Tamazight, Acehnese and the rest are not exceptions:
|
||||
WeKan cannot find a speaker for every language, and a file left in English stays in
|
||||
English for years. **Look the words up.** Any dictionary, word list, grammar or
|
||||
Wiktionary page on the Internet is a legitimate source — read it, take the terms it
|
||||
gives, and write the translation from them.
|
||||
- **A locale file must contain its declared language, not whichever language happened
|
||||
to seed it.** If a Mongolian file contains Russian, replace the Russian with
|
||||
Mongolian; likewise replace French, German, Malay, Zulu or any other wrong-language
|
||||
text with the language named by the file's locale tag. This rule applies to isolated
|
||||
mixed-language values and to an entire wrongly seeded file. Wrong-language text is
|
||||
not a human translation for that locale and does not receive human-preference
|
||||
protection. Because `fill-translations.mjs --apply` deliberately writes only English
|
||||
placeholders, make these corrections directly in the locale JSON (preserving key
|
||||
order and formatting), add regression coverage for the correct language/script, and
|
||||
note low-confidence replacements in the commit. Script checks alone are insufficient
|
||||
when both languages share a script, such as Russian and Mongolian; inspect vocabulary
|
||||
as well.
|
||||
- **Placeholders are code, not language. Never translate them.** Any source token that
|
||||
begins and ends with underscores (for example `__board__`, `__card__` or
|
||||
`__username__`) and any format token whose token begins with `%` (for example `%s`,
|
||||
`%d` or `%1$s`) must be copied from the corresponding value in `en.i18n.json`
|
||||
exactly, character for character. Preserve every placeholder, its spelling, case,
|
||||
count and format; translate only the prose around it. If a locale has translated,
|
||||
renamed, removed or malformed one of these tokens, restore the original token from
|
||||
the same key in `en.i18n.json`, even when the surrounding translation is human.
|
||||
Regression coverage for a translation batch must compare its underscore-delimited
|
||||
and percent-prefixed token inventory with English so a translated placeholder cannot
|
||||
pass as ordinary prose.
|
||||
- **Add the languages WeKan does not have yet.** The 154 files under
|
||||
`imports/i18n/data/` are not the list of languages worth supporting - they are the
|
||||
list somebody happened to start. When a language has a dictionary, a word list or a
|
||||
Wiktionary anywhere on the Internet, it can have a WeKan. Adding one is three edits,
|
||||
and all three are needed or the language is invisible:
|
||||
1. `imports/i18n/data/<tag>.i18n.json` — the strings, in `en.i18n.json`'s key ORDER
|
||||
(`tests/boardItemLinks.test.cjs` checks that), 2-space indent.
|
||||
2. `imports/i18n/languages.js` — `{ code, tag, name, load, rtl }`. The **name is
|
||||
written in that language itself** ("Suomi", "tlhIngan Hol"), the way every entry
|
||||
there already is, because it is read by somebody who does not read English. `rtl:
|
||||
true` for Arabic, Hebrew, Persian, Uyghur and the rest that are written
|
||||
right-to-left.
|
||||
3. `client/components/users/userHeader.js` — the `flagMap` in `languageFlag()`, so the
|
||||
picker shows a **flag** beside the name. A language without a country of its own
|
||||
takes the flag of where it is spoken, and a constructed one (Esperanto, Klingon,
|
||||
Volapük) keeps the `🌐` fallback rather than being given somebody's country.
|
||||
- **A bad translation beats no translation.** An imperfect string is readable, it is
|
||||
obviously improvable, and it is an *invitation*: somebody who speaks the language sees
|
||||
it is wrong and fixes it, which an English placeholder never provokes. So: do not skip
|
||||
a language because the result would be imperfect. Translate it, say in the commit which
|
||||
ones were done with low confidence, and let a human correct it — the merge rules below
|
||||
guarantee that when that human translation arrives on Transifex, it REPLACES the filled
|
||||
one and is never overwritten by it.
|
||||
|
||||
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
|
||||
strings still needing translation (English + `en-*` variants are skipped: English by
|
||||
design). Also printed at the end of `pull-translations.sh`.
|
||||
- `node releases/translations/fill-translations.mjs --list <lang> [--limit N]` — dump the
|
||||
untranslated keys of a language as `{ key: englishSource }` for the translator to fill.
|
||||
- `node releases/translations/fill-translations.mjs --apply <lang> <translated.json>` —
|
||||
merge the translations back. It writes **only** into placeholder keys, so it can
|
||||
**never** overwrite a human translation, and a value still equal to English/empty is
|
||||
ignored. Key order and 2-space indent are preserved.
|
||||
|
||||
**Both directions are safe, and it is verified:**
|
||||
`node releases/translations/verify-human-preference.mjs` proves (pure-logic, no network)
|
||||
that a real Transifex translation wins, the pre-pull local translation fills only an
|
||||
English result, a fill only touches placeholders, and the pull script contains no push.
|
||||
Thus **correct-language Transifex human strings are preferred and merged**, remaining
|
||||
keys retain their local human or machine/LLM translations, and nothing restored or
|
||||
filled is pushed to Transifex as if it were human. Wrong-language values are corrected
|
||||
directly after the merge.
|
||||
|
||||
## General practices (from .tools/log/v10/Claude.txt)
|
||||
|
||||
- **[maintainer only]** Commit as `Lauri Ojansivu <x@xet7.org>`, with **no**
|
||||
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
|
||||
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.**
|
||||
(Contributors do the opposite: work on a branch and open a pull request — see the
|
||||
top section.)
|
||||
This is the same rule as the top section, restated where the release work is: one
|
||||
author, `Lauri Ojansivu <x@xet7.org>`, and no AI attribution anywhere.
|
||||
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi), the FerretDB v1 fork, and
|
||||
the two patch repositories under `.tools/` — node-patches and mongo-tools-patches.
|
||||
- Directory structure:
|
||||
- `wekan` — this repo (https://github.com/wekan/wekan); see
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root. It is at
|
||||
`~/repos/wekan` on Linux, `~/Documents/repos/wekan` on macOS and
|
||||
`Downloads\repos\wekan` on Windows. The maintainer commits on `main`; a
|
||||
contributor works on a branch and opens a pull request — see "Where this
|
||||
repository is, and which branch commits go on" above.
|
||||
- `.build/` and `_build/` — the two BUILD directories, generated and
|
||||
gitignored, easy to confuse and different things. `.build/` is the RELEASE
|
||||
bundle (`meteor build .build --directory`; `.build/bundle` is what is
|
||||
deployed, tested and packaged). `_build/` is rspack's compiled output,
|
||||
written by ANY Meteor compile, and Meteor reads the app's main modules from
|
||||
`_build/main-prod/` — so it is a HANDOFF, not a leftover, and must **not**
|
||||
be added to `.meteorignore` (ignoring it breaks the build; that file says
|
||||
why). Never edit or commit either, and SKIP `_build/` in anything that walks
|
||||
the repository: it holds a bundled second copy of every source file, so a
|
||||
tool that reads it sees every file twice and reports the bundler's rewritten
|
||||
code as if it were source. That is not hypothetical — a source-scanning test
|
||||
did exactly that and failed on generated code nobody can edit.
|
||||
- `.tools/wekan.fi` — the WeKan website companion repository.
|
||||
- `.tools/` — everything that is NOT part of this repository but is needed to
|
||||
build, test and release it, in ONE directory that `.gitignore` and
|
||||
`.meteorignore` already exclude (each used to need its own ignore entry at
|
||||
the repo root), so nothing in it can reach a commit or a Meteor rebuild. Two
|
||||
different kinds of thing live there, and the difference matters:
|
||||
|
||||
**Companion git repositories.** Separate repositories with their own history,
|
||||
branches, changelog and release flow — a commit here is never a commit there.
|
||||
`build.sh`'s `ensure_tool_repo` clones one on demand (SSH first, HTTPS
|
||||
second), so a fresh checkout needs no manual setup:
|
||||
|
||||
| Path | Repository | Branch | What it is |
|
||||
| --- | --- | --- | --- |
|
||||
| `.tools/FerretDB` | wekan/FerretDB | `main-v1` | the FerretDB v1 fork WeKan ships as its default database |
|
||||
| `.tools/node-patches` | wekan/node-patches | `main` | patches to upstream Node.js; builds the `node-<platform>` binaries the bundles, the Docker image and the snap embed |
|
||||
| `.tools/mongo-tools-patches` | wekan/mongo-tools-patches | `main` | patches to the MongoDB Database Tools; builds `<tool>-<arch>` |
|
||||
| `.tools/TSC` | Secretchronicles/TSC | `devel` | the game xet7 also maintains — same author and maintainer mode, its own GNU ChangeLog and release process |
|
||||
| `.tools/sandstorm` | sandstorm-io/sandstorm | — | upstream Sandstorm, when present — somebody else's project, cloned for reference |
|
||||
|
||||
**Unpacked toolchains and caches.** Downloads, not repositories — put there by
|
||||
the sandbox instructions in `docs/Security/Sandboxes/vscodium/README.md`, deleted
|
||||
and re-fetched freely, never committed anywhere:
|
||||
`node-v<version>-linux-<arch>/` (the Node.js the test suites are run with),
|
||||
`go/` with `gopath/`, `gomodcache/` and `gocache/` (FerretDB's Go builds),
|
||||
`.meteor/` when `HOME` is pointed at `.tools`, and the `TSC*` AppImage.
|
||||
- **Temporary files belong in `.tools/tmp`, never in `/tmp`.** At the start of
|
||||
work that creates temporary files or runs tools which may create them, make
|
||||
`.tools/tmp` if necessary and export its absolute path as `TMPDIR` before
|
||||
invoking `mktemp`, npm, node-gyp, compilers, test runners or build scripts.
|
||||
Keep task-specific subdirectories beneath it and remove only the exact
|
||||
subdirectories the task created. The host `/tmp` may be a small tmpfs even
|
||||
when the repository filesystem has ample space. On Windows, use
|
||||
`.tools\\tmp` and set both `TEMP` and `TMP` to its absolute path for the
|
||||
command or script. `.tools/` is already excluded by `.gitignore` and
|
||||
`.meteorignore`, so these files cannot enter a commit or trigger Meteor.
|
||||
- **Do NOT add a `CLAUDE.md` or an `AGENTS.md` to any repository under
|
||||
`.tools/`.** node-patches and mongo-tools-patches each had a pair and they were
|
||||
REMOVED on purpose: the rules are the same for every one of these repositories,
|
||||
and a second copy of a rule drifts from the first. THIS file, and its `AGENTS.md`
|
||||
twin beside it, are where they live for all of them. Something true of only one
|
||||
of those repositories goes in that repository's own `README.md` or `docs/`,
|
||||
never in a new instruction file — and if you find one there, remove it rather
|
||||
than updating it.
|
||||
- `.tools/FerretDB` specifics — FerretDB `.go` files must contain **no**
|
||||
application-specific names (say "the client" / "a Meteor 3 driver" / a bare
|
||||
`#NNNN`); its `CHANGELOG.md` may use `wekan/wekan#NNNN`.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
five that come up here:
|
||||
|
||||
| Repository | File | Format |
|
||||
| --- | --- | --- |
|
||||
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
|
||||
| `wekan/node-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming node-patches release` |
|
||||
| `wekan/mongo-tools-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming mongo-tools-patches release` |
|
||||
| `wekan/FerretDB` | `CHANGELOG.md` | **upstream FerretDB's** format, not WeKan's: `## [v1.48.0](tag URL) (YYYY-MM-DD)` and `### New Features 🎉` / `### Fixed 🐛` / `### Other Changes 🤖` bullets ending `by @xet7. Thanks to xet7.` |
|
||||
| `Secretchronicles/TSC` | `CHANGELOG` (no extension) | **GNU ChangeLog** format: a `YYYY-MM-DD Name <email>` header line, then TAB-indented `* Version …` / `* Fix: …` / `* Misc: …` entries, wrapped and continued with further tabs, each ending `(by Name)` |
|
||||
|
||||
The reason is the reader, not consistency for its own sake: a FerretDB release is
|
||||
read beside upstream FerretDB's releases, and a TSC entry beside a decade of GNU
|
||||
ChangeLog entries. A WeKan-shaped `<details>` block in either would be the odd one
|
||||
out and would break the tooling that parses them. When this file and the file being
|
||||
edited disagree, **the file being edited wins** — and everything below in this
|
||||
section is about the WeKan format specifically.
|
||||
- During development, add entries under a new `# Upcoming WeKan ® release` section above
|
||||
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
|
||||
`# Upcoming <repo> release`). Do **not** hand-edit
|
||||
`package.json` or any other version reference — the release workflow bumps those.
|
||||
- **Whenever `# Upcoming WeKan ® release` is updated, audit every feature, bug fix
|
||||
and security fix in that section for regression coverage.** If coverage does not
|
||||
exist yet, add a positive test, a negative test and a UI test wherever that kind
|
||||
of test is possible and relevant. Run the tests that can run in the current
|
||||
environment; when a UI stack or another required service is unavailable, still
|
||||
add and syntax-check/register the test and state clearly what could not be run.
|
||||
Do not duplicate an existing test merely to satisfy the audit — extend it when
|
||||
that keeps one behavior in one suite.
|
||||
- **CHANGELOG.md holds the CURRENT MONTH only.** It reached 2.6 MB and 51,365
|
||||
lines over 1,100 releases back to 2015 (#6580), which is slow to open and
|
||||
slower to read. Moving whole years out left 1.9 MB, still too large, because
|
||||
releases here are frequent: 2026 alone is 272 releases over eight months and
|
||||
July was 80 on its own. So:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `CHANGELOG.md` | the current month, plus `# Status` (Platforms/TODO Later inside it), `# Upcoming` |
|
||||
| `old-CHANGELOG/<year>/<MM>.md` | earlier months of the current year |
|
||||
| `old-CHANGELOG/<year>.md` | years that are over, whole |
|
||||
|
||||
Past years stay one file each because they are already small (30–107 KB);
|
||||
splitting them further would trade a size problem nobody has for a hundred
|
||||
more files. Each archive opens with a **release count** — per month in a year
|
||||
file, per day in a month file — and a bullet in `# Status`'s "Newest WeKan at
|
||||
these platforms" details links every one.
|
||||
That `git blame` is less useful on the split file is accepted: the history is
|
||||
still in git (`gitk`, `git-gui`, `git log --follow`), and being small enough
|
||||
to open is worth more.
|
||||
|
||||
Run `node releases/changelog-archive.mjs` at the start of a month. It is
|
||||
idempotent — a run with nothing to move only refreshes the tables — and it
|
||||
takes the month to keep from the FILE rather than the clock, so two people
|
||||
running it on the same day agree. An archived section is never edited, for the
|
||||
same reason a released one is not.
|
||||
- **The file's shape, top to bottom** — keep it exactly as it is now. There is
|
||||
exactly ONE `#` heading before the releases: `# Status`. Platforms, Version
|
||||
and TODO Later are `<details>` blocks nested inside it, not headings of
|
||||
their own (they used to be `# Platforms` and `# TODO Later`; both were
|
||||
folded under `# Status` so the file opens with a single top-level section
|
||||
rather than three):
|
||||
1. `# Status` — opens with a `<details>` whose `<summary>` is `More status
|
||||
info`, holding a link to <https://wekan.fi/status/>. Then a `<details>`
|
||||
whose `<summary>` is `Newest WeKan at these platforms`, holding the line
|
||||
`Newest WeKan at these platforms:` and the Install / Upgrade / Docs / Mac
|
||||
ChangeLog bullets, plus the `Older releases:` bullet linking the per-year
|
||||
archives. Then a `<details>` whose `<summary>` is `Version` holding
|
||||
"which WeKan version uses what". Then a `<details>` whose `<summary>` is
|
||||
`TODO Later`, itself holding a `<details>` whose `<summary>` is `Carried
|
||||
to a future release.` explaining the list, then one `<details>` per
|
||||
category (below). There is no `# Version` or `# TODO Later` heading of
|
||||
its own.
|
||||
2. The releases, newest first, each `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release`.
|
||||
|
||||
Nothing else is an `#` heading. A `##`/`###` inside a release would break the
|
||||
version list, and a wrapped line that BEGINS with `#` (e.g. an issue number such
|
||||
as `#6514`) becomes a heading too — escape it `\#6514` or keep it off the line
|
||||
start.
|
||||
- **A release's order of subsections** — CRITICAL SECURITY ISSUES first, then new
|
||||
features, then the Admin Panel / UI reorganisation if there is one, then dependency
|
||||
updates (`Thanks to dependabot.`), then bug fixes, then developer-facing changes,
|
||||
documentation and translations. The section ends with the line `Thanks to above
|
||||
GitHub users for their contributions and translators for their translations.`
|
||||
- **Every entry is a `<details>` block, and they are what a release is made of.**
|
||||
The `<summary>` is the SHORT description of what was done and IS the link to the
|
||||
commit — the hash lives in the `href` and is never on the page — followed by the
|
||||
`Thanks to …` sentence. Clicking it reveals the long description:
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/<hash>">Short description of
|
||||
what was done</a>. Thanks to (issue creator's GitHub nickname) and xet7.</summary>
|
||||
|
||||
The long description: what was wrong, why, what it does now, what the test pins.
|
||||
Word-wrapped at 80 characters, ordinary markdown - links, `code`, emphasis.
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Exactly as above: `<details>` and `</details>` each on their own line, the whole
|
||||
`<summary>…</summary>` on ONE line, a blank line under it, the body, a blank line,
|
||||
the close — and a blank line between two blocks.
|
||||
- **The summary is one line at a glance** — aim for ≤ 110 characters — and PLAIN text:
|
||||
no `[text](url)`, no `**bold**`, no backticks inside `<summary>`, because a link
|
||||
cannot nest inside the `<a>` and the rest renders literally. It ends with a full
|
||||
stop, then `Thanks to …`. Every summary in a release links its own commit.
|
||||
- **A change with nothing more to say stays a plain bullet** —
|
||||
`- [Short description](https://github.com/wekan/wekan/commit/<hash>). Thanks to xet7.`
|
||||
— and a dependency batch keeps its `- **package 1.2.3 → 1.2.4** — one line on what
|
||||
it is` bullets, closing with `Thanks to dependabot.` That bot is named for the same
|
||||
reason CodeQL and Copilot Autofix are: it raised those pull requests itself, with no
|
||||
human behind them, so it IS the contributor (see the attribution rule at the top).
|
||||
A `<details>` whose body only
|
||||
repeats its summary is noise; use one when there IS a longer story to reveal, which
|
||||
is most fixes.
|
||||
- **TODO Later's own blocks are the same shape with two differences:** the `<summary>` is
|
||||
the short category text (no `<a>`, because nothing was committed), and there is **no
|
||||
`Thanks to`** — nothing is done yet, so there is nobody to thank. The body lists the
|
||||
issues as `[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)`.
|
||||
- **The hash is never the link text.** `[f1c89548e](…)` shows a hash to a reader who
|
||||
cannot do anything with it; the link text says what changed. Same for `[merge
|
||||
commit](…)` and for a bare URL — see the next rule.
|
||||
- **Never show a long URL as visible text.** A link is always
|
||||
`[short text](url)` — an issue is `[#6524](…/issues/6524)`, an advisory is
|
||||
`[GHSA-xxxx](…)`, a security page is `[ZipBleed](https://wekan.fi/hall-of-fame/zipbleed/)`.
|
||||
- **Subsection headers read as ONE flowing sentence.** The FIRST subsection of a release
|
||||
starts with `This release ` (e.g. `This release fixes the following bugs:`); every LATER
|
||||
subsection in the SAME release starts with a lowercase `and ` instead of repeating
|
||||
`This release ` — e.g.
|
||||
`This release fixes the following bugs:` … `and updates the following dependencies:` …
|
||||
`and has the following developer-tooling fix:` … `and improves the translation workflow:`
|
||||
… then the closing `Thanks to above GitHub users …` line. A release with only one
|
||||
subsection just keeps its single `This release …:` header. Use `and adds the following
|
||||
new features:`, `and fixes the following bugs:`, `and updates the following
|
||||
dependencies:`, etc., matching the verb to the subsection.
|
||||
- **The Upcoming section opens with an `**In short:**` paragraph.** It must be
|
||||
short: put one compact, high-level paragraph directly under the
|
||||
`# Upcoming WeKan ® release` line. Aim for 2–4 sentences and no more than about
|
||||
120 words, however many commits the release contains. Name only the release's
|
||||
major outcomes or themes, with notable names in `**bold**` so it can be skimmed.
|
||||
It is not a commit inventory, progress log or miniature changelog: do not list
|
||||
batches, per-language counts, individual test fixes or every touched component,
|
||||
do not link commits, and do not repeat entry wording. The topic groups and their
|
||||
`<details>` blocks below carry that information. Keep the paragraph current as
|
||||
topics change, and shorten it when added commits make it grow. A finished release
|
||||
keeps the paragraph it was written with.
|
||||
- **No release section carries a Platform/Binary/From/Version/SHA256 table, and
|
||||
none ever should again.** It used to sit right under the `**In short:**`
|
||||
paragraph, then moved to a `**Binaries in these bundles:**` label at the end
|
||||
of the section — both tried and both removed, because CHANGELOG.md is not
|
||||
where that data belongs: it made every release's entry mostly a giant table
|
||||
nobody read, on top of what `<details>` entries already say. "Which Node.js
|
||||
is in the arm64 bundle of 10.69, and was it checked" is answered by the
|
||||
**GitHub Release notes** instead: `releases/provenance-table.sh` puts the
|
||||
same table at the TOP of those, built fresh from the `provenance.tsv` rows
|
||||
each build job records (see `releases/record-provenance.sh`), every time a
|
||||
release is made — so it is never stale and never needs hand-editing into
|
||||
CHANGELOG.md. Adding a binaries table back into a CHANGELOG entry, in any
|
||||
shape, is reintroducing something the maintainer removed on purpose.
|
||||
- **Inside a subsection, entries are GROUPED BY TOPIC/AREA.** A release touches a handful
|
||||
of areas and repeating the area's name in every summary is the noise this
|
||||
removes — twelve entries that each begin "All Boards:" say "All Boards" twelve
|
||||
times and the part that differs starts halfway through the line. Instead the area
|
||||
is named ONCE, as a bold line with a short description of what the group covers,
|
||||
and every entry under it drops the prefix:
|
||||
|
||||
```
|
||||
and fixes the following bugs:
|
||||
|
||||
**The first header bar** - how it lays itself out, and what sits under it.
|
||||
|
||||
<details>
|
||||
<summary><a href="…">It fills each row before starting the next one</a>. Thanks to xet7.</summary>
|
||||
…
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="…">It wraps to a second row instead of hiding the buttons that do not fit</a>. Thanks to xet7.</summary>
|
||||
…
|
||||
</details>
|
||||
|
||||
**All Boards** - the overview and its search.
|
||||
|
||||
<details>
|
||||
…
|
||||
```
|
||||
|
||||
The group line is `**Topic** - short summary.` on ONE line where practical,
|
||||
wrapped at 80 like everything else, with a blank line under it. This line is the
|
||||
high-level summary of ALL commits in that topic: say what changed and why it
|
||||
matters without copying a commit summary or enumerating implementation details.
|
||||
The `<summary>` under it gives each commit's short outcome, and the expanded
|
||||
`<details>` body carries the implementation, rationale and test evidence. In
|
||||
other words, the release reads from release summary → topic summary → commit
|
||||
detail, with each level adding information instead of repeating the level above.
|
||||
The group line is NOT a heading: a `##` inside a release breaks the version list
|
||||
(see above). Group labels are the areas of the
|
||||
app — `All Boards`, `The first header bar`, `The left menus`, `The Admin Panel`,
|
||||
`Board views`, `Public Boards`, `Member Settings`, `Board roles` — and the same
|
||||
label is reused across subsections when an area has both a feature and a fix.
|
||||
EVERY entry of a grouped subsection belongs to a group, including a group with
|
||||
one entry: a section that is half grouped and half loose reads as a mistake. Put
|
||||
each commit under the topic it actually changes; do not create a chronological
|
||||
catch-all group. A subsection with only one entry, and the dependency bullets,
|
||||
stay flat.
|
||||
- **CRITICAL security header — match the previous releases' wording.** A security release
|
||||
leads with `This release fixes the following CRITICAL SECURITY ISSUE of
|
||||
[Name](https://wekan.fi/hall-of-fame/namebleed/):` for a single named *Bleed, or
|
||||
`This release fixes the following CRITICAL SECURITY ISSUES:` for several — NOT
|
||||
`CRITICAL VULNERABILITIES`. Each security bullet still links its own
|
||||
`https://wekan.fi/hall-of-fame/...bleed/`. CodeQL-only releases use the established
|
||||
`This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:`.
|
||||
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
|
||||
subsection becomes `and …` per the rule above.
|
||||
- **TODO Later** — a `<details>` nested inside `# Status` near the TOP of
|
||||
`CHANGELOG.md` (above the version sections), a triage backlog for open
|
||||
issues that were **investigated but not fixed here**, each
|
||||
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
|
||||
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
|
||||
ending `Fixes #NNNN,`), **close it** if already fixed in current code (commit `Close #NNNN` /
|
||||
`Fixes #NNNN,`), or — when it can't be fixed/verified in this environment — **add it to `TODO
|
||||
Later` under the matching category with the reason**. Group issues by category bullet, e.g.:
|
||||
*Need specific infrastructure / a running server stack* (LDAP/WebHooks/Sandstorm/proxy —
|
||||
environment owners), *Need the running app to reproduce/verify* (runtime UI / publication /
|
||||
mergebox / router state, not unit-testable), *Already correct in the current code* (verified by
|
||||
reading; could not reproduce), *Feature requests / behaviour-by-design rather than bugs*, and
|
||||
*Needs a maintainer decision on the intended contract*. Each category is a `<details>`
|
||||
whose `<summary>` is the SHORT category text and whose body lists the issues with their
|
||||
reasons — and with **no `Thanks to` line**, because nothing is done yet, so there is
|
||||
nobody to thank. Each entry is a normal issue link
|
||||
`[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)` — issue links here, NOT
|
||||
commit links (nothing was committed). **Keep it current:** when an issue in `TODO Later` gets
|
||||
fixed, REMOVE it from the list (its fix commit's `Fixes #NNNN` closes it); do not leave fixed
|
||||
issues in the backlog. This is the only CHANGELOG place that uses issue links and lives above
|
||||
the releases; everything else uses the per-release commit-link bullets above.
|
||||
- FerretDB Upcoming structure — `### New Features 🎉`, `### Fixed 🐛`, `### Other Changes
|
||||
🤖`; entries end `... by @xet7. Thanks to xet7.`
|
||||
- Word-wrap both CHANGELOGs at 80 chars, but never break a long link across lines (a
|
||||
`<summary>` line, or any other line carrying a link, may be longer). Continuation
|
||||
lines of a plain bullet are indented by two spaces.
|
||||
|
||||
### Commit message structure
|
||||
|
||||
```
|
||||
Do something.
|
||||
|
||||
Thanks to (original creator of issue) and xet7 !
|
||||
|
||||
Fixes #1234,
|
||||
Fixes #1235.
|
||||
```
|
||||
|
||||
- **A commit that fixes a GitHub issue ENDS with `Fixes #NNNN`** — last lines of
|
||||
the message, one per issue, comma after each and a full stop on the last. That
|
||||
trailer is what closes the issue when the commit is pushed; a fix that only
|
||||
*mentions* the number in prose leaves the issue open, and somebody has to close
|
||||
it by hand later or it stays open forever. Several issues in one commit get
|
||||
several lines. The same applies to an issue that turns out to be **already
|
||||
fixed**: commit the test or the note that proves it and end with `Fixes #NNNN,`
|
||||
so the issue closes with a reference to where it was fixed.
|
||||
- **Only when the commit really fixes it.** A commit that improves the
|
||||
diagnostics, narrows the cause, or fixes one of several reported problems
|
||||
references the issue in the body (`#6585 comment 5276581923`) and does NOT
|
||||
carry the trailer — closing an issue whose reporter is still stuck is worse
|
||||
than leaving it open. If an issue was closed and then REOPENED for follow-up
|
||||
items (as #6586 was), it is fixed again only when those items are done.
|
||||
- **A commit with no issue behind it has no trailer** — the email reports, the
|
||||
release-tooling fixes found in build logs, refactors. `Thanks to ... and xet7 !`
|
||||
still names whoever reported it.
|
||||
|
||||
### Making a release — no version number needed **[human maintainer only — never AI]**
|
||||
|
||||
All publishing / release steps below are human-maintainer-only. Contributors and AI
|
||||
assistants never run them. An AI may prepare a local release commit only when requested,
|
||||
then must stop and hand the commit and command sequence to the human maintainer.
|
||||
|
||||
**Releases are FREQUENT, and that is the normal state of this repository — not an
|
||||
interruption to it.** The maintenance loop is:
|
||||
|
||||
1. `./build.sh` (or `build.bat` on Windows) → **option 1, "git pull and git
|
||||
push"**. Both directions do the whole job: a pull rebases and then repairs the
|
||||
CHANGELOG commit links the rebase made stale, and a push repairs them again
|
||||
BEFORE publishing, because a stale link that reaches GitHub 404s for everyone
|
||||
who reads the release notes.
|
||||
2. `./releases/release-all.sh`, **with no arguments**, whenever there is something
|
||||
worth shipping — which is often, several times a day when a fault is being
|
||||
chased. It takes the version from the CHANGELOG and needs nothing typed.
|
||||
|
||||
So a release happening "in the middle" of a piece of work is not a special case
|
||||
to reason about; it is what always happens, and anything that only works when
|
||||
releases are rare is broken here. Two consequences worth stating, because both
|
||||
have cost a released section its accuracy:
|
||||
|
||||
- **Work continues immediately after a release**, so an entry written right
|
||||
after one has to go somewhere. `release-all.sh` renames `# Upcoming WeKan ®
|
||||
release` to `# v<NEW> …` and stops there — it does **not** create a new,
|
||||
empty `# Upcoming` section. CHANGELOG.md never carries an
|
||||
`**In short:** nothing here yet.` placeholder sitting between releases; it
|
||||
used to (via a now-deleted `releases/changelog-open-next.mjs`), and that
|
||||
meant the file always had a section saying nothing, from the moment a
|
||||
release was cut until the first real entry replaced it. Instead: **add `#
|
||||
Upcoming WeKan ® release` yourself, by hand, the moment you have a real
|
||||
entry for it** — not before — using the skeleton at
|
||||
[docs/DeveloperDocs/Changelog-Upcoming-Template.md](docs/DeveloperDocs/Changelog-Upcoming-Template.md).
|
||||
An entry appended above the closing `Thanks to above GitHub users …` line
|
||||
with no Upcoming section yet lands INSIDE the release just published — the
|
||||
same mistake the auto-created section used to prevent — but it is caught
|
||||
regardless: `tests/changelogEntriesBelongToTheirRelease.test.cjs` asks git
|
||||
which commits a release actually contains and fails when a section links
|
||||
one that is not an ancestor of that release, whether or not an Upcoming
|
||||
heading existed first. Run that test (or `build.sh`'s node-suite option)
|
||||
before committing a CHANGELOG entry written right after a release.
|
||||
- **A released section is a RECORD, not a draft.** When a release turns out to be
|
||||
broken, its section keeps saying what it shipped — including the part that was
|
||||
wrong — and the fix goes in a new `# Upcoming` above it. Do NOT edit a
|
||||
published entry to describe the smaller, tidier change you wish had shipped;
|
||||
add to it that it was wrong and where the fix is. Somebody reading v10.97's
|
||||
notes is most likely somebody whose v10.97 just died.
|
||||
|
||||
- WeKan: run `./releases/release-all.sh` (no arguments). It renames
|
||||
`# Upcoming WeKan ® release` to the next version (same increment as the last release;
|
||||
9.99 → 10.00) dated today, commits + pushes, and triggers
|
||||
`.github/workflows/release-all.yml` — whose `bump` job bumps `package.json` and every
|
||||
version reference and rebuilds the API docs, whose `prepare` job then pushes the tag
|
||||
`v<new>` and checks the notes exist, and whose `release` job publishes the GitHub
|
||||
Release the bundle jobs attach to. An explicit `oldversion newversion` pair still
|
||||
overrides. Adding entries under Upcoming is the only hand step.
|
||||
- **A release that FAILED and one that shipped BROKEN are handled differently, and
|
||||
the difference is whether anything was PUBLISHED.** Both happened with v10.92, so
|
||||
neither is hypothetical:
|
||||
- **Nothing published** (the workflow died before the GitHub Release existed): there
|
||||
is no release for that number, so `# v<new> …` must not claim there is one. Rename
|
||||
the heading back to `# Upcoming WeKan ® release`, add the fix under it, and run
|
||||
`release-all.sh` again — it will take the same number, since the newest release is
|
||||
still the previous one.
|
||||
- **It published, and something in it is broken**: the release exists and people can
|
||||
download it, so its section STAYS as it is — a released section is a record, not a
|
||||
draft. Add a NEW `# Upcoming WeKan ® release` above it with the fix, exactly as
|
||||
during ordinary development, and the next run takes the next number.
|
||||
What decides it is the GitHub Release, not the tag: `prepare` pushes the tag early,
|
||||
so a tag can exist for a release that never published.
|
||||
- FerretDB: run `./build.sh release-ferretdb` from `.tools/FerretDB` (no
|
||||
version). It renames `## Upcoming FerretDB release` to the next version with the
|
||||
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
|
||||
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
|
||||
already-tagged version.
|
||||
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
reported.** A test that pins one call site leaves the same mistake free to live in
|
||||
the other five, and that is how most of these arrive: SignupBleed's guard read an
|
||||
option nothing sets, and the same shape sat in a second endpoint; the source-map
|
||||
trim was safe on the client and fatal on the server. So:
|
||||
- the **test** proves the fix does what it claims, driving the decision itself
|
||||
where that is possible. A pure module — `loginFailureDecision.js`,
|
||||
`lockoutDecision.js` — can be tested as arithmetic, without a server or a
|
||||
database, and reproduces the reporter's attack exactly rather than approximately;
|
||||
- the **negative test** proves the fault is gone rather than moved. Search the
|
||||
whole tree for the SHAPE of it and assert nothing matches: no other endpoint
|
||||
reads the dead option, no other counter is global, no other caller skips the
|
||||
check. When the shape is a pattern, pin the pattern.
|
||||
- and a test that reads the source is a real test here. `tests/*.test.cjs` may
|
||||
parse a file and fail on a construct — that is what makes "and nowhere else"
|
||||
checkable at all.
|
||||
- **If somebody ATTEMPTS the attack, and the secure default DENIES it, that denial
|
||||
has to be visible in Admin Panel → Problems** — in every case where the denial can
|
||||
be attributed. This applies to every security fix added to Upcoming. That is the
|
||||
difference between a hole that is closed and a hole that is closed and watched:
|
||||
an administrator should be able to see that somebody tried.
|
||||
- Add a key to the catalog in `models/lib/securityCategories.js` (category, the
|
||||
hall-of-fame `bleed` name, severity, CWE), and call
|
||||
`require('/server/lib/securityLog').record({ key, action: 'blocked', source, detail })`
|
||||
on the refusal path. `action` is `'blocked'` when the fix stopped it and
|
||||
`'detected'` when it was only noticed.
|
||||
- **Wrap the call so logging can never break the guard**:
|
||||
`try { ... } catch (e) { /* logging must never break the guard */ }`. The refusal
|
||||
matters more than the record of it.
|
||||
- **Only log an ATTEMPT, never ordinary use.** The test to apply is whether a
|
||||
legitimate user can reach that line. Registration refused while registration is
|
||||
off has no legitimate caller, so it is logged; an admin endpoint whose fault was
|
||||
in what its answer CARRIED fires on every normal call, so it is NOT — that is why
|
||||
HashBleed (GHSA-6qpx-x7vr-p9w6) deliberately has no key. A log that fills with
|
||||
normal traffic hides the one line that mattered.
|
||||
- Where the denial cannot be attributed to an attempt — a fix that changes what a
|
||||
response contains, or one that only takes effect at build time — there is nothing
|
||||
to record, and that is a decision to state in the entry rather than an omission.
|
||||
- **Admin Panel → Problems is a SUMMARY, never a row per event.** A guard on a path
|
||||
an attacker controls fires as fast as they can send, so a document per occurrence
|
||||
grows the database with the attack, turns the page into a scroll of near-identical
|
||||
lines, and buries the one event that mattered under ten thousand that did not. The
|
||||
admin's question is never *list every attempt* — it is *what is happening, how
|
||||
much, since when, and who*. So each problem is ONE row that accumulates
|
||||
(`models/lib/eventLogSummary.js`, written through `server/lib/eventLogFold.js`,
|
||||
shared by the security, speed and test loggers):
|
||||
|
||||
| field | what it holds |
|
||||
| --- | --- |
|
||||
| `count` | how many times this problem has happened |
|
||||
| `firstAt` … `at` | the window it happened in |
|
||||
| `actors` | who, each with their own count — `username1 25, 100.100.100.100 30` |
|
||||
| `username` / `ip` / `detail` | the MOST RECENT occurrence |
|
||||
|
||||
- **Identity is the KIND of thing that happened** — stream, `bleed`, category,
|
||||
action, source, severity, CWE (and `type`/`db`/`kind` for the database stream).
|
||||
The actor is NOT part of it: putting a username or an address in the key gives a
|
||||
row per attacker per attempt, which is the cost being removed.
|
||||
- **A username and an address are tallied SEPARATELY**, not as a pair. They answer
|
||||
different questions — which account, and where from — and an unauthenticated
|
||||
attempt has an address and no name.
|
||||
- **The tally is CAPPED** (`MAX_ACTORS`), with the remainder counted in
|
||||
`actorsOverflow`. Otherwise an attacker rotating addresses grows the row with the
|
||||
attack and reintroduces the same bug one level down — and *"and 9,412 others"* is
|
||||
itself the signal that the source is spread rather than single.
|
||||
- **A logger that writes per event is a bug to fix, not a style.** If one is added
|
||||
or found, route it through the shared fold and migrate what it already wrote
|
||||
(`server/lib/eventLogSummaryMigration.js` folds legacy rows in place, in batches,
|
||||
idempotently).
|
||||
- **Every hall-of-fame vulnerability that CAN be detected at runtime should have a
|
||||
catalog key**, so Admin Panel → Problems groups attempts under the same name the
|
||||
Hall of Fame uses and an admin can go from one to the other.
|
||||
`tests/hallOfFameProblemsCoverage.test.cjs` lists the names that have no key and
|
||||
requires each to be accounted for — a *Bleed with nothing to detect (a fix that
|
||||
changed what a response carried, or one that only applies at build time) is
|
||||
recorded there as a deliberate omission with its reason, not left silent.
|
||||
- **A Hall of Fame row has EIGHT cells**, in this order: CVE, Icon, Vulnerability
|
||||
name, Date, Responsible Security Disclosure by, Stars, Process, Vulnerabilities.
|
||||
One thing per cell — the name without its icons, the Font Awesome icons alone in
|
||||
Icon (still wrapped in `<h2>`), the reporter alone in "by", the `GoldStar.png`
|
||||
images alone in Stars, and the sentence about how the report arrived (coordinated
|
||||
disclosure, GitHub advisory, code scanning, found while reviewing …) in Process.
|
||||
In "by", the reporter's NICKNAME is the link —
|
||||
`<b><a href="https://github.com/nick">nick</a></b>`, never the name followed by a
|
||||
separate "(GitHub)" link. **Check the account exists before linking it** (a 404 or,
|
||||
worse, somebody else's profile is the failure here); a real name, a company or a
|
||||
tool such as GitHub CodeQL is not a GitHub user and stays plain text. Do not write
|
||||
a role note after a name — no `(fix)`, `(found)` or `(found and fix)`: this column
|
||||
is the credit, and the Process cell beside it already says how the report arrived.
|
||||
Stars is a column so it can be compared down the page; do not put a star, an icon
|
||||
or the process sentence back into another cell. A page-local `<style>` in
|
||||
`index.html` keeps a row's stars on ONE line from 900px up (they are a count) and
|
||||
sizes them at 24px, and keeps the icon and its red drop on one line at every width
|
||||
(they are one icon) — the table itself stays percentage-sized with no fixed or
|
||||
minimum width, so it fills the page at every browser width.
|
||||
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
|
||||
collapsed.** Those two columns carry the prose — how the report arrived, and the
|
||||
whole story of the vulnerability — so the page would be one wall of text and the
|
||||
columns that identify a row (CVE, name, date, reporter, stars) would be far apart.
|
||||
In `hall-of-fame/index.html`, and ONLY in those two `<td>`s, the cell's content
|
||||
sits inside a `<details>` whose summary is the column's own name — `Process` for
|
||||
the Process cell, `Details` for the Vulnerabilities cell:
|
||||
|
||||
```
|
||||
<td valign="top">
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
<ul>
|
||||
<li>… the same list as before …</li>
|
||||
</ul>
|
||||
</details>
|
||||
</td>
|
||||
```
|
||||
|
||||
The summary is that plain word, there is a blank line under it, and the cell's
|
||||
contents are otherwise unchanged. The other six cells of the row stay as they are
|
||||
— they are what a reader scans — and so does the header row.
|
||||
- **The vuln-name subdirectory page is NOT collapsed.** `hall-of-fame/<name>bleed/index.html`
|
||||
is the page a reader opened on purpose, for one vulnerability: its table cell stays
|
||||
open and its `<h2 class="hof">Details</h2>` prose section below the table stays as
|
||||
it is. Only the index, which lists them all, hides them behind the summary.
|
||||
|
||||
### Tests
|
||||
|
||||
- `build.sh` / `build.bat` have a menu to install dependencies, build
|
||||
WeKan and run tests.
|
||||
- Add tests, negative tests and UI tests for all new features and fixes that do not yet
|
||||
have tests. When adding a test, run or validate it and fix it until it works.
|
||||
- **"Check newest test logs":** test logs go into a `log/<datetime>/` directory,
|
||||
one per run (e.g. `2026-07-21_20-58-09/`). That is **`.tools/log/`** inside the
|
||||
repository's ignored tool area, so a run does not show up in `git status` and
|
||||
does not depend on the parent directory being writable. The
|
||||
newest datetime directory is the latest run. Each holds the Playwright per-browser
|
||||
logs (`wekan-alltests-chromium.log`, `-firefox.log`, `-webkit.log`), the mocha/unit
|
||||
log (`wekan-alltests-mocha.log`), the e2e/import logs, and `wekan-test-server.log`
|
||||
(the WeKan test server + database output). A whole-run directory also holds the
|
||||
database-conformance logs (`db-conformance-*.log`, `-report.md`, `-summary.txt`)
|
||||
and FerretDB's own (`ferretdb-unit.log`, `-vet.log`, `-integration.log`). "Check
|
||||
the newest test logs" means: open the most recent
|
||||
`.tools/log/<datetime>/` and read those.
|
||||
- **Check and fix WHILE the tests are still running.** A full run takes a long time
|
||||
(three browsers, then every database with an image for this CPU, then FerretDB's
|
||||
own suites), and its stages finish one at a time. Do not wait for the end: read
|
||||
the logs that are already written, fix what they show, and then look again for the
|
||||
stages that have finished since — repeat until every stage has run and everything
|
||||
found is fixed. Two things make this work:
|
||||
- A stage's log file is complete when its `===== ... finished` line is there; a
|
||||
file whose mtime is still moving is a stage in progress, and its failures so far
|
||||
are already real and worth fixing.
|
||||
- The node suites are run by `tests/run-node-suites.cjs`, which runs ALL of them
|
||||
and lists every failure at the end (`===== node suites: N run, M failed`). They
|
||||
are the fastest signal - about 15 seconds for 260 suites - so they are usually
|
||||
what to fix first while the browsers are still going.
|
||||
- **A failing guard is not automatically a broken app.** Most of these suites read
|
||||
the source and pin a behaviour. When one fails, decide which side is wrong: fix
|
||||
the CODE when the guard still describes what WeKan should do, and fix the GUARD
|
||||
when the behaviour deliberately changed - and then say in the test WHY, so the
|
||||
next reader knows it was a decision and not a slip. Every fixed test keeps the
|
||||
assertion that made it valuable; do not delete a test to make a run green.
|
||||
|
||||
## Environment
|
||||
|
||||
- The editor (VSCodium) runs inside a **Flatpak sandbox**, launched by
|
||||
`docs/Security/Sandboxes/vscodium/vscodium-sandbox.sh`. What the sandbox allows/blocks and
|
||||
how it is set up is documented in `docs/Security/Sandboxes/vscodium/README.md` in that
|
||||
same directory — read it when something behaves differently than a normal host (file
|
||||
access, network, running services).
|
||||
|
||||
### Flatpak sandbox: install task tools under `.tools/`
|
||||
|
||||
- In the Flatpak sandbox launched by
|
||||
`docs/Security/Sandboxes/vscodium/vscodium-sandbox.sh`,
|
||||
the repository may be the only writable/shared host directory. When a command
|
||||
needs a tool that is missing, install its binary, virtual environment, models
|
||||
and caches under the repository-local `.tools/` directory. Do not install it
|
||||
into the read-only Flatpak image or scatter generated tool files through the
|
||||
source tree. `.tools/` is excluded by both `.gitignore` and `.meteorignore`, so
|
||||
local toolchains neither enter commits nor consume Meteor file-watcher slots.
|
||||
- Use the version selected by the repository, resolved from the current files at
|
||||
install time; never copy a version number from an old log or hard-code the
|
||||
example currently shown in the sandbox README. In particular: Node.js and npm
|
||||
come from `NODE_VERSION` and `NPM_VERSION` in `Dockerfile`; Meteor comes from
|
||||
`.meteor/release`; companion repositories use the versions in their own build
|
||||
scripts or module files. Use the architecture reported by `uname -m`. Reuse an
|
||||
existing matching `.tools` installation before downloading another copy.
|
||||
- Follow the complete, tested bootstrap commands and environment variables in
|
||||
`docs/Security/Sandboxes/vscodium/README.md`, especially its "Reuse same
|
||||
in-sandbox toolchain" and test-runtime sections. Prefer repository setup
|
||||
helpers such as
|
||||
`build.sh` and `releases/ensure-tools.sh` when they already install the needed
|
||||
pinned tool. Keep `HOME`, `PATH`, `GOROOT`, `GOPATH`, `GOCACHE`, `GOMODCACHE`
|
||||
and similar overrides scoped to the command or sandbox terminal; do not change
|
||||
the real host home or a system installation.
|
||||
- For screenshot or image text that cannot be read by the normal image viewer
|
||||
because Flatpak/bubblewrap cannot create a user namespace, install RapidOCR
|
||||
locally instead of guessing from filenames or logs:
|
||||
|
||||
```bash
|
||||
python3 -m venv .tools/ocr-venv
|
||||
.tools/ocr-venv/bin/pip install rapidocr_onnxruntime pillow
|
||||
.tools/ocr-venv/bin/python -c 'from rapidocr_onnxruntime import RapidOCR; import sys; result, _ = RapidOCR()(sys.argv[1]); print("\n".join(row[1] for row in (result or [])))' path/to/screenshot.png
|
||||
```
|
||||
|
||||
Process a timestamped screenshot series in chronological order and correlate
|
||||
OCR output with the matching `.tools/log/<datetime>/` files. OCR is evidence
|
||||
with recognition errors: retain timestamps and coordinates when needed, verify
|
||||
suspicious numbers against adjacent frames, and do not claim visual details
|
||||
that neither OCR nor another available viewer confirmed.
|
||||
|
||||
### Always validate from the actual code
|
||||
|
||||
- When doing anything, check how it actually works in the code first.
|
||||
+3352
-6105
File diff suppressed because it is too large.
Load diff
@@ -1,977 +0,0 @@
|
||||
# CLAUDE.md — instructions Claude reads first
|
||||
|
||||
Claude Code reads this file at the repo root before doing work here. Follow it.
|
||||
|
||||
## Non-negotiable AI git boundary: commit locally, never push
|
||||
|
||||
An AI assistant may prepare changes and create local commits when the rules below
|
||||
authorize committing. **An AI assistant must never push anything to any remote.** This
|
||||
prohibition has no exception, even when the user asks the AI to release, publish, merge,
|
||||
or "finish" work, and even when credentials or an SSH agent are available. In particular:
|
||||
|
||||
- never run `git push`, including `--force`, `--force-with-lease`, tags, or deletion;
|
||||
- never update remote refs through an API, GitHub CLI, IDE action, or another tool;
|
||||
- never run a script, build-menu option, release command, or alias that performs a push;
|
||||
- never use or probe an SSH agent, credential helper, token, or private key for a push.
|
||||
|
||||
Stop after the local commit and report its hash and the exact human-run push command.
|
||||
All push, merge, release, and publishing instructions elsewhere in this file are for a
|
||||
human maintainer only; they do not authorize an AI assistant to execute remote writes.
|
||||
|
||||
## Where this repository is, and which branch commits go on
|
||||
|
||||
**Which branch depends on WHO is committing, and it is one of exactly two answers.**
|
||||
Work out which you are from the git identity (the next section says how), then:
|
||||
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
contributor — that offer is the mistake, not the rule.
|
||||
|
||||
The maintainer's half is the release flow, not taste. Releases here are frequent —
|
||||
several a day when a fault is being chased — and `releases/release-all.sh` cuts one
|
||||
from whatever is on `main`, taking the version from the `# Upcoming WeKan ® release`
|
||||
section of `CHANGELOG.md`. Work parked on a branch is work that is not in the next
|
||||
release, and `tests/changelogEntriesBelongToTheirRelease.test.cjs` fails when a
|
||||
released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
```
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
For the maintainer, anything but `main` is a reason to stop and say so rather than
|
||||
commit where you are. A side task that was given its own git worktree cannot be on
|
||||
`main` — git allows one checkout per branch — so its work belongs back on `main` in
|
||||
the checkout below before it is committed, not on the worktree's branch.
|
||||
|
||||
Either way the boundary at the top of this file still holds: an AI assistant makes
|
||||
the local commit and stops. Pushing a branch, opening the pull request, and every
|
||||
release step are the human's.
|
||||
|
||||
The checkout is at a fixed place on each operating system:
|
||||
|
||||
| OS | WeKan repository |
|
||||
| --- | --- |
|
||||
| Linux | `~/repos/wekan` |
|
||||
| macOS | `~/Documents/repos/wekan` |
|
||||
| Windows | `Downloads\repos\wekan` (under the user's profile, e.g. `C:\Users\<name>\Downloads\repos\wekan`) |
|
||||
|
||||
The companion repositories under `.tools/` follow the same rule on their OWN default
|
||||
branch, which is not always called `main`: `main-v1` for the FerretDB fork, `devel`
|
||||
for TSC (see the table further down). Committing directly is not permission to push
|
||||
— the boundary above still holds, for every one of them.
|
||||
|
||||
## First: who maintains this, and who is committing?
|
||||
|
||||
**WeKan, the `wekan/` repositories cloned under `.tools/`, and
|
||||
[Secretchronicles/TSC](https://github.com/Secretchronicles/TSC) are all maintained by
|
||||
Lauri Ojansivu (xet7) `<x@xet7.org>`** — [wekan/wekan](https://github.com/wekan/wekan),
|
||||
[wekan/FerretDB](https://github.com/wekan/FerretDB),
|
||||
[wekan/node-patches](https://github.com/wekan/node-patches),
|
||||
[wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches) and TSC,
|
||||
which is under the **Secretchronicles** organisation rather than **wekan** and is his
|
||||
all the same — his GitHub profile, [xet7](https://github.com/xet7), says exactly that:
|
||||
*"WeKan and TSC maintainer"*. Work done on the maintainer's behalf is committed as
|
||||
**`Lauri Ojansivu <x@xet7.org>`** — that author, in every one of those repositories,
|
||||
every time. Two rules follow from it and neither has an exception:
|
||||
|
||||
- **Name the actual participant — and an AI is only ever the participant when no
|
||||
human is behind the change.** One rule, two halves, and the test is simply whether
|
||||
a person made this change or a tool did it on its own:
|
||||
- **A human's AI is not a participant. It is invisible.** When the maintainer or a
|
||||
contributor uses Claude Code, Codex, Copilot as an assistant or any other model
|
||||
to do the work, that person is the author and the tool is not mentioned at all:
|
||||
no `Co-Authored-By:` trailer, no "Generated with", no assistant or model name —
|
||||
not in the commit message, not in a pull-request body, not in the CHANGELOG.
|
||||
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) is where this comes from: *"For pull
|
||||
requests, mention only those participants that are **human**."* A
|
||||
`Thanks to ... and xet7 !` line credits people — the issue reporter and xet7 —
|
||||
never the tool that helped.
|
||||
|
||||
It IS acknowledged, once, in exactly ONE place: the sponsors page,
|
||||
<https://wekan.fi/sponsors>, under *"AI donated by. All code and PRs verified by
|
||||
xet7"*, where **Claude**, **Codex** and **GitHub Copilot** are listed alongside
|
||||
the people and companies that donate hosting, servers, grants and testing to
|
||||
WeKan. That is the whole of the credit and it is deliberate. Attributing it per
|
||||
commit instead put the same fact on thousands of lines and drowned out the
|
||||
humans the entries exist to name, so it was moved to the one page that says who
|
||||
gives WeKan what. Acknowledging it anywhere else is not extra politeness; it is
|
||||
undoing that.
|
||||
- **An AI that raised the pull request ITSELF is the participant, and is named.**
|
||||
GitHub CodeQL filing a security fix, Copilot Autofix, Dependabot raising a
|
||||
dependency bump: nobody wrote those, so crediting a human would be false and
|
||||
crediting nobody would leave the change unattributed. Name the bot, the way the
|
||||
dependency sections below already close with `Thanks to dependabot.`
|
||||
So the same words — "Copilot", say — are forbidden in one commit and required in
|
||||
another, and what decides it is not the word but whether a person is behind the
|
||||
change.
|
||||
- **If the git identity is missing or wrong in one of these checkouts, set it; do not
|
||||
commit under something else.** The `.tools/` clones are made by `build.sh` inside
|
||||
this checkout and can come up with no `user.name`/`user.email` of their own, which
|
||||
would silently author a commit as whatever the machine's default is:
|
||||
|
||||
```
|
||||
git -C .tools/<repo> config user.name 'Lauri Ojansivu'
|
||||
git -C .tools/<repo> config user.email 'x@xet7.org'
|
||||
```
|
||||
|
||||
Check the current git identity before committing or releasing:
|
||||
|
||||
```
|
||||
git config user.name && git config user.email
|
||||
```
|
||||
|
||||
- **Maintainer mode** — the identity is `Lauri Ojansivu <x@xet7.org>` (name
|
||||
`Lauri Ojansivu`, email `x@xet7.org`), or it is unset in a checkout of one of the
|
||||
repositories above, which means it is to be SET to that as above rather than worked
|
||||
around. Then the AI ITSELF makes the commit, and makes it **directly on `main`**
|
||||
(see the section above), authored `Lauri Ojansivu <x@xet7.org>` — not as a
|
||||
co-author, not as a second author, but as that author and no other — with no pull
|
||||
request and with nothing anywhere in the commit message, the CHANGELOG entry or
|
||||
anything else it writes that says an AI was involved. Working for the maintainer
|
||||
is the invisible half of the attribution rule above — the acknowledgement for it
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
- **A person contributing, with an AI helping them.** The person is the author and
|
||||
the tool is not mentioned — the pull request stays free of AI attribution, body
|
||||
and commits alike. Pushing the branch and opening the pull request are that
|
||||
person's own steps; the never-push boundary above means their AI stops at the
|
||||
local commit on the branch.
|
||||
- **An AI contributing on its own** — GitHub CodeQL raising a fix for something it
|
||||
found, Copilot Autofix, Dependabot. There is no human in that pull request at
|
||||
all, so it is named as the contributor it is. This is the one place an AI is
|
||||
credited by name in this project, and it is credited because it is the author,
|
||||
not because it assisted one.
|
||||
|
||||
Maintainer mode covers TSC as well: commit directly to its `devel` branch, no pull
|
||||
request, same author and no AI attribution. What it does NOT bring along is WeKan's
|
||||
house style — TSC keeps its own **GNU ChangeLog** `CHANGELOG` and its own release
|
||||
process, because a project is read on its own terms (see the CHANGELOG section below).
|
||||
The one repository under `.tools/` that is somebody else's is `sandstorm-io/sandstorm`,
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
|
||||
When asked to fix an open issue (one issue at a time):
|
||||
|
||||
1. **Read the whole issue** — the description AND all comments (e.g.
|
||||
`gh issue view <n> --comments`, or the GitHub API `.../issues/<n>/comments`).
|
||||
2. **If it is a bug, first check whether it is already fixed** in the current WeKan
|
||||
AND FerretDB source code (read the actual code, not just changelogs). It may have
|
||||
been fixed already.
|
||||
3. **If it is NOT fixed yet, fix it**, using the newest documentation and issues of
|
||||
the dependencies involved, checking where the newest MAINTAINED dependency lives and
|
||||
reading its source and issues:
|
||||
- Meteor 3 docs — https://docs.meteor.com/
|
||||
- Meteor 3 source + issues — https://github.com/meteor/meteor/issues
|
||||
- other Meteor software docs/code (e.g. Rocket.Chat) for how they solved it
|
||||
- Meteor forums — https://forums.meteor.com
|
||||
- AtmosphereJS — https://atmospherejs.com/ ; Packosphere — https://packosphere.com/
|
||||
- Meteor Community Packages — https://github.com/meteor-community-packages
|
||||
- Meteor-Files — https://github.com/veliovgroup/Meteor-Files
|
||||
- npm — https://www.npmjs.com
|
||||
4. **After fixing, add tests** — unit tests, negative tests, and UI tests where
|
||||
appropriate — and **run the new tests to verify they pass** (see Tests / the sandbox
|
||||
build+test instructions in `docs/Security/Sandboxes/vscodium/README.md`).
|
||||
5. **Commit** (maintainer only — when the current git user is `Lauri Ojansivu
|
||||
<x@xet7.org>`), with no "Co-Authored" / AI trailer, a message body ending:
|
||||
|
||||
```
|
||||
Thanks to (GitHub nickname of the issue creator) and xet7 !
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
Fix from source and test — do not guess. If the environment cannot run the relevant
|
||||
test (e.g. Docker is unavailable in this sandbox), reproduce as closely as possible
|
||||
from source and say clearly what was and was not verified.
|
||||
|
||||
## Translations (Transifex ↔ direct LLM fill, no external service)
|
||||
|
||||
WeKan translations live in `imports/i18n/data/<lang>.i18n.json` (flat
|
||||
`key -> string`, 2-space indent, key order matches `en.i18n.json`). Transifex holds
|
||||
the human translations. **The policy is: never overwrite a human translation in the
|
||||
correct language with a filled (or English) one, but always take the newest correct-
|
||||
language translations from Transifex. Text in another language is not protected: the
|
||||
locale tag is authoritative, and wrong-language or mixed-language values must be
|
||||
replaced with the language named by that tag.**
|
||||
|
||||
- Pull with `releases/translations/pull-translations.sh`. It runs `tx pull -a -f`
|
||||
(which fills every string that is UNtranslated on Transifex with the English source)
|
||||
and then a **per-key merge** (`releases/translations/merge-translations.mjs`) that,
|
||||
for every language file and every key:
|
||||
- Transifex has a real translation in the locale's declared language (pulled value
|
||||
differs from English) → **keep it** (the newest correct-language human translation
|
||||
always wins);
|
||||
- the pull returned English but the pre-pull local file has a translation →
|
||||
**restore the local translation as the fallback**. It may be human or a direct
|
||||
machine/LLM fill; either way it remains local, while a real correct-language
|
||||
Transifex translation takes precedence;
|
||||
- **no translation anywhere** (untranslated on Transifex AND never committed) → leave
|
||||
the English source as a placeholder. **This is the only case a non-human value is
|
||||
used.** A separate fill step may fill *only* these English placeholders, so a filled
|
||||
string can never overwrite a human translation.
|
||||
- After the merge, audit for mixed or wrong-language values. A value that differs from
|
||||
English can still be wrong for its locale; replace it directly as described below.
|
||||
- **The pull workflow never pushes translations to Transifex.** It cannot distinguish a
|
||||
restored human translation from a committed direct fill, so automatic push-back would
|
||||
misrepresent machine/LLM translations as human. Push only separately reviewed,
|
||||
provenance-known human translations with an explicit push command.
|
||||
|
||||
### Filling the remaining untranslated strings — directly, no translation service
|
||||
|
||||
The strings still equal to the English source after the merge are the ones untranslated
|
||||
**everywhere** (Transifex + git). Translate these **directly** — the maintainer or the
|
||||
assistant (an LLM) writes the translation itself, using that language's **existing
|
||||
translations** and general **kanban terminology** for the language as the reference.
|
||||
**Do NOT wire up any external translation service, API, endpoint, key or password** — an
|
||||
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
|
||||
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
|
||||
|
||||
- **EVERY language gets translated — including the ones nobody has volunteered for.**
|
||||
Klingon, Volapük, Wolof, Venda, Tamazight, Acehnese and the rest are not exceptions:
|
||||
WeKan cannot find a speaker for every language, and a file left in English stays in
|
||||
English for years. **Look the words up.** Any dictionary, word list, grammar or
|
||||
Wiktionary page on the Internet is a legitimate source — read it, take the terms it
|
||||
gives, and write the translation from them.
|
||||
- **A locale file must contain its declared language, not whichever language happened
|
||||
to seed it.** If a Mongolian file contains Russian, replace the Russian with
|
||||
Mongolian; likewise replace French, German, Malay, Zulu or any other wrong-language
|
||||
text with the language named by the file's locale tag. This rule applies to isolated
|
||||
mixed-language values and to an entire wrongly seeded file. Wrong-language text is
|
||||
not a human translation for that locale and does not receive human-preference
|
||||
protection. Because `fill-translations.mjs --apply` deliberately writes only English
|
||||
placeholders, make these corrections directly in the locale JSON (preserving key
|
||||
order and formatting), add regression coverage for the correct language/script, and
|
||||
note low-confidence replacements in the commit. Script checks alone are insufficient
|
||||
when both languages share a script, such as Russian and Mongolian; inspect vocabulary
|
||||
as well.
|
||||
- **Placeholders are code, not language. Never translate them.** Any source token that
|
||||
begins and ends with underscores (for example `__board__`, `__card__` or
|
||||
`__username__`) and any format token whose token begins with `%` (for example `%s`,
|
||||
`%d` or `%1$s`) must be copied from the corresponding value in `en.i18n.json`
|
||||
exactly, character for character. Preserve every placeholder, its spelling, case,
|
||||
count and format; translate only the prose around it. If a locale has translated,
|
||||
renamed, removed or malformed one of these tokens, restore the original token from
|
||||
the same key in `en.i18n.json`, even when the surrounding translation is human.
|
||||
Regression coverage for a translation batch must compare its underscore-delimited
|
||||
and percent-prefixed token inventory with English so a translated placeholder cannot
|
||||
pass as ordinary prose.
|
||||
- **Add the languages WeKan does not have yet.** The 154 files under
|
||||
`imports/i18n/data/` are not the list of languages worth supporting - they are the
|
||||
list somebody happened to start. When a language has a dictionary, a word list or a
|
||||
Wiktionary anywhere on the Internet, it can have a WeKan. Adding one is three edits,
|
||||
and all three are needed or the language is invisible:
|
||||
1. `imports/i18n/data/<tag>.i18n.json` — the strings, in `en.i18n.json`'s key ORDER
|
||||
(`tests/boardItemLinks.test.cjs` checks that), 2-space indent.
|
||||
2. `imports/i18n/languages.js` — `{ code, tag, name, load, rtl }`. The **name is
|
||||
written in that language itself** ("Suomi", "tlhIngan Hol"), the way every entry
|
||||
there already is, because it is read by somebody who does not read English. `rtl:
|
||||
true` for Arabic, Hebrew, Persian, Uyghur and the rest that are written
|
||||
right-to-left.
|
||||
3. `client/components/users/userHeader.js` — the `flagMap` in `languageFlag()`, so the
|
||||
picker shows a **flag** beside the name. A language without a country of its own
|
||||
takes the flag of where it is spoken, and a constructed one (Esperanto, Klingon,
|
||||
Volapük) keeps the `🌐` fallback rather than being given somebody's country.
|
||||
- **A bad translation beats no translation.** An imperfect string is readable, it is
|
||||
obviously improvable, and it is an *invitation*: somebody who speaks the language sees
|
||||
it is wrong and fixes it, which an English placeholder never provokes. So: do not skip
|
||||
a language because the result would be imperfect. Translate it, say in the commit which
|
||||
ones were done with low confidence, and let a human correct it — the merge rules below
|
||||
guarantee that when that human translation arrives on Transifex, it REPLACES the filled
|
||||
one and is never overwritten by it.
|
||||
|
||||
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
|
||||
strings still needing translation (English + `en-*` variants are skipped: English by
|
||||
design). Also printed at the end of `pull-translations.sh`.
|
||||
- `node releases/translations/fill-translations.mjs --list <lang> [--limit N]` — dump the
|
||||
untranslated keys of a language as `{ key: englishSource }` for the translator to fill.
|
||||
- `node releases/translations/fill-translations.mjs --apply <lang> <translated.json>` —
|
||||
merge the translations back. It writes **only** into placeholder keys, so it can
|
||||
**never** overwrite a human translation, and a value still equal to English/empty is
|
||||
ignored. Key order and 2-space indent are preserved.
|
||||
|
||||
**Both directions are safe, and it is verified:**
|
||||
`node releases/translations/verify-human-preference.mjs` proves (pure-logic, no network)
|
||||
that a real Transifex translation wins, the pre-pull local translation fills only an
|
||||
English result, a fill only touches placeholders, and the pull script contains no push.
|
||||
Thus **correct-language Transifex human strings are preferred and merged**, remaining
|
||||
keys retain their local human or machine/LLM translations, and nothing restored or
|
||||
filled is pushed to Transifex as if it were human. Wrong-language values are corrected
|
||||
directly after the merge.
|
||||
|
||||
## General practices (from .tools/log/v10/Claude.txt)
|
||||
|
||||
- **[maintainer only]** Commit as `Lauri Ojansivu <x@xet7.org>`, with **no**
|
||||
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
|
||||
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.**
|
||||
(Contributors do the opposite: work on a branch and open a pull request — see the
|
||||
top section.)
|
||||
This is the same rule as the top section, restated where the release work is: one
|
||||
author, `Lauri Ojansivu <x@xet7.org>`, and no AI attribution anywhere.
|
||||
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi), the FerretDB v1 fork, and
|
||||
the two patch repositories under `.tools/` — node-patches and mongo-tools-patches.
|
||||
- Directory structure:
|
||||
- `wekan` — this repo (https://github.com/wekan/wekan); see
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root. It is at
|
||||
`~/repos/wekan` on Linux, `~/Documents/repos/wekan` on macOS and
|
||||
`Downloads\repos\wekan` on Windows. The maintainer commits on `main`; a
|
||||
contributor works on a branch and opens a pull request — see "Where this
|
||||
repository is, and which branch commits go on" above.
|
||||
- `.build/` and `_build/` — the two BUILD directories, generated and
|
||||
gitignored, easy to confuse and different things. `.build/` is the RELEASE
|
||||
bundle (`meteor build .build --directory`; `.build/bundle` is what is
|
||||
deployed, tested and packaged). `_build/` is rspack's compiled output,
|
||||
written by ANY Meteor compile, and Meteor reads the app's main modules from
|
||||
`_build/main-prod/` — so it is a HANDOFF, not a leftover, and must **not**
|
||||
be added to `.meteorignore` (ignoring it breaks the build; that file says
|
||||
why). Never edit or commit either, and SKIP `_build/` in anything that walks
|
||||
the repository: it holds a bundled second copy of every source file, so a
|
||||
tool that reads it sees every file twice and reports the bundler's rewritten
|
||||
code as if it were source. That is not hypothetical — a source-scanning test
|
||||
did exactly that and failed on generated code nobody can edit.
|
||||
- `.tools/wekan.fi` — the WeKan website companion repository.
|
||||
- `.tools/` — everything that is NOT part of this repository but is needed to
|
||||
build, test and release it, in ONE directory that `.gitignore` and
|
||||
`.meteorignore` already exclude (each used to need its own ignore entry at
|
||||
the repo root), so nothing in it can reach a commit or a Meteor rebuild. Two
|
||||
different kinds of thing live there, and the difference matters:
|
||||
|
||||
**Companion git repositories.** Separate repositories with their own history,
|
||||
branches, changelog and release flow — a commit here is never a commit there.
|
||||
`build.sh`'s `ensure_tool_repo` clones one on demand (SSH first, HTTPS
|
||||
second), so a fresh checkout needs no manual setup:
|
||||
|
||||
| Path | Repository | Branch | What it is |
|
||||
| --- | --- | --- | --- |
|
||||
| `.tools/FerretDB` | wekan/FerretDB | `main-v1` | the FerretDB v1 fork WeKan ships as its default database |
|
||||
| `.tools/node-patches` | wekan/node-patches | `main` | patches to upstream Node.js; builds the `node-<platform>` binaries the bundles, the Docker image and the snap embed |
|
||||
| `.tools/mongo-tools-patches` | wekan/mongo-tools-patches | `main` | patches to the MongoDB Database Tools; builds `<tool>-<arch>` |
|
||||
| `.tools/TSC` | Secretchronicles/TSC | `devel` | the game xet7 also maintains — same author and maintainer mode, its own GNU ChangeLog and release process |
|
||||
| `.tools/sandstorm` | sandstorm-io/sandstorm | — | upstream Sandstorm, when present — somebody else's project, cloned for reference |
|
||||
|
||||
**Unpacked toolchains and caches.** Downloads, not repositories — put there by
|
||||
the sandbox instructions in `docs/Security/Sandboxes/vscodium/README.md`, deleted
|
||||
and re-fetched freely, never committed anywhere:
|
||||
`node-v<version>-linux-<arch>/` (the Node.js the test suites are run with),
|
||||
`go/` with `gopath/`, `gomodcache/` and `gocache/` (FerretDB's Go builds),
|
||||
`.meteor/` when `HOME` is pointed at `.tools`, and the `TSC*` AppImage.
|
||||
- **Temporary files belong in `.tools/tmp`, never in `/tmp`.** At the start of
|
||||
work that creates temporary files or runs tools which may create them, make
|
||||
`.tools/tmp` if necessary and export its absolute path as `TMPDIR` before
|
||||
invoking `mktemp`, npm, node-gyp, compilers, test runners or build scripts.
|
||||
Keep task-specific subdirectories beneath it and remove only the exact
|
||||
subdirectories the task created. The host `/tmp` may be a small tmpfs even
|
||||
when the repository filesystem has ample space. On Windows, use
|
||||
`.tools\\tmp` and set both `TEMP` and `TMP` to its absolute path for the
|
||||
command or script. `.tools/` is already excluded by `.gitignore` and
|
||||
`.meteorignore`, so these files cannot enter a commit or trigger Meteor.
|
||||
- **Do NOT add a `CLAUDE.md` or an `AGENTS.md` to any repository under
|
||||
`.tools/`.** node-patches and mongo-tools-patches each had a pair and they were
|
||||
REMOVED on purpose: the rules are the same for every one of these repositories,
|
||||
and a second copy of a rule drifts from the first. THIS file, and its `AGENTS.md`
|
||||
twin beside it, are where they live for all of them. Something true of only one
|
||||
of those repositories goes in that repository's own `README.md` or `docs/`,
|
||||
never in a new instruction file — and if you find one there, remove it rather
|
||||
than updating it.
|
||||
- `.tools/FerretDB` specifics — FerretDB `.go` files must contain **no**
|
||||
application-specific names (say "the client" / "a Meteor 3 driver" / a bare
|
||||
`#NNNN`); its `CHANGELOG.md` may use `wekan/wekan#NNNN`.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
five that come up here:
|
||||
|
||||
| Repository | File | Format |
|
||||
| --- | --- | --- |
|
||||
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
|
||||
| `wekan/node-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming node-patches release` |
|
||||
| `wekan/mongo-tools-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming mongo-tools-patches release` |
|
||||
| `wekan/FerretDB` | `CHANGELOG.md` | **upstream FerretDB's** format, not WeKan's: `## [v1.48.0](tag URL) (YYYY-MM-DD)` and `### New Features 🎉` / `### Fixed 🐛` / `### Other Changes 🤖` bullets ending `by @xet7. Thanks to xet7.` |
|
||||
| `Secretchronicles/TSC` | `CHANGELOG` (no extension) | **GNU ChangeLog** format: a `YYYY-MM-DD Name <email>` header line, then TAB-indented `* Version …` / `* Fix: …` / `* Misc: …` entries, wrapped and continued with further tabs, each ending `(by Name)` |
|
||||
|
||||
The reason is the reader, not consistency for its own sake: a FerretDB release is
|
||||
read beside upstream FerretDB's releases, and a TSC entry beside a decade of GNU
|
||||
ChangeLog entries. A WeKan-shaped `<details>` block in either would be the odd one
|
||||
out and would break the tooling that parses them. When this file and the file being
|
||||
edited disagree, **the file being edited wins** — and everything below in this
|
||||
section is about the WeKan format specifically.
|
||||
- During development, add entries under a new `# Upcoming WeKan ® release` section above
|
||||
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
|
||||
`# Upcoming <repo> release`). Do **not** hand-edit
|
||||
`package.json` or any other version reference — the release workflow bumps those.
|
||||
- **Whenever `# Upcoming WeKan ® release` is updated, audit every feature, bug fix
|
||||
and security fix in that section for regression coverage.** If coverage does not
|
||||
exist yet, add a positive test, a negative test and a UI test wherever that kind
|
||||
of test is possible and relevant. Run the tests that can run in the current
|
||||
environment; when a UI stack or another required service is unavailable, still
|
||||
add and syntax-check/register the test and state clearly what could not be run.
|
||||
Do not duplicate an existing test merely to satisfy the audit — extend it when
|
||||
that keeps one behavior in one suite.
|
||||
- **CHANGELOG.md holds the CURRENT MONTH only.** It reached 2.6 MB and 51,365
|
||||
lines over 1,100 releases back to 2015 (#6580), which is slow to open and
|
||||
slower to read. Moving whole years out left 1.9 MB, still too large, because
|
||||
releases here are frequent: 2026 alone is 272 releases over eight months and
|
||||
July was 80 on its own. So:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `CHANGELOG.md` | the current month, plus `# Status` (Platforms/TODO Later inside it), `# Upcoming` |
|
||||
| `old-CHANGELOG/<year>/<MM>.md` | earlier months of the current year |
|
||||
| `old-CHANGELOG/<year>.md` | years that are over, whole |
|
||||
|
||||
Past years stay one file each because they are already small (30–107 KB);
|
||||
splitting them further would trade a size problem nobody has for a hundred
|
||||
more files. Each archive opens with a **release count** — per month in a year
|
||||
file, per day in a month file — and a bullet in `# Status`'s "Newest WeKan at
|
||||
these platforms" details links every one.
|
||||
That `git blame` is less useful on the split file is accepted: the history is
|
||||
still in git (`gitk`, `git-gui`, `git log --follow`), and being small enough
|
||||
to open is worth more.
|
||||
|
||||
Run `node releases/changelog-archive.mjs` at the start of a month. It is
|
||||
idempotent — a run with nothing to move only refreshes the tables — and it
|
||||
takes the month to keep from the FILE rather than the clock, so two people
|
||||
running it on the same day agree. An archived section is never edited, for the
|
||||
same reason a released one is not.
|
||||
- **The file's shape, top to bottom** — keep it exactly as it is now. There is
|
||||
exactly ONE `#` heading before the releases: `# Status`. Platforms, Version
|
||||
and TODO Later are `<details>` blocks nested inside it, not headings of
|
||||
their own (they used to be `# Platforms` and `# TODO Later`; both were
|
||||
folded under `# Status` so the file opens with a single top-level section
|
||||
rather than three):
|
||||
1. `# Status` — opens with a `<details>` whose `<summary>` is `More status
|
||||
info`, holding a link to <https://wekan.fi/status/>. Then a `<details>`
|
||||
whose `<summary>` is `Newest WeKan at these platforms`, holding the line
|
||||
`Newest WeKan at these platforms:` and the Install / Upgrade / Docs / Mac
|
||||
ChangeLog bullets, plus the `Older releases:` bullet linking the per-year
|
||||
archives. Then a `<details>` whose `<summary>` is `Version` holding
|
||||
"which WeKan version uses what". Then a `<details>` whose `<summary>` is
|
||||
`TODO Later`, itself holding a `<details>` whose `<summary>` is `Carried
|
||||
to a future release.` explaining the list, then one `<details>` per
|
||||
category (below). There is no `# Version` or `# TODO Later` heading of
|
||||
its own.
|
||||
2. The releases, newest first, each `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release`.
|
||||
|
||||
Nothing else is an `#` heading. A `##`/`###` inside a release would break the
|
||||
version list, and a wrapped line that BEGINS with `#` (e.g. an issue number such
|
||||
as `#6514`) becomes a heading too — escape it `\#6514` or keep it off the line
|
||||
start.
|
||||
- **A release's order of subsections** — CRITICAL SECURITY ISSUES first, then new
|
||||
features, then the Admin Panel / UI reorganisation if there is one, then dependency
|
||||
updates (`Thanks to dependabot.`), then bug fixes, then developer-facing changes,
|
||||
documentation and translations. The section ends with the line `Thanks to above
|
||||
GitHub users for their contributions and translators for their translations.`
|
||||
- **Every entry is a `<details>` block, and they are what a release is made of.**
|
||||
The `<summary>` is the SHORT description of what was done and IS the link to the
|
||||
commit — the hash lives in the `href` and is never on the page — followed by the
|
||||
`Thanks to …` sentence. Clicking it reveals the long description:
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/<hash>">Short description of
|
||||
what was done</a>. Thanks to (issue creator's GitHub nickname) and xet7.</summary>
|
||||
|
||||
The long description: what was wrong, why, what it does now, what the test pins.
|
||||
Word-wrapped at 80 characters, ordinary markdown - links, `code`, emphasis.
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Exactly as above: `<details>` and `</details>` each on their own line, the whole
|
||||
`<summary>…</summary>` on ONE line, a blank line under it, the body, a blank line,
|
||||
the close — and a blank line between two blocks.
|
||||
- **The summary is one line at a glance** — aim for ≤ 110 characters — and PLAIN text:
|
||||
no `[text](url)`, no `**bold**`, no backticks inside `<summary>`, because a link
|
||||
cannot nest inside the `<a>` and the rest renders literally. It ends with a full
|
||||
stop, then `Thanks to …`. Every summary in a release links its own commit.
|
||||
- **A change with nothing more to say stays a plain bullet** —
|
||||
`- [Short description](https://github.com/wekan/wekan/commit/<hash>). Thanks to xet7.`
|
||||
— and a dependency batch keeps its `- **package 1.2.3 → 1.2.4** — one line on what
|
||||
it is` bullets, closing with `Thanks to dependabot.` That bot is named for the same
|
||||
reason CodeQL and Copilot Autofix are: it raised those pull requests itself, with no
|
||||
human behind them, so it IS the contributor (see the attribution rule at the top).
|
||||
A `<details>` whose body only
|
||||
repeats its summary is noise; use one when there IS a longer story to reveal, which
|
||||
is most fixes.
|
||||
- **TODO Later's own blocks are the same shape with two differences:** the `<summary>` is
|
||||
the short category text (no `<a>`, because nothing was committed), and there is **no
|
||||
`Thanks to`** — nothing is done yet, so there is nobody to thank. The body lists the
|
||||
issues as `[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)`.
|
||||
- **The hash is never the link text.** `[f1c89548e](…)` shows a hash to a reader who
|
||||
cannot do anything with it; the link text says what changed. Same for `[merge
|
||||
commit](…)` and for a bare URL — see the next rule.
|
||||
- **Never show a long URL as visible text.** A link is always
|
||||
`[short text](url)` — an issue is `[#6524](…/issues/6524)`, an advisory is
|
||||
`[GHSA-xxxx](…)`, a security page is `[ZipBleed](https://wekan.fi/hall-of-fame/zipbleed/)`.
|
||||
- **Subsection headers read as ONE flowing sentence.** The FIRST subsection of a release
|
||||
starts with `This release ` (e.g. `This release fixes the following bugs:`); every LATER
|
||||
subsection in the SAME release starts with a lowercase `and ` instead of repeating
|
||||
`This release ` — e.g.
|
||||
`This release fixes the following bugs:` … `and updates the following dependencies:` …
|
||||
`and has the following developer-tooling fix:` … `and improves the translation workflow:`
|
||||
… then the closing `Thanks to above GitHub users …` line. A release with only one
|
||||
subsection just keeps its single `This release …:` header. Use `and adds the following
|
||||
new features:`, `and fixes the following bugs:`, `and updates the following
|
||||
dependencies:`, etc., matching the verb to the subsection.
|
||||
- **The Upcoming section opens with an `**In short:**` paragraph.** It must be
|
||||
short: put one compact, high-level paragraph directly under the
|
||||
`# Upcoming WeKan ® release` line. Aim for 2–4 sentences and no more than about
|
||||
120 words, however many commits the release contains. Name only the release's
|
||||
major outcomes or themes, with notable names in `**bold**` so it can be skimmed.
|
||||
It is not a commit inventory, progress log or miniature changelog: do not list
|
||||
batches, per-language counts, individual test fixes or every touched component,
|
||||
do not link commits, and do not repeat entry wording. The topic groups and their
|
||||
`<details>` blocks below carry that information. Keep the paragraph current as
|
||||
topics change, and shorten it when added commits make it grow. A finished release
|
||||
keeps the paragraph it was written with.
|
||||
- **No release section carries a Platform/Binary/From/Version/SHA256 table, and
|
||||
none ever should again.** It used to sit right under the `**In short:**`
|
||||
paragraph, then moved to a `**Binaries in these bundles:**` label at the end
|
||||
of the section — both tried and both removed, because CHANGELOG.md is not
|
||||
where that data belongs: it made every release's entry mostly a giant table
|
||||
nobody read, on top of what `<details>` entries already say. "Which Node.js
|
||||
is in the arm64 bundle of 10.69, and was it checked" is answered by the
|
||||
**GitHub Release notes** instead: `releases/provenance-table.sh` puts the
|
||||
same table at the TOP of those, built fresh from the `provenance.tsv` rows
|
||||
each build job records (see `releases/record-provenance.sh`), every time a
|
||||
release is made — so it is never stale and never needs hand-editing into
|
||||
CHANGELOG.md. Adding a binaries table back into a CHANGELOG entry, in any
|
||||
shape, is reintroducing something the maintainer removed on purpose.
|
||||
- **Inside a subsection, entries are GROUPED BY TOPIC/AREA.** A release touches a handful
|
||||
of areas and repeating the area's name in every summary is the noise this
|
||||
removes — twelve entries that each begin "All Boards:" say "All Boards" twelve
|
||||
times and the part that differs starts halfway through the line. Instead the area
|
||||
is named ONCE, as a bold line with a short description of what the group covers,
|
||||
and every entry under it drops the prefix:
|
||||
|
||||
```
|
||||
and fixes the following bugs:
|
||||
|
||||
**The first header bar** - how it lays itself out, and what sits under it.
|
||||
|
||||
<details>
|
||||
<summary><a href="…">It fills each row before starting the next one</a>. Thanks to xet7.</summary>
|
||||
…
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="…">It wraps to a second row instead of hiding the buttons that do not fit</a>. Thanks to xet7.</summary>
|
||||
…
|
||||
</details>
|
||||
|
||||
**All Boards** - the overview and its search.
|
||||
|
||||
<details>
|
||||
…
|
||||
```
|
||||
|
||||
The group line is `**Topic** - short summary.` on ONE line where practical,
|
||||
wrapped at 80 like everything else, with a blank line under it. This line is the
|
||||
high-level summary of ALL commits in that topic: say what changed and why it
|
||||
matters without copying a commit summary or enumerating implementation details.
|
||||
The `<summary>` under it gives each commit's short outcome, and the expanded
|
||||
`<details>` body carries the implementation, rationale and test evidence. In
|
||||
other words, the release reads from release summary → topic summary → commit
|
||||
detail, with each level adding information instead of repeating the level above.
|
||||
The group line is NOT a heading: a `##` inside a release breaks the version list
|
||||
(see above). Group labels are the areas of the
|
||||
app — `All Boards`, `The first header bar`, `The left menus`, `The Admin Panel`,
|
||||
`Board views`, `Public Boards`, `Member Settings`, `Board roles` — and the same
|
||||
label is reused across subsections when an area has both a feature and a fix.
|
||||
EVERY entry of a grouped subsection belongs to a group, including a group with
|
||||
one entry: a section that is half grouped and half loose reads as a mistake. Put
|
||||
each commit under the topic it actually changes; do not create a chronological
|
||||
catch-all group. A subsection with only one entry, and the dependency bullets,
|
||||
stay flat.
|
||||
- **CRITICAL security header — match the previous releases' wording.** A security release
|
||||
leads with `This release fixes the following CRITICAL SECURITY ISSUE of
|
||||
[Name](https://wekan.fi/hall-of-fame/namebleed/):` for a single named *Bleed, or
|
||||
`This release fixes the following CRITICAL SECURITY ISSUES:` for several — NOT
|
||||
`CRITICAL VULNERABILITIES`. Each security bullet still links its own
|
||||
`https://wekan.fi/hall-of-fame/...bleed/`. CodeQL-only releases use the established
|
||||
`This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:`.
|
||||
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
|
||||
subsection becomes `and …` per the rule above.
|
||||
- **TODO Later** — a `<details>` nested inside `# Status` near the TOP of
|
||||
`CHANGELOG.md` (above the version sections), a triage backlog for open
|
||||
issues that were **investigated but not fixed here**, each
|
||||
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
|
||||
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
|
||||
ending `Fixes #NNNN,`), **close it** if already fixed in current code (commit `Close #NNNN` /
|
||||
`Fixes #NNNN,`), or — when it can't be fixed/verified in this environment — **add it to `TODO
|
||||
Later` under the matching category with the reason**. Group issues by category bullet, e.g.:
|
||||
*Need specific infrastructure / a running server stack* (LDAP/WebHooks/Sandstorm/proxy —
|
||||
environment owners), *Need the running app to reproduce/verify* (runtime UI / publication /
|
||||
mergebox / router state, not unit-testable), *Already correct in the current code* (verified by
|
||||
reading; could not reproduce), *Feature requests / behaviour-by-design rather than bugs*, and
|
||||
*Needs a maintainer decision on the intended contract*. Each category is a `<details>`
|
||||
whose `<summary>` is the SHORT category text and whose body lists the issues with their
|
||||
reasons — and with **no `Thanks to` line**, because nothing is done yet, so there is
|
||||
nobody to thank. Each entry is a normal issue link
|
||||
`[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)` — issue links here, NOT
|
||||
commit links (nothing was committed). **Keep it current:** when an issue in `TODO Later` gets
|
||||
fixed, REMOVE it from the list (its fix commit's `Fixes #NNNN` closes it); do not leave fixed
|
||||
issues in the backlog. This is the only CHANGELOG place that uses issue links and lives above
|
||||
the releases; everything else uses the per-release commit-link bullets above.
|
||||
- FerretDB Upcoming structure — `### New Features 🎉`, `### Fixed 🐛`, `### Other Changes
|
||||
🤖`; entries end `... by @xet7. Thanks to xet7.`
|
||||
- Word-wrap both CHANGELOGs at 80 chars, but never break a long link across lines (a
|
||||
`<summary>` line, or any other line carrying a link, may be longer). Continuation
|
||||
lines of a plain bullet are indented by two spaces.
|
||||
|
||||
### Commit message structure
|
||||
|
||||
```
|
||||
Do something.
|
||||
|
||||
Thanks to (original creator of issue) and xet7 !
|
||||
|
||||
Fixes #1234,
|
||||
Fixes #1235.
|
||||
```
|
||||
|
||||
- **A commit that fixes a GitHub issue ENDS with `Fixes #NNNN`** — last lines of
|
||||
the message, one per issue, comma after each and a full stop on the last. That
|
||||
trailer is what closes the issue when the commit is pushed; a fix that only
|
||||
*mentions* the number in prose leaves the issue open, and somebody has to close
|
||||
it by hand later or it stays open forever. Several issues in one commit get
|
||||
several lines. The same applies to an issue that turns out to be **already
|
||||
fixed**: commit the test or the note that proves it and end with `Fixes #NNNN,`
|
||||
so the issue closes with a reference to where it was fixed.
|
||||
- **Only when the commit really fixes it.** A commit that improves the
|
||||
diagnostics, narrows the cause, or fixes one of several reported problems
|
||||
references the issue in the body (`#6585 comment 5276581923`) and does NOT
|
||||
carry the trailer — closing an issue whose reporter is still stuck is worse
|
||||
than leaving it open. If an issue was closed and then REOPENED for follow-up
|
||||
items (as #6586 was), it is fixed again only when those items are done.
|
||||
- **A commit with no issue behind it has no trailer** — the email reports, the
|
||||
release-tooling fixes found in build logs, refactors. `Thanks to ... and xet7 !`
|
||||
still names whoever reported it.
|
||||
|
||||
### Making a release — no version number needed **[human maintainer only — never AI]**
|
||||
|
||||
All publishing / release steps below are human-maintainer-only. Contributors and AI
|
||||
assistants never run them. An AI may prepare a local release commit only when requested,
|
||||
then must stop and hand the commit and command sequence to the human maintainer.
|
||||
|
||||
**Releases are FREQUENT, and that is the normal state of this repository — not an
|
||||
interruption to it.** The maintenance loop is:
|
||||
|
||||
1. `./build.sh` (or `build.bat` on Windows) → **option 1, "git pull and git
|
||||
push"**. Both directions do the whole job: a pull rebases and then repairs the
|
||||
CHANGELOG commit links the rebase made stale, and a push repairs them again
|
||||
BEFORE publishing, because a stale link that reaches GitHub 404s for everyone
|
||||
who reads the release notes.
|
||||
2. `./releases/release-all.sh`, **with no arguments**, whenever there is something
|
||||
worth shipping — which is often, several times a day when a fault is being
|
||||
chased. It takes the version from the CHANGELOG and needs nothing typed.
|
||||
|
||||
So a release happening "in the middle" of a piece of work is not a special case
|
||||
to reason about; it is what always happens, and anything that only works when
|
||||
releases are rare is broken here. Two consequences worth stating, because both
|
||||
have cost a released section its accuracy:
|
||||
|
||||
- **Work continues immediately after a release**, so an entry written right
|
||||
after one has to go somewhere. `release-all.sh` renames `# Upcoming WeKan ®
|
||||
release` to `# v<NEW> …` and stops there — it does **not** create a new,
|
||||
empty `# Upcoming` section. CHANGELOG.md never carries an
|
||||
`**In short:** nothing here yet.` placeholder sitting between releases; it
|
||||
used to (via a now-deleted `releases/changelog-open-next.mjs`), and that
|
||||
meant the file always had a section saying nothing, from the moment a
|
||||
release was cut until the first real entry replaced it. Instead: **add `#
|
||||
Upcoming WeKan ® release` yourself, by hand, the moment you have a real
|
||||
entry for it** — not before — using the skeleton at
|
||||
[docs/DeveloperDocs/Changelog-Upcoming-Template.md](docs/DeveloperDocs/Changelog-Upcoming-Template.md).
|
||||
An entry appended above the closing `Thanks to above GitHub users …` line
|
||||
with no Upcoming section yet lands INSIDE the release just published — the
|
||||
same mistake the auto-created section used to prevent — but it is caught
|
||||
regardless: `tests/changelogEntriesBelongToTheirRelease.test.cjs` asks git
|
||||
which commits a release actually contains and fails when a section links
|
||||
one that is not an ancestor of that release, whether or not an Upcoming
|
||||
heading existed first. Run that test (or `build.sh`'s node-suite option)
|
||||
before committing a CHANGELOG entry written right after a release.
|
||||
- **A released section is a RECORD, not a draft.** When a release turns out to be
|
||||
broken, its section keeps saying what it shipped — including the part that was
|
||||
wrong — and the fix goes in a new `# Upcoming` above it. Do NOT edit a
|
||||
published entry to describe the smaller, tidier change you wish had shipped;
|
||||
add to it that it was wrong and where the fix is. Somebody reading v10.97's
|
||||
notes is most likely somebody whose v10.97 just died.
|
||||
|
||||
- WeKan: run `./releases/release-all.sh` (no arguments). It renames
|
||||
`# Upcoming WeKan ® release` to the next version (same increment as the last release;
|
||||
9.99 → 10.00) dated today, commits + pushes, and triggers
|
||||
`.github/workflows/release-all.yml` — whose `bump` job bumps `package.json` and every
|
||||
version reference and rebuilds the API docs, whose `prepare` job then pushes the tag
|
||||
`v<new>` and checks the notes exist, and whose `release` job publishes the GitHub
|
||||
Release the bundle jobs attach to. An explicit `oldversion newversion` pair still
|
||||
overrides. Adding entries under Upcoming is the only hand step.
|
||||
- **A release that FAILED and one that shipped BROKEN are handled differently, and
|
||||
the difference is whether anything was PUBLISHED.** Both happened with v10.92, so
|
||||
neither is hypothetical:
|
||||
- **Nothing published** (the workflow died before the GitHub Release existed): there
|
||||
is no release for that number, so `# v<new> …` must not claim there is one. Rename
|
||||
the heading back to `# Upcoming WeKan ® release`, add the fix under it, and run
|
||||
`release-all.sh` again — it will take the same number, since the newest release is
|
||||
still the previous one.
|
||||
- **It published, and something in it is broken**: the release exists and people can
|
||||
download it, so its section STAYS as it is — a released section is a record, not a
|
||||
draft. Add a NEW `# Upcoming WeKan ® release` above it with the fix, exactly as
|
||||
during ordinary development, and the next run takes the next number.
|
||||
What decides it is the GitHub Release, not the tag: `prepare` pushes the tag early,
|
||||
so a tag can exist for a release that never published.
|
||||
- FerretDB: run `./build.sh release-ferretdb` from `.tools/FerretDB` (no
|
||||
version). It renames `## Upcoming FerretDB release` to the next version with the
|
||||
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
|
||||
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
|
||||
already-tagged version.
|
||||
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
reported.** A test that pins one call site leaves the same mistake free to live in
|
||||
the other five, and that is how most of these arrive: SignupBleed's guard read an
|
||||
option nothing sets, and the same shape sat in a second endpoint; the source-map
|
||||
trim was safe on the client and fatal on the server. So:
|
||||
- the **test** proves the fix does what it claims, driving the decision itself
|
||||
where that is possible. A pure module — `loginFailureDecision.js`,
|
||||
`lockoutDecision.js` — can be tested as arithmetic, without a server or a
|
||||
database, and reproduces the reporter's attack exactly rather than approximately;
|
||||
- the **negative test** proves the fault is gone rather than moved. Search the
|
||||
whole tree for the SHAPE of it and assert nothing matches: no other endpoint
|
||||
reads the dead option, no other counter is global, no other caller skips the
|
||||
check. When the shape is a pattern, pin the pattern.
|
||||
- and a test that reads the source is a real test here. `tests/*.test.cjs` may
|
||||
parse a file and fail on a construct — that is what makes "and nowhere else"
|
||||
checkable at all.
|
||||
- **If somebody ATTEMPTS the attack, and the secure default DENIES it, that denial
|
||||
has to be visible in Admin Panel → Problems** — in every case where the denial can
|
||||
be attributed. This applies to every security fix added to Upcoming. That is the
|
||||
difference between a hole that is closed and a hole that is closed and watched:
|
||||
an administrator should be able to see that somebody tried.
|
||||
- Add a key to the catalog in `models/lib/securityCategories.js` (category, the
|
||||
hall-of-fame `bleed` name, severity, CWE), and call
|
||||
`require('/server/lib/securityLog').record({ key, action: 'blocked', source, detail })`
|
||||
on the refusal path. `action` is `'blocked'` when the fix stopped it and
|
||||
`'detected'` when it was only noticed.
|
||||
- **Wrap the call so logging can never break the guard**:
|
||||
`try { ... } catch (e) { /* logging must never break the guard */ }`. The refusal
|
||||
matters more than the record of it.
|
||||
- **Only log an ATTEMPT, never ordinary use.** The test to apply is whether a
|
||||
legitimate user can reach that line. Registration refused while registration is
|
||||
off has no legitimate caller, so it is logged; an admin endpoint whose fault was
|
||||
in what its answer CARRIED fires on every normal call, so it is NOT — that is why
|
||||
HashBleed (GHSA-6qpx-x7vr-p9w6) deliberately has no key. A log that fills with
|
||||
normal traffic hides the one line that mattered.
|
||||
- Where the denial cannot be attributed to an attempt — a fix that changes what a
|
||||
response contains, or one that only takes effect at build time — there is nothing
|
||||
to record, and that is a decision to state in the entry rather than an omission.
|
||||
- **Admin Panel → Problems is a SUMMARY, never a row per event.** A guard on a path
|
||||
an attacker controls fires as fast as they can send, so a document per occurrence
|
||||
grows the database with the attack, turns the page into a scroll of near-identical
|
||||
lines, and buries the one event that mattered under ten thousand that did not. The
|
||||
admin's question is never *list every attempt* — it is *what is happening, how
|
||||
much, since when, and who*. So each problem is ONE row that accumulates
|
||||
(`models/lib/eventLogSummary.js`, written through `server/lib/eventLogFold.js`,
|
||||
shared by the security, speed and test loggers):
|
||||
|
||||
| field | what it holds |
|
||||
| --- | --- |
|
||||
| `count` | how many times this problem has happened |
|
||||
| `firstAt` … `at` | the window it happened in |
|
||||
| `actors` | who, each with their own count — `username1 25, 100.100.100.100 30` |
|
||||
| `username` / `ip` / `detail` | the MOST RECENT occurrence |
|
||||
|
||||
- **Identity is the KIND of thing that happened** — stream, `bleed`, category,
|
||||
action, source, severity, CWE (and `type`/`db`/`kind` for the database stream).
|
||||
The actor is NOT part of it: putting a username or an address in the key gives a
|
||||
row per attacker per attempt, which is the cost being removed.
|
||||
- **A username and an address are tallied SEPARATELY**, not as a pair. They answer
|
||||
different questions — which account, and where from — and an unauthenticated
|
||||
attempt has an address and no name.
|
||||
- **The tally is CAPPED** (`MAX_ACTORS`), with the remainder counted in
|
||||
`actorsOverflow`. Otherwise an attacker rotating addresses grows the row with the
|
||||
attack and reintroduces the same bug one level down — and *"and 9,412 others"* is
|
||||
itself the signal that the source is spread rather than single.
|
||||
- **A logger that writes per event is a bug to fix, not a style.** If one is added
|
||||
or found, route it through the shared fold and migrate what it already wrote
|
||||
(`server/lib/eventLogSummaryMigration.js` folds legacy rows in place, in batches,
|
||||
idempotently).
|
||||
- **Every hall-of-fame vulnerability that CAN be detected at runtime should have a
|
||||
catalog key**, so Admin Panel → Problems groups attempts under the same name the
|
||||
Hall of Fame uses and an admin can go from one to the other.
|
||||
`tests/hallOfFameProblemsCoverage.test.cjs` lists the names that have no key and
|
||||
requires each to be accounted for — a *Bleed with nothing to detect (a fix that
|
||||
changed what a response carried, or one that only applies at build time) is
|
||||
recorded there as a deliberate omission with its reason, not left silent.
|
||||
- **A Hall of Fame row has EIGHT cells**, in this order: CVE, Icon, Vulnerability
|
||||
name, Date, Responsible Security Disclosure by, Stars, Process, Vulnerabilities.
|
||||
One thing per cell — the name without its icons, the Font Awesome icons alone in
|
||||
Icon (still wrapped in `<h2>`), the reporter alone in "by", the `GoldStar.png`
|
||||
images alone in Stars, and the sentence about how the report arrived (coordinated
|
||||
disclosure, GitHub advisory, code scanning, found while reviewing …) in Process.
|
||||
In "by", the reporter's NICKNAME is the link —
|
||||
`<b><a href="https://github.com/nick">nick</a></b>`, never the name followed by a
|
||||
separate "(GitHub)" link. **Check the account exists before linking it** (a 404 or,
|
||||
worse, somebody else's profile is the failure here); a real name, a company or a
|
||||
tool such as GitHub CodeQL is not a GitHub user and stays plain text. Do not write
|
||||
a role note after a name — no `(fix)`, `(found)` or `(found and fix)`: this column
|
||||
is the credit, and the Process cell beside it already says how the report arrived.
|
||||
Stars is a column so it can be compared down the page; do not put a star, an icon
|
||||
or the process sentence back into another cell. A page-local `<style>` in
|
||||
`index.html` keeps a row's stars on ONE line from 900px up (they are a count) and
|
||||
sizes them at 24px, and keeps the icon and its red drop on one line at every width
|
||||
(they are one icon) — the table itself stays percentage-sized with no fixed or
|
||||
minimum width, so it fills the page at every browser width.
|
||||
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
|
||||
collapsed.** Those two columns carry the prose — how the report arrived, and the
|
||||
whole story of the vulnerability — so the page would be one wall of text and the
|
||||
columns that identify a row (CVE, name, date, reporter, stars) would be far apart.
|
||||
In `hall-of-fame/index.html`, and ONLY in those two `<td>`s, the cell's content
|
||||
sits inside a `<details>` whose summary is the column's own name — `Process` for
|
||||
the Process cell, `Details` for the Vulnerabilities cell:
|
||||
|
||||
```
|
||||
<td valign="top">
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
<ul>
|
||||
<li>… the same list as before …</li>
|
||||
</ul>
|
||||
</details>
|
||||
</td>
|
||||
```
|
||||
|
||||
The summary is that plain word, there is a blank line under it, and the cell's
|
||||
contents are otherwise unchanged. The other six cells of the row stay as they are
|
||||
— they are what a reader scans — and so does the header row.
|
||||
- **The vuln-name subdirectory page is NOT collapsed.** `hall-of-fame/<name>bleed/index.html`
|
||||
is the page a reader opened on purpose, for one vulnerability: its table cell stays
|
||||
open and its `<h2 class="hof">Details</h2>` prose section below the table stays as
|
||||
it is. Only the index, which lists them all, hides them behind the summary.
|
||||
|
||||
### Tests
|
||||
|
||||
- `build.sh` / `build.bat` have a menu to install dependencies, build
|
||||
WeKan and run tests.
|
||||
- Add tests, negative tests and UI tests for all new features and fixes that do not yet
|
||||
have tests. When adding a test, run or validate it and fix it until it works.
|
||||
- **"Check newest test logs":** test logs go into a `log/<datetime>/` directory,
|
||||
one per run (e.g. `2026-07-21_20-58-09/`). That is **`.tools/log/`** inside the
|
||||
repository's ignored tool area, so a run does not show up in `git status` and
|
||||
does not depend on the parent directory being writable. The
|
||||
newest datetime directory is the latest run. Each holds the Playwright per-browser
|
||||
logs (`wekan-alltests-chromium.log`, `-firefox.log`, `-webkit.log`), the mocha/unit
|
||||
log (`wekan-alltests-mocha.log`), the e2e/import logs, and `wekan-test-server.log`
|
||||
(the WeKan test server + database output). A whole-run directory also holds the
|
||||
database-conformance logs (`db-conformance-*.log`, `-report.md`, `-summary.txt`)
|
||||
and FerretDB's own (`ferretdb-unit.log`, `-vet.log`, `-integration.log`). "Check
|
||||
the newest test logs" means: open the most recent
|
||||
`.tools/log/<datetime>/` and read those.
|
||||
- **Check and fix WHILE the tests are still running.** A full run takes a long time
|
||||
(three browsers, then every database with an image for this CPU, then FerretDB's
|
||||
own suites), and its stages finish one at a time. Do not wait for the end: read
|
||||
the logs that are already written, fix what they show, and then look again for the
|
||||
stages that have finished since — repeat until every stage has run and everything
|
||||
found is fixed. Two things make this work:
|
||||
- A stage's log file is complete when its `===== ... finished` line is there; a
|
||||
file whose mtime is still moving is a stage in progress, and its failures so far
|
||||
are already real and worth fixing.
|
||||
- The node suites are run by `tests/run-node-suites.cjs`, which runs ALL of them
|
||||
and lists every failure at the end (`===== node suites: N run, M failed`). They
|
||||
are the fastest signal - about 15 seconds for 260 suites - so they are usually
|
||||
what to fix first while the browsers are still going.
|
||||
- **A failing guard is not automatically a broken app.** Most of these suites read
|
||||
the source and pin a behaviour. When one fails, decide which side is wrong: fix
|
||||
the CODE when the guard still describes what WeKan should do, and fix the GUARD
|
||||
when the behaviour deliberately changed - and then say in the test WHY, so the
|
||||
next reader knows it was a decision and not a slip. Every fixed test keeps the
|
||||
assertion that made it valuable; do not delete a test to make a run green.
|
||||
|
||||
## Environment
|
||||
|
||||
- The editor (VSCodium) runs inside a **Flatpak sandbox**, launched by
|
||||
`docs/Security/Sandboxes/vscodium/vscodium-sandbox.sh`. What the sandbox allows/blocks and
|
||||
how it is set up is documented in `docs/Security/Sandboxes/vscodium/README.md` in that
|
||||
same directory — read it when something behaves differently than a normal host (file
|
||||
access, network, running services).
|
||||
|
||||
### Flatpak sandbox: install task tools under `.tools/`
|
||||
|
||||
- In the Flatpak sandbox launched by
|
||||
`docs/Security/Sandboxes/vscodium/vscodium-sandbox.sh`,
|
||||
the repository may be the only writable/shared host directory. When a command
|
||||
needs a tool that is missing, install its binary, virtual environment, models
|
||||
and caches under the repository-local `.tools/` directory. Do not install it
|
||||
into the read-only Flatpak image or scatter generated tool files through the
|
||||
source tree. `.tools/` is excluded by both `.gitignore` and `.meteorignore`, so
|
||||
local toolchains neither enter commits nor consume Meteor file-watcher slots.
|
||||
- Use the version selected by the repository, resolved from the current files at
|
||||
install time; never copy a version number from an old log or hard-code the
|
||||
example currently shown in the sandbox README. In particular: Node.js and npm
|
||||
come from `NODE_VERSION` and `NPM_VERSION` in `Dockerfile`; Meteor comes from
|
||||
`.meteor/release`; companion repositories use the versions in their own build
|
||||
scripts or module files. Use the architecture reported by `uname -m`. Reuse an
|
||||
existing matching `.tools` installation before downloading another copy.
|
||||
- Follow the complete, tested bootstrap commands and environment variables in
|
||||
`docs/Security/Sandboxes/vscodium/README.md`, especially its "Reuse same
|
||||
in-sandbox toolchain" and test-runtime sections. Prefer repository setup
|
||||
helpers such as
|
||||
`build.sh` and `releases/ensure-tools.sh` when they already install the needed
|
||||
pinned tool. Keep `HOME`, `PATH`, `GOROOT`, `GOPATH`, `GOCACHE`, `GOMODCACHE`
|
||||
and similar overrides scoped to the command or sandbox terminal; do not change
|
||||
the real host home or a system installation.
|
||||
- For screenshot or image text that cannot be read by the normal image viewer
|
||||
because Flatpak/bubblewrap cannot create a user namespace, install RapidOCR
|
||||
locally instead of guessing from filenames or logs:
|
||||
|
||||
```bash
|
||||
python3 -m venv .tools/ocr-venv
|
||||
.tools/ocr-venv/bin/pip install rapidocr_onnxruntime pillow
|
||||
.tools/ocr-venv/bin/python -c 'from rapidocr_onnxruntime import RapidOCR; import sys; result, _ = RapidOCR()(sys.argv[1]); print("\n".join(row[1] for row in (result or [])))' path/to/screenshot.png
|
||||
```
|
||||
|
||||
Process a timestamped screenshot series in chronological order and correlate
|
||||
OCR output with the matching `.tools/log/<datetime>/` files. OCR is evidence
|
||||
with recognition errors: retain timestamps and coordinates when needed, verify
|
||||
suspicious numbers against adjacent frames, and do not claim visual details
|
||||
that neither OCR nor another available viewer confirmed.
|
||||
|
||||
### Always validate from the actual code
|
||||
|
||||
- When doing anything, check how it actually works in the code first.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
For all code at WeKan GitHub Organization https://github.com/wekan
|
||||
|
||||
- For pull requests, mention only those participants that are **human**: the person who made the change, not the AI tool they used to make it. No "Co-Authored-By" AI trailer, no "Generated with", and no assistant or model name, in commits, pull request bodies or the CHANGELOG.
|
||||
- An AI that opened the pull request **itself**, with no human behind it — GitHub CodeQL raising a fix for something it found, Copilot Autofix, Dependabot — is the contributor, and is named as one. There is nobody else to credit.
|
||||
- The AI a human works with is thanked in one place only: https://wekan.fi/sponsors , under "AI donated by. All code and PRs verified by xet7", where Claude, Codex and GitHub Copilot are listed with the other sponsors. There were too many AI mentions before, so they were all moved to that one page.
|
||||
- Maintainer Lauri Ojansivu (xet7) commits directly to the `main` branch as `Lauri Ojansivu <x@xet7.org>`, and an AI working for the maintainer commits the same way, under that same name. Everyone else sends a pull request.
|
||||
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become 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
|
||||
- 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/Features/Login/Forgot-Password.md)
|
||||
|
||||
# Reporting about violations or something else
|
||||
|
||||
## Private reports
|
||||
|
||||
- Email support@wekan.fi
|
||||
- Security issues: [SECURITY.md](SECURITY.md)
|
||||
- License violations
|
||||
- Anything private, sensitive or negative
|
||||
|
||||
## Public
|
||||
|
||||
- Feature Requests and Bug Reports https://github.com/wekan/wekan/issues
|
||||
- Anything happy, positive, encouraging, helping, at friendly WeKan Global FOSS Community
|
||||
+2
-92
@@ -1,94 +1,4 @@
|
||||
## About money
|
||||
To get started, [please sign the Contributor License Agreement](https://www.clahub.com/agreements/wekan/wekan).
|
||||
|
||||
Not paid:
|
||||
[Then, please read documentation at wiki](https://github.com/wekan/wekan/wiki).
|
||||
|
||||
- Money is not paid for these, everyone uses their own time at their own cost:
|
||||
- Security reports, see [SECURITY.md](SECURITY.md)
|
||||
- Pull requests
|
||||
- xet7 checking pull requests
|
||||
- This is because full-featured WeKan FLOSS kanban with MIT license is free to use
|
||||
also for Commercial Use without any payment, with full source code at
|
||||
https://github.com/wekan/wekan for all platforms at https://wekan.fi/install/ .
|
||||
There is nothing proprietary. WeKan is NOT Big Tech.
|
||||
WeKan is maintained by https://github.com/xet7 at Finland https://wekan.fi .
|
||||
- Public FLOSS Developer Community Support:
|
||||
- https://github.com/wekan/wekan/issues
|
||||
|
||||
Paid by customers of WeKan to WeKan:
|
||||
|
||||
- Optional Private Paid Commercial Support at https://wekan.fi/commercial-support/
|
||||
- Optional Paid Support/Features/Fixes/PrivateChat . All Paid Features and Fixes are added directly to FLOSS WeKan https://github.com/wekan/wekan
|
||||
- Optional Paid Official SaaS Hosting https://wekan.fi/saas/ , if you need full-featured hosting, that is more than free app https://wekan.fi/app/
|
||||
|
||||
## Contributing Security related
|
||||
|
||||
For responsible security disclosure, please follow this process: [SECURITY.md](SECURITY.md)
|
||||
|
||||
CVE Hall of Fame is at https://wekan.fi/hall-of-fame/
|
||||
|
||||
## Contributing to Documentation Wiki
|
||||
|
||||
Fork WeKan repo https://github.com/wekan/wekan ,
|
||||
edit `docs` directory content at GitHub web interface,
|
||||
and click send PR.
|
||||
|
||||
## Contributing code
|
||||
|
||||
Use build.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
|
||||
GitHub usernames are mentioned with changes added:
|
||||
|
||||
https://github.com/wekan/wekan/blob/main/CHANGELOG.md
|
||||
|
||||
Changes can be like typo fixes, bugfixes, features, or anything else
|
||||
like for example at open GitHub issues https://github.com/wekan/wekan/issues .
|
||||
Closed issues are already fixed or implemented.
|
||||
|
||||
Also see other docs:
|
||||
|
||||
https://github.com/wekan/wekan/tree/main/docs/DeveloperDocs
|
||||
|
||||
WeKan code has MIT license.
|
||||
|
||||
About 300 persons have contributed to WeKan, stats at:
|
||||
|
||||
https://www.openhub.net/p/wekan
|
||||
|
||||
WeKan maintainer xet7 reviews PR for typos etc before accepting to WeKan,
|
||||
so that WeKan code will still work OK.
|
||||
|
||||
## Contributing translations
|
||||
|
||||
Non-English translations are contributed only at
|
||||
https://app.transifex.com/wekan/wekan
|
||||
|
||||
When adding new features, in your PR to
|
||||
https://github.com/wekan/wekan/pulls
|
||||
only add new English source language strings
|
||||
to https://github.com/wekan/wekan/blob/main/imports/i18n/data/en.i18n.json
|
||||
|
||||
Maintainer of WeKan xet7 downloads all newest
|
||||
translations from Transifex and adds
|
||||
them to WeKan repo before making
|
||||
new release.
|
||||
|
||||
## About WeKan Organization https://github.com/wekan
|
||||
|
||||
Only xet7 has write access to WeKan Organization.
|
||||
|
||||
xet7 reviews all PRs before merging.
|
||||
|
||||
There has been over 300 contributors to WeKan, newest stats at:
|
||||
|
||||
https://www.openhub.net/p/wekan
|
||||
+172
-318
@@ -1,91 +1,44 @@
|
||||
# debian:trixie (Debian 13), not ubuntu:26.04: Ubuntu publishes no linux/386
|
||||
# image, which stopped the image from ever building for i386. Debian ships every
|
||||
# arch this image targets - amd64, arm64, 386, arm/v7, ppc64le, riscv64, s390x -
|
||||
# so ONE base covers them all, and it is the same base WeKan's per-arch .zip
|
||||
# bundles are already built in (releases/... build-extra-arches).
|
||||
#
|
||||
# The ONE it does not ship is arm/v6: debian:trixie's manifest list has arm/v5
|
||||
# and arm/v7 and nothing between them. That matters because containerd treats a
|
||||
# LOWER ARM variant as compatible, so a linux/arm/v6 request does not fail - it
|
||||
# quietly resolves to the arm/v5 (armel, SOFT-float) image, whose loader and
|
||||
# glibc cannot run the hard-float node-armv6 in the armv6 bundle. The docker job
|
||||
# therefore asks this base what it publishes and drops a platform it lacks; see
|
||||
# docs/Platforms/FOSS/Container/Docker/CPU-platforms.md.
|
||||
FROM debian:trixie
|
||||
FROM ubuntu:disco
|
||||
LABEL maintainer="wekan"
|
||||
LABEL org.opencontainers.image.ref.name="debian"
|
||||
LABEL org.opencontainers.image.version="trixie"
|
||||
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=11.65
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
ENV \
|
||||
# Set the environment variables (defaults where required)
|
||||
# DOES NOT WORK: paxctl fix for alpine linux: https://github.com/wekan/wekan/issues/1303
|
||||
# ENV BUILD_DEPS="paxctl"
|
||||
ENV BUILD_DEPS="apt-utils bsdtar gnupg gosu wget curl bzip2 build-essential python3 python3-pip git ca-certificates gcc-8" \
|
||||
DEBUG=false \
|
||||
DDP_TRANSPORT=sockjs \
|
||||
NODE_VERSION=v24.21.0 \
|
||||
METEOR_RELEASE=METEOR@3.5.2 \
|
||||
NODE_VERSION=v8.16.0 \
|
||||
METEOR_RELEASE=1.6.0.1 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=11.12.1 \
|
||||
METEOR_EDGE=1.5-beta.17 \
|
||||
NPM_VERSION=latest \
|
||||
FIBERS_VERSION=2.0.0 \
|
||||
ARCHITECTURE=linux-x64 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
RESULTS_PER_PAGE="" \
|
||||
DEFAULT_BOARD_ID="" \
|
||||
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE=3 \
|
||||
ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD=60 \
|
||||
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW=15 \
|
||||
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE=3 \
|
||||
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD=60 \
|
||||
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW=15 \
|
||||
ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90 \
|
||||
ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM="" \
|
||||
ATTACHMENTS_UPLOAD_MIME_TYPES="" \
|
||||
ATTACHMENTS_UPLOAD_MAX_SIZE=0 \
|
||||
AVATARS_UPLOAD_EXTERNAL_PROGRAM="" \
|
||||
AVATARS_UPLOAD_MIME_TYPES="" \
|
||||
AVATARS_UPLOAD_MAX_SIZE=72000 \
|
||||
RICHER_CARD_COMMENT_EDITOR=false \
|
||||
CARD_OPENED_WEBHOOK_ENABLED=false \
|
||||
MAX_IMAGE_PIXEL="" \
|
||||
IMAGE_COMPRESS_RATIO="" \
|
||||
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
|
||||
BIGEVENTS_PATTERN=NONE \
|
||||
NOTIFY_ON_ASSIGN="true" \
|
||||
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
|
||||
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
|
||||
EMAIL_NOTIFICATION_TIMEOUT=30000 \
|
||||
MATOMO_ADDRESS="" \
|
||||
MATOMO_SITE_ID="" \
|
||||
MATOMO_DO_NOT_TRACK=true \
|
||||
MATOMO_WITH_USERNAME=false \
|
||||
METRICS_ALLOWED_IP_ADDRESSES="" \
|
||||
BROWSER_POLICY_ENABLED=true \
|
||||
TRUSTED_URL="" \
|
||||
WEBHOOKS_ATTRIBUTES="" \
|
||||
OAUTH2_ENABLED=false \
|
||||
OIDC_REDIRECTION_ENABLED=false \
|
||||
OAUTH2_CA_CERT="" \
|
||||
OAUTH2_ADFS_ENABLED=false \
|
||||
OAUTH2_B2C_ENABLED=false \
|
||||
OAUTH2_LOGIN_STYLE=redirect \
|
||||
OAUTH2_CLIENT_ID="" \
|
||||
OAUTH2_SECRET="" \
|
||||
OAUTH2_SECRET_FILE="" \
|
||||
OAUTH2_SERVER_URL="" \
|
||||
OAUTH2_AUTH_ENDPOINT="" \
|
||||
OAUTH2_USERINFO_ENDPOINT="" \
|
||||
OAUTH2_TOKEN_ENDPOINT="" \
|
||||
OAUTH2_LOGOUT_ENDPOINT="" \
|
||||
OAUTH2_ID_MAP="" \
|
||||
OAUTH2_USERNAME_MAP="" \
|
||||
OAUTH2_AUTO_REGISTRATION="true" \
|
||||
OAUTH2_ADMIN_GROUPS="" \
|
||||
OAUTH2_FULLNAME_MAP="" \
|
||||
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
|
||||
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
|
||||
@@ -93,9 +46,6 @@ ENV \
|
||||
LDAP_ENABLE=false \
|
||||
LDAP_PORT=389 \
|
||||
LDAP_HOST="" \
|
||||
LDAP_AD_SIMPLE_AUTH="" \
|
||||
LDAP_USER_AUTHENTICATION=false \
|
||||
LDAP_USER_AUTHENTICATION_FIELD=uid \
|
||||
LDAP_BASEDN="" \
|
||||
LDAP_LOGIN_FALLBACK=false \
|
||||
LDAP_RECONNECT=true \
|
||||
@@ -105,16 +55,15 @@ ENV \
|
||||
LDAP_AUTHENTIFICATION=false \
|
||||
LDAP_AUTHENTIFICATION_USERDN="" \
|
||||
LDAP_AUTHENTIFICATION_PASSWORD="" \
|
||||
LDAP_AUTHENTIFICATION_PASSWORD_FILE="" \
|
||||
LDAP_LOG_ENABLED=false \
|
||||
LDAP_BACKGROUND_SYNC=false \
|
||||
LDAP_BACKGROUND_SYNC_INTERVAL="" \
|
||||
LDAP_BACKGROUND_SYNC_INTERVAL=100 \
|
||||
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
|
||||
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
|
||||
LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false \
|
||||
LDAP_ENCRYPTION=false \
|
||||
LDAP_CA_CERT="" \
|
||||
LDAP_REJECT_UNAUTHORIZED=false \
|
||||
LDAP_USER_AUTHENTICATION=false \
|
||||
LDAP_USER_SEARCH_FILTER="" \
|
||||
LDAP_USER_SEARCH_SCOPE="" \
|
||||
LDAP_USER_SEARCH_FIELD="" \
|
||||
@@ -141,16 +90,10 @@ ENV \
|
||||
LDAP_DEFAULT_DOMAIN="" \
|
||||
LDAP_SYNC_ADMIN_STATUS="" \
|
||||
LDAP_SYNC_ADMIN_GROUPS="" \
|
||||
LDAP_SYNC_ORGANIZATIONS=false \
|
||||
LDAP_SYNC_ORGANIZATIONS_GROUPS="" \
|
||||
LDAP_SYNC_TEAMS=false \
|
||||
LDAP_SYNC_TEAMS_GROUPS="" \
|
||||
HEADER_LOGIN_ID="" \
|
||||
HEADER_LOGIN_FIRSTNAME="" \
|
||||
HEADER_LOGIN_LASTNAME="" \
|
||||
HEADER_LOGIN_EMAIL="" \
|
||||
HEADER_LOGIN_TRUSTED_IPS="" \
|
||||
HEADER_LOGIN_TRUSTED_PROXIES="" \
|
||||
LOGOUT_WITH_TIMER=false \
|
||||
LOGOUT_IN="" \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
@@ -158,257 +101,168 @@ ENV \
|
||||
CORS="" \
|
||||
CORS_ALLOW_HEADERS="" \
|
||||
CORS_EXPOSE_HEADERS="" \
|
||||
DEFAULT_AUTHENTICATION_METHOD="" \
|
||||
PASSWORD_LOGIN_ENABLED=true \
|
||||
CAS_ENABLED=false \
|
||||
CAS_BASE_URL="" \
|
||||
CAS_LOGIN_URL="" \
|
||||
CAS_VALIDATE_URL="" \
|
||||
SAML_ENABLED=false \
|
||||
SAML_PROVIDER="" \
|
||||
SAML_ENTRYPOINT="" \
|
||||
SAML_ISSUER="" \
|
||||
SAML_CERT="" \
|
||||
SAML_IDPSLO_REDIRECTURL="" \
|
||||
SAML_PRIVATE_KEYFILE="" \
|
||||
SAML_PUBLIC_CERTFILE="" \
|
||||
SAML_IDENTIFIER_FORMAT="" \
|
||||
SAML_LOCAL_PROFILE_MATCH_ATTRIBUTE="" \
|
||||
SAML_ATTRIBUTES="" \
|
||||
ORACLE_OIM_ENABLED=false \
|
||||
WAIT_SPINNER="" \
|
||||
WRITABLE_PATH=/data \
|
||||
S3="" \
|
||||
MAIL_SERVICE_PASSWORD_FILE="" \
|
||||
MONGO_PASSWORD_FILE="" \
|
||||
S3_SECRET_FILE=""
|
||||
DEFAULT_AUTHENTICATION_METHOD=""
|
||||
|
||||
# Where this image's Node.js comes from is decided by the SAME script the .zip
|
||||
# bundles and the snap use - official nodejs.org, then unofficial-builds, then
|
||||
# wekan/node-patches - so the image and the bundle of the same architecture can
|
||||
# never end up on Node.js from different places. It is copied in rather than
|
||||
# reimplemented here; a second copy of that order would drift from the first.
|
||||
COPY --chmod=755 releases/resolve-node-source.sh /tmp/resolve-node-source.sh
|
||||
# It asks nodejs.org and github.com which builds exist, through releases/fetch.sh
|
||||
# - which retries a 503 instead of reading it as "that build does not exist".
|
||||
# The two travel together: without this line the resolve step dies on the first
|
||||
# lookup. tests/releaseDownloads.test.cjs pins the pair.
|
||||
COPY --chmod=755 releases/fetch.sh /tmp/fetch.sh
|
||||
# The bundle's `npm install` leaves node-gyp's whole tree - 83 of the 120
|
||||
# packages in programs/server/node_modules - in a bundle that compiles nothing at
|
||||
# run time, and a scan of the published image reads it as what it is. The same
|
||||
# script runs in every per-arch leg of release-all.yml, so the .zip bundles and
|
||||
# this image are pruned identically. tests/imageBuildOnlyModules.test.cjs pins it.
|
||||
COPY --chmod=755 releases/prune-build-only-modules.mjs /tmp/prune-build-only-modules.mjs
|
||||
# Its companion, and its manifest: the npm packages Meteor's own packages bundle,
|
||||
# which nothing in package.json can reach. The .zip is built with these already
|
||||
# applied; what this run has to redo is the part `npm install` puts back.
|
||||
COPY --chmod=755 releases/bump-bundle-npm-deps.mjs /tmp/bump-bundle-npm-deps.mjs
|
||||
COPY --chmod=644 releases/bundle-npm-security-bumps.json /tmp/bundle-npm-security-bumps.json
|
||||
# And the third of the same kind: WeKan talks DDP over sockjs on every platform,
|
||||
# so no bundle carries uWebSockets.js. ddp-server requires that module only
|
||||
# inside the uws transport's setup(), which a sockjs server never calls, and it
|
||||
# is 121M of prebuilt binaries for OS/CPU/ABI combinations one machine cannot
|
||||
# use. The entrypoint coerces DDP_TRANSPORT=uws to sockjs, so an existing
|
||||
# compose file that asks for uws keeps working rather than crash-looping.
|
||||
COPY --chmod=755 releases/bundle-trim.mjs /tmp/bundle-trim.mjs
|
||||
# Its companion: the same idea applied to programs/server/npm/node_modules.
|
||||
COPY --chmod=755 releases/prune-unreachable-npm.mjs /tmp/prune-unreachable-npm.mjs
|
||||
# Copy the app to the image
|
||||
COPY ${SRC_PATH} /home/wekan/app
|
||||
|
||||
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
|
||||
RUN \
|
||||
set -o xtrace && \
|
||||
# Add non-root user wekan
|
||||
useradd --user-group --system --home-dir /home/wekan wekan && \
|
||||
\
|
||||
# OS dependencies
|
||||
apt-get update -y && apt-get install -y --no-install-recommends ${BUILD_DEPS} && \
|
||||
pip3 install -U pip setuptools wheel && \
|
||||
\
|
||||
# Meteor installer doesn't work with the default tar binary, so using bsdtar while installing.
|
||||
# https://github.com/coreos/bugs/issues/1095#issuecomment-350574389
|
||||
cp $(which tar) $(which tar)~ && \
|
||||
ln -sf $(which bsdtar) $(which tar) && \
|
||||
\
|
||||
# Download nodejs
|
||||
wget https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
|
||||
wget https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc && \
|
||||
#---------------------------------------------------------------------------------------------
|
||||
# Node Fibers 100% CPU usage issue:
|
||||
# https://github.com/wekan/wekan-mongodb/issues/2#issuecomment-381453161
|
||||
# https://github.com/meteor/meteor/issues/9796#issuecomment-381676326
|
||||
# https://github.com/sandstorm-io/sandstorm/blob/0f1fec013fe7208ed0fd97eb88b31b77e3c61f42/shell/server/00-startup.js#L99-L129
|
||||
# Also see beginning of wekan/server/authentication.js
|
||||
# import Fiber from "fibers";
|
||||
# Fiber.poolSize = 1e9;
|
||||
# OLD: Download node version 8.12.0 prerelease that has fix included, => Official 8.12.0 has been released
|
||||
# Description at https://releases.wekan.team/node.txt
|
||||
#wget https://releases.wekan.team/node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
|
||||
#echo "1ed54adb8497ad8967075a0b5d03dd5d0a502be43d4a4d84e5af489c613d7795 node-v8.12.0-linux-x64.tar.gz" >> SHASUMS256.txt.asc && \
|
||||
\
|
||||
# Verify nodejs authenticity
|
||||
grep ${NODE_VERSION}-${ARCHITECTURE}.tar.gz SHASUMS256.txt.asc | shasum -a 256 -c - && \
|
||||
#export GNUPGHOME="$(mktemp -d)" && \
|
||||
#\
|
||||
# Try other key servers if ha.pool.sks-keyservers.net is unreachable
|
||||
# Code from https://github.com/chorrell/docker-node/commit/2b673e17547c34f17f24553db02beefbac98d23c
|
||||
# gpg keys listed at https://github.com/nodejs/node#release-team
|
||||
# and keys listed here from previous version of this Dockerfile
|
||||
#for key in \
|
||||
#9554F04D7259F04124DE6B476D5A82AC7E37093B \
|
||||
#94AE36675C464D64BAFA68DD7434390BDBE9B9C5 \
|
||||
#FD3A5288F042B6850C66B31F09FE44734EB7990E \
|
||||
#71DCFD284A79C3B38668286BC97EC7A07EDE3FC1 \
|
||||
#DD8F2338BAE7501E3DD5AC78C273792F7D83545D \
|
||||
#C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \
|
||||
#B9AE9905FFD7803F25714661B63B535A4C206CA9 \
|
||||
#; do \
|
||||
#gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$key" || \
|
||||
#gpg --keyserver pgp.mit.edu --recv-keys "$key" || \
|
||||
#gpg --keyserver keyserver.pgp.com --recv-keys "$key" ; \
|
||||
#done && \
|
||||
#gpg --verify SHASUMS256.txt.asc && \
|
||||
# Ignore socket files then delete files then delete directories
|
||||
#find "$GNUPGHOME" -type f | xargs rm -f && \
|
||||
#find "$GNUPGHOME" -type d | xargs rm -fR && \
|
||||
rm -f SHASUMS256.txt.asc && \
|
||||
\
|
||||
# Install Node
|
||||
tar xvzf node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
|
||||
rm node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
|
||||
mv node-${NODE_VERSION}-${ARCHITECTURE} /opt/nodejs && \
|
||||
ln -s /opt/nodejs/bin/node /usr/bin/node && \
|
||||
ln -s /opt/nodejs/bin/npm /usr/bin/npm && \
|
||||
\
|
||||
#DOES NOT WORK: paxctl fix for alpine linux: https://github.com/wekan/wekan/issues/1303
|
||||
#paxctl -mC `which node` && \
|
||||
\
|
||||
# Install Node dependencies
|
||||
npm install -g npm@${NPM_VERSION} && \
|
||||
npm install -g node-gyp && \
|
||||
npm install -g fibers@${FIBERS_VERSION} && \
|
||||
\
|
||||
# Change user to wekan and install meteor
|
||||
cd /home/wekan/ && \
|
||||
chown wekan:wekan --recursive /home/wekan && \
|
||||
curl "https://install.meteor.com" -o /home/wekan/install_meteor.sh && \
|
||||
#curl "https://install.meteor.com/?release=${METEOR_RELEASE}" -o /home/wekan/install_meteor.sh && \
|
||||
# OLD: sed -i "s|RELEASE=.*|RELEASE=${METEOR_RELEASE}\"\"|g" ./install_meteor.sh && \
|
||||
# Install Meteor forcing its progress
|
||||
sed -i 's/VERBOSITY="--silent"/VERBOSITY="--progress-bar"/' ./install_meteor.sh && \
|
||||
echo "Starting meteor ${METEOR_RELEASE} installation... \n" && \
|
||||
chown wekan:wekan /home/wekan/install_meteor.sh && \
|
||||
\
|
||||
# Check if opting for a release candidate instead of major release
|
||||
if [ "$USE_EDGE" = false ]; then \
|
||||
gosu wekan:wekan sh /home/wekan/install_meteor.sh; \
|
||||
else \
|
||||
gosu wekan:wekan git clone --recursive --depth 1 -b release/METEOR@${METEOR_EDGE} git://github.com/meteor/meteor.git /home/wekan/.meteor; \
|
||||
fi; \
|
||||
\
|
||||
# Get additional packages
|
||||
#mkdir -p /home/wekan/app/packages && \
|
||||
#chown wekan:wekan --recursive /home/wekan && \
|
||||
# REPOS BELOW ARE INCLUDED TO WEKAN REPO
|
||||
#cd /home/wekan/app/packages && \
|
||||
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/flow-router.git kadira-flow-router && \
|
||||
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/meteor-useraccounts/core.git meteor-useraccounts-core && \
|
||||
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-cas.git && \
|
||||
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/wekan-ldap.git && \
|
||||
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/wekan-scrollbar.git && \
|
||||
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-oidc.git && \
|
||||
#gosu wekan:wekan git clone --depth 1 -b master --recurse-submodules https://github.com/wekan/markdown.git && \
|
||||
#gosu wekan:wekan mv meteor-accounts-oidc/packages/switch_accounts-oidc wekan-accounts-oidc && \
|
||||
#gosu wekan:wekan mv meteor-accounts-oidc/packages/switch_oidc wekan-oidc && \
|
||||
#gosu wekan:wekan rm -rf meteor-accounts-oidc && \
|
||||
sed -i 's/api\.versionsFrom/\/\/api.versionsFrom/' /home/wekan/app/packages/meteor-useraccounts-core/package.js && \
|
||||
cd /home/wekan/.meteor && \
|
||||
gosu wekan:wekan /home/wekan/.meteor/meteor -- help; \
|
||||
\
|
||||
# extract the OpenAPI specification
|
||||
npm install -g api2html@0.3.3 && \
|
||||
mkdir -p /home/wekan/python && \
|
||||
chown wekan:wekan --recursive /home/wekan/python && \
|
||||
cd /home/wekan/python && \
|
||||
gosu wekan:wekan git clone --depth 1 -b master https://github.com/Kronuz/esprima-python && \
|
||||
cd /home/wekan/python/esprima-python && \
|
||||
python3 setup.py install --record files.txt && \
|
||||
cd /home/wekan/app &&\
|
||||
gosu wekan:wekan mkdir -p ./public/api && \
|
||||
gosu wekan:wekan python3 ./openapi/generate_openapi.py --release $(git describe --tags --abbrev=0) > ./public/api/wekan.yml && \
|
||||
gosu wekan:wekan /opt/nodejs/bin/api2html -c ./public/logo-header.png -o ./public/api/wekan.html ./public/api/wekan.yml; \
|
||||
# Build app
|
||||
cd /home/wekan/app && \
|
||||
gosu wekan:wekan /home/wekan/.meteor/meteor add standard-minifier-js && \
|
||||
gosu wekan:wekan /home/wekan/.meteor/meteor npm install && \
|
||||
gosu wekan:wekan /home/wekan/.meteor/meteor build --directory /home/wekan/app_build && \
|
||||
cp /home/wekan/app/fix-download-unicode/cfs_access-point.txt /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js && \
|
||||
rm /home/wekan/app_build/bundle/programs/server/npm/node_modules/meteor/rajit_bootstrap3-datepicker/lib/bootstrap-datepicker/node_modules/phantomjs-prebuilt/lib/phantom/bin/phantomjs && \
|
||||
chown wekan:wekan /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js && \
|
||||
#Removed binary version of bcrypt because of security vulnerability that is not fixed yet.
|
||||
#https://github.com/wekan/wekan/commit/4b2010213907c61b0e0482ab55abb06f6a668eac
|
||||
#https://github.com/wekan/wekan/commit/7eeabf14be3c63fae2226e561ef8a0c1390c8d3c
|
||||
#cd /home/wekan/app_build/bundle/programs/server/npm/node_modules/meteor/npm-bcrypt && \
|
||||
#gosu wekan:wekan rm -rf node_modules/bcrypt && \
|
||||
#gosu wekan:wekan npm install bcrypt && \
|
||||
cd /home/wekan/app_build/bundle/programs/server/ && \
|
||||
gosu wekan:wekan npm install && \
|
||||
#gosu wekan:wekan npm install bcrypt && \
|
||||
mv /home/wekan/app_build/bundle /build && \
|
||||
\
|
||||
# Put back the original tar
|
||||
mv $(which tar)~ $(which tar) && \
|
||||
\
|
||||
# Cleanup
|
||||
apt-get remove --purge -y ${BUILD_DEPS} && \
|
||||
apt-get autoremove -y && \
|
||||
npm uninstall -g api2html &&\
|
||||
rm -R /var/lib/apt/lists/* && \
|
||||
rm -R /home/wekan/.meteor && \
|
||||
rm -R /home/wekan/app && \
|
||||
rm -R /home/wekan/app_build && \
|
||||
cat /home/wekan/python/esprima-python/files.txt | xargs rm -R && \
|
||||
rm -R /home/wekan/python && \
|
||||
rm /home/wekan/install_meteor.sh
|
||||
|
||||
# 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
|
||||
apt-get upgrade --assume-yes
|
||||
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS}
|
||||
|
||||
# Multi-arch mapping: Docker TARGETARCH -> WeKan's own platform name, which is
|
||||
# both the bundle .zip's name and what resolve-node-source.sh is asked about.
|
||||
# amd64/arm64 have MongoDB Community; ppc64le/s390x/riscv64 have no MongoDB
|
||||
# server and ship FerretDB v1 instead (the ferretdb binary is baked into their
|
||||
# .zip and started by wekan-entrypoint.sh). Debian's 32-bit ARM port is armhf
|
||||
# (ARMv7, VFPv3-D16), which is what linux/arm/v7 runs.
|
||||
case "${TARGETARCH}" in
|
||||
"amd64") WEKAN_ARCH="amd64" ;;
|
||||
"arm64") WEKAN_ARCH="arm64" ;;
|
||||
"ppc64le") WEKAN_ARCH="ppc64le" ;;
|
||||
"s390x") WEKAN_ARCH="s390x" ;;
|
||||
"riscv64") WEKAN_ARCH="riscv64" ;;
|
||||
"386") WEKAN_ARCH="i386" ;;
|
||||
# BOTH linux/arm/v6 and linux/arm/v7 arrive here as TARGETARCH=arm - the CPU
|
||||
# generation is in TARGETVARIANT, not in TARGETARCH - so this branch MUST read
|
||||
# the variant. Mapping "arm" straight to armhf, as it did, would hand an ARMv6
|
||||
# board (Raspberry Pi 1, Zero) the armhf bundle, which is built to Debian's
|
||||
# ARMv7-A baseline and whose instructions that board cannot execute.
|
||||
"arm")
|
||||
case "${TARGETVARIANT}" in
|
||||
"v6") WEKAN_ARCH="armv6" ;;
|
||||
# v7, and an unset variant, are Debian's armhf port: ARMv7-A,
|
||||
# VFPv3-D16 hard-float. That is what linux/arm/v7 runs.
|
||||
"v7"|"") WEKAN_ARCH="armhf" ;;
|
||||
# v5 is armel: FerretDB and the MongoDB tools publish it (they are Go),
|
||||
# but Node.js does not exist for ARMv5 at all, so there is no bundle to
|
||||
# put in an image and refusing is the only honest answer.
|
||||
*) echo "Unsupported 32-bit ARM variant: ${TARGETVARIANT} (only v6 and v7 have a WeKan bundle)"; exit 1 ;;
|
||||
esac ;;
|
||||
*) echo "Unsupported architecture: ${TARGETARCH}${TARGETVARIANT:+/${TARGETVARIANT}}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Node.js installation - official nodejs.org, then unofficial-builds.nodejs.org,
|
||||
# then wekan/node-patches, in that order, for EVERY architecture.
|
||||
#
|
||||
# The choice is made by releases/resolve-node-source.sh, copied in above and used
|
||||
# by the .zip bundles and the snap as well, so the image and the bundle of one
|
||||
# architecture always carry the same Node.js from the same place. It is asked
|
||||
# about the MAJOR, so a CPU whose newest build lags a release still gets its
|
||||
# newest: it answers with the exact file, what shape that file is, and the
|
||||
# SHA256 the source published for it.
|
||||
#
|
||||
# npm is arch-independent JavaScript, so it is grafted from the official amd64
|
||||
# tarball of the version that was resolved - a build-time tool, not the shipped
|
||||
# node. (node-patches publishes a bare node binary and no npm at all, which is
|
||||
# why npm is fetched separately rather than taken from the archive above.)
|
||||
cd /tmp
|
||||
NODE_MAJOR="${NODE_VERSION#v}"; NODE_MAJOR="${NODE_MAJOR%%.*}"
|
||||
if ! NODE_META="$(bash /tmp/resolve-node-source.sh "${WEKAN_ARCH}" "${NODE_MAJOR}")"; then
|
||||
echo "No Node.js ${NODE_MAJOR}.x for ${WEKAN_ARCH} at nodejs.org, unofficial-builds.nodejs.org or wekan/node-patches, so this image cannot be built for it. Build node-${WEKAN_ARCH} in wekan/node-patches, or drop ${TARGETARCH} from the image's platform list." >&2
|
||||
exit 1
|
||||
fi
|
||||
eval "${NODE_META}"
|
||||
echo "Node.js ${node_full} for ${WEKAN_ARCH}: ${node_from} (${node_url})"
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=403,500,502,503 -O node-download "${node_url}"
|
||||
echo "${node_sha256} node-download" | sha256sum -c -
|
||||
case "${node_kind}" in
|
||||
binary)
|
||||
cp node-download /usr/local/bin/node
|
||||
;;
|
||||
tar.xz|tar.gz|tar)
|
||||
# bsdtar (libarchive-tools) is installed below for the bundle; plain tar
|
||||
# here, letting it detect the compression rather than naming it.
|
||||
tar -xf node-download --no-same-owner "${node_member}"
|
||||
cp "${node_member}" /usr/local/bin/node
|
||||
;;
|
||||
*)
|
||||
echo "resolve-node-source.sh returned an unknown kind '${node_kind}'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
chmod +x /usr/local/bin/node
|
||||
# npm + npx from the official amd64 tarball of the SAME version; extract only
|
||||
# those paths, not the amd64 node binary.
|
||||
# Same flags as the download above: a bare wget treats a 503 as fatal, and
|
||||
# nodejs.org has them.
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=403,500,502,503 \
|
||||
"https://nodejs.org/dist/${node_full}/node-${node_full}-linux-x64.tar.gz"
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=403,500,502,503 \
|
||||
"https://nodejs.org/dist/${node_full}/SHASUMS256.txt"
|
||||
grep " node-${node_full}-linux-x64.tar.gz\$" SHASUMS256.txt | sha256sum -c -
|
||||
tar xzf "node-${node_full}-linux-x64.tar.gz" -C /usr/local --strip-components=1 --no-same-owner \
|
||||
"node-${node_full}-linux-x64/lib/node_modules/npm" \
|
||||
"node-${node_full}-linux-x64/bin/npm" \
|
||||
"node-${node_full}-linux-x64/bin/npx"
|
||||
rm -rf node-download "node-${node_full}-linux-x64.tar.gz" "node-${node_full}-linux-x64" SHASUMS256.txt
|
||||
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
|
||||
|
||||
# NPM configuration
|
||||
npm install -g npm@${NPM_VERSION}
|
||||
chown --recursive wekan:wekan /home/wekan/
|
||||
|
||||
# Temporary Tar swap for Meteor bundle
|
||||
cp $(which tar) $(which tar)~
|
||||
ln -sf $(which bsdtar) $(which tar)
|
||||
|
||||
# WeKan Bundle Installation
|
||||
mkdir -p /home/wekan/app
|
||||
cd /home/wekan/app
|
||||
# Retry the release-asset download: even though the CI `docker` job needs the
|
||||
# `release` job (so the asset is already uploaded), GitHub's
|
||||
# releases/download/<tag>/<asset> URL can briefly return 404 right after upload
|
||||
# (CDN/propagation lag). A plain wget treats 404 as fatal, which failed the
|
||||
# build; retry on transient HTTP errors so propagation lag no longer breaks it.
|
||||
WEKAN_ZIP_URL="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 "${WEKAN_ZIP_URL}" \
|
||||
|| { echo "Failed to download ${WEKAN_ZIP_URL} after retries"; exit 8; }
|
||||
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
npm install --prefix ./bundle/programs/server
|
||||
# node-gyp and @mapbox/node-pre-gyp compiled nothing here - every native module
|
||||
# in the bundle is a prebuilt .node - and nothing in boot.js reaches them. Their
|
||||
# tree is what shipped `tar` 6.2.1 (CRITICAL) and npm's networking stack to the
|
||||
# published image, so it goes now that the install is done.
|
||||
node /tmp/prune-build-only-modules.mjs ./bundle
|
||||
# The .zip already carries the bumped meteor/ tree; this install put back
|
||||
# meteor-dev-bundle's own underscore 1.13.7 pin (CVE-2026-27601) over it, so the
|
||||
# same pass runs here.
|
||||
node /tmp/bump-bundle-npm-deps.mjs ./bundle
|
||||
# No uWebSockets.js, no legacy client, no source maps: this image runs sockjs,
|
||||
# serves web.browser to every browser, and has no debugger attached to it.
|
||||
node /tmp/bundle-trim.mjs ./bundle --transport sockjs --drop-legacy-client
|
||||
# And the npm tree rspack cannot tree-shake, because Atmosphere packages load it
|
||||
# through Npm.require(). Only what it can prove nothing requires.
|
||||
node /tmp/prune-unreachable-npm.mjs ./bundle
|
||||
mv /home/wekan/app/bundle /build
|
||||
|
||||
# The .zip bundle now ships a self-contained launcher + its own Node.js for the
|
||||
# offline downloads; the Docker image installs its own Node and uses
|
||||
# wekan-entrypoint.sh, so drop the redundant bundled node + launchers. Keeps
|
||||
# /build/ferretdb (used by the entrypoint) and the per-arch MongoDB Database Tools
|
||||
# (bsondump, mongodump, mongorestore, … from wekan/mongo-tools-patches, embedded in the
|
||||
# bundle) for backup/restore inside the container. Saves ~80 MB per arch.
|
||||
rm -f /build/node /build/start-wekan.sh /build/start-wekan.bat
|
||||
|
||||
# Restore original tar
|
||||
mv $(which tar)~ $(which tar)
|
||||
|
||||
# Cleanup
|
||||
# npm is a BUILD tool in this image and nothing else: the only thing it does is
|
||||
# the `npm install` above, and the container starts bash + wekan-entrypoint.sh,
|
||||
# which never calls it. Shipping it shipped npm's own bundled dependencies - its
|
||||
# `tar` (CRITICAL), `sigstore`, `@sigstore/*`, `ip-address`, `brace-expansion` -
|
||||
# as image content that no code path can reach. node itself stays, because that
|
||||
# is what runs WeKan.
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
# 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
|
||||
rm -Rf /tmp/*
|
||||
rm -Rf /var/lib/apt/lists/*
|
||||
rm -Rf /home/wekan/app
|
||||
|
||||
mkdir -p /data
|
||||
chown wekan:wekan --recursive /data
|
||||
EOR
|
||||
|
||||
# Database-backend selector entrypoint. Every arch's bundle now ships a FerretDB
|
||||
# binary (baked into the .zip and moved to /build/ferretdb above); this script
|
||||
# starts FerretDB v1 (SQLite) or leaves the DB external based on WEKAN_DB and the
|
||||
# /build/.ferretdb-default marker (present only on MongoDB-less arches). See
|
||||
# releases/ferretdb/wekan-entrypoint.sh.
|
||||
COPY --chmod=755 releases/ferretdb/wekan-entrypoint.sh /build/wekan-entrypoint.sh
|
||||
# #6492: standalone "recovering data" page the entrypoint serves as a brief bridge on
|
||||
# the web port while a just-restored FerretDB comes back up during a data recovery.
|
||||
COPY --chmod=644 releases/ferretdb/recovery-bridge.mjs /build/recovery-bridge.mjs
|
||||
# #6595: the entrypoint asks this whether the database answers yet, so it can
|
||||
# serve the "waiting for database" page instead of leaving the web port unbound
|
||||
# for a reverse proxy to time out on.
|
||||
COPY --chmod=644 releases/ferretdb/db-ready.mjs /build/db-ready.mjs
|
||||
|
||||
USER wekan
|
||||
ENV PORT=8080
|
||||
EXPOSE $PORT
|
||||
STOPSIGNAL SIGKILL
|
||||
WORKDIR /build
|
||||
USER wekan
|
||||
|
||||
CMD ["bash", "/build/wekan-entrypoint.sh"]
|
||||
CMD ["node", "/build/main.js"]
|
||||
@@ -1,40 +0,0 @@
|
||||
# Future
|
||||
|
||||
## Moved Import/Export/Sync issues to Big Picture Roadmap wiki page
|
||||
|
||||
This change is limited to only Import/Export/Sync issues, while those are In Progress of being fixed.
|
||||
|
||||
2023-11-21 xet7 closed 261 issues that are linked at https://github.com/wekan/wekan/wiki/Sync ,
|
||||
that is Roadmap of Import/Export/Sync in WeKan. It means, that those issues progress will be
|
||||
updated at that wiki page, when xet7 and other WeKan contributors fix those.
|
||||
Many of those issues are In Progress of being fixed and added.
|
||||
|
||||
## Platform Updates
|
||||
|
||||
Issues related to platforms are being closed, because only list of working platforms is mentioned now
|
||||
at WeKan website https://wekan.github.io Install section and at [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md)
|
||||
where is this new text:
|
||||
|
||||
> Newest WeKan at amd64 platforms: Linux bundle, Snap Candidate, Docker, Kubernetes. Fixing other platforms In Progress.
|
||||
|
||||
Platform support changes often, because:
|
||||
|
||||
- There are many dependencies, that update or break or change often
|
||||
- Node.js segfaults at some CPU/OS
|
||||
- Some platforms have build errors
|
||||
|
||||
Roadmap is to update all existing platforms, and add more platforms.
|
||||
|
||||
Upcoming platform upgrades:
|
||||
|
||||
- Fix migrations, so that newest WeKan can be released to Snap Stable. (Currently newest is at Snap Candidate).
|
||||
|
||||
## WeKan features
|
||||
|
||||
Most Meteor WeKan features are listed here:
|
||||
|
||||
https://github.com/wekan/wekan/wiki/Deep-Dive-Into-WeKan
|
||||
|
||||
Remaining features and all changes are listed here:
|
||||
|
||||
https://github.com/wekan/wekan/blob/main/CHANGELOG.md
|
||||
@@ -1,10 +0,0 @@
|
||||
# Governance
|
||||
|
||||
Anyone can send pull request to https://github.com/wekan/wekan/wiki/pulls ,
|
||||
if there is permission to add code to WeKan with MIT license.
|
||||
|
||||
As maintainer, xet7 checks all pull requests and merges them.
|
||||
|
||||
Only xet7 has write access to repo https://github.com/wekan/wekan
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Lauri Ojansivu
|
||||
Copyright (c) 2014-2019 The Wekan Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,68 +1,42 @@
|
||||
# WeKan ® - Open Source kanban
|
||||
# Wekan - Open Source kanban
|
||||
|
||||
## Downloads
|
||||
[](https://github.com/wekan/wekan/graphs/contributors)
|
||||
[](https://quay.io/repository/wekan/wekan)
|
||||
[](https://hub.docker.com/r/wekanteam/wekan)
|
||||
[](https://hub.docker.com/r/wekanteam/wekan)
|
||||
[![Wekan Build Status][travis_badge]][travis_status]
|
||||
[](https://www.codacy.com/app/xet7/wekan?utm_source=github.com&utm_medium=referral&utm_content=wekan/wekan&utm_campaign=Badge_Grade)
|
||||
[](https://codeclimate.com/github/wekan/wekan)
|
||||
[](https://david-dm.org/wekan/wekan)
|
||||
[](https://www.openhub.net/p/wekan)
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2Fwekan%2Fwekan?ref=badge_shield)
|
||||
|
||||
https://wekan.fi/install/
|
||||
## [Translate Wekan at Transifex](https://transifex.com/wekan/wekan)
|
||||
|
||||
## Docker Containers
|
||||
Translations to non-English languages are accepted only at [Transifex](https://transifex.com/wekan/wekan) using webbrowser.
|
||||
New English strings of new features can be added as PRs to edge branch file wekan/i18n/en.i18n.json .
|
||||
|
||||
- [GitHub](https://ghcr.io/wekan/wekan)
|
||||
```
|
||||
image: ghcr.io/wekan/wekan:latest
|
||||
```
|
||||
- [Docker Hub](https://hub.docker.com/r/wekanteam/wekan)
|
||||
```
|
||||
image: wekanteam/wekan:latest
|
||||
```
|
||||
- [RedHat Quay.io](https://quay.io/wekan/wekan)
|
||||
```
|
||||
image: quay.io/wekan/wekan:latest
|
||||
```
|
||||
## [Wekan feature requests and bugs](https://github.com/wekan/wekan/issues)
|
||||
|
||||
docker-compose.yml at https://github.com/wekan/wekan/blob/main/docker-compose.yml
|
||||
|
||||
## Standards
|
||||
|
||||
- [WeKan and Standard for Public Code](https://wekan.fi/standard-for-public-code/) assessment was made at 2023-11.
|
||||
Currently Wekan meets 8 out of 16 criteria out of the box.
|
||||
Some others could be met with small changes.
|
||||
|
||||
## Code stats
|
||||
|
||||
- [CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/4619)
|
||||
- [Code Climate](https://codeclimate.com/github/wekan/wekan)
|
||||
- [Open Hub](https://www.openhub.net/p/wekan)
|
||||
- [OSS Insight](https://ossinsight.io/analyze/wekan/wekan)
|
||||
|
||||
## Translate WeKan ® at Transifex
|
||||
|
||||
Translations to non-English languages are accepted only at [Transifex](https://app.transifex.com/wekan/wekan) using webbrowser.
|
||||
New English strings of new features can be added as PRs to master branch file wekan/imports/i18n/data/en.i18n.json .
|
||||
|
||||
## WeKan ® feature requests and bugs
|
||||
|
||||
Please add most of your questions as GitHub issue: [WeKan ® Feature Requests and Bugs](https://github.com/wekan/wekan/issues).
|
||||
Please add most of your questions as GitHub issue: [Wekan feature requests and bugs](https://github.com/wekan/wekan/issues).
|
||||
It's better than at chat where details get lost when chat scrolls up.
|
||||
|
||||
## Discussions
|
||||
## Chat
|
||||
|
||||
[IRC](https://github.com/wekan/wekan/blob/main/docs/FAQ/IRC-FAQ.md)
|
||||
[![Wekan Chat][vanila_badge]][wekan_chat] - Most Wekan community and developers are here. Works on webbrowser
|
||||
and PWA app that can be added as icon on Android and bookmark on iOS, used like native app.
|
||||
|
||||
## Docker: Latest tag has newest release
|
||||
|
||||
You can use latest tag to get newest release tag.
|
||||
See bottom of https://github.com/wekan/wekan/issues/3874
|
||||
[Wekan IRC FAQ](https://github.com/wekan/wekan/wiki/IRC-FAQ)
|
||||
|
||||
## FAQ
|
||||
|
||||
**NOTE**:
|
||||
**NOTE**:
|
||||
- Please read the [FAQ](https://github.com/wekan/wekan/wiki/FAQ) first
|
||||
- Please don't feed the trolls and spammers that are mentioned in the FAQ :)
|
||||
|
||||
- Please read the [FAQ](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md) first
|
||||
- Please don't feed the [trolls](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-troll) and [spammers](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-spammer) that are mentioned in the FAQ :)
|
||||
## About Wekan
|
||||
|
||||
## About WeKan ®
|
||||
|
||||
WeKan ® is a completely [Open Source][open_source] and [Free software][free_software]
|
||||
Wekan is an completely [Open Source][open_source] and [Free software][free_software]
|
||||
collaborative kanban board application with MIT license.
|
||||
|
||||
Whether you’re maintaining a personal todo list, planning your holidays with some friends,
|
||||
@@ -70,108 +44,96 @@ or working in a team on your next revolutionary idea, Kanban boards are an unbea
|
||||
to keep your things organized. They give you a visual overview of the current state of your project,
|
||||
and make you productive by allowing you to focus on the few items that matter the most.
|
||||
|
||||
Since WeKan ® is a free software, you don’t have to trust us with your data and can
|
||||
Since Wekan is a free software, you don’t have to trust us with your data and can
|
||||
install Wekan on your own computer or server. In fact we encourage you to do
|
||||
that by providing one-click installation on various platforms.
|
||||
|
||||
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
|
||||
- WeKan ® largest user has 30k users using WeKan ® in their company.
|
||||
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to 234 languages,
|
||||
234 of them essentially complete.
|
||||
- [Features][https://github.com/wekan/wekan/tree/main/docs/Features]: WeKan ® has real-time user interface.
|
||||
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
|
||||
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
|
||||
- Wekan is used in [most countries of the world](https://snapcraft.io/wekan).
|
||||
- Wekan largest user has 13k users using Wekan in their company.
|
||||
- Wekan has been [translated](https://transifex.com/wekan/wekan) to about 50 languages.
|
||||
- [Features][features]: Wekan has real-time user interface.
|
||||
- [Platforms][platforms]: Wekan supports many platforms.
|
||||
Wekan is critical part of new platforms Wekan is currently being integrated to.
|
||||
- [Integrations][integrations]: Current possible integrations and future plans.
|
||||
|
||||
## Requirements
|
||||
|
||||
- 1 GB RAM minimum free for WeKan ®. Production server should have minimum total 4 GB RAM.
|
||||
For thousands of users, for example with [Docker](https://github.com/wekan/wekan/blob/main/docker-compose.yml): 3 frontend servers,
|
||||
each having 2 CPU and 2 wekan-app containers. One backend wekan-db server with many CPUs.
|
||||
- Enough disk space and alerts about low disk space. If you run out of disk space, MongoDB database gets corrupted.
|
||||
- SECURITY: Updating to newest WeKan ® version very often. Please check you do not have automatic updates of Sandstorm or Snap turned off.
|
||||
Old versions have security issues because of old versions Node.js etc. Only newest WeKan ® is supported.
|
||||
WeKan ® on Sandstorm is not usually affected by any Standalone WeKan ® (Snap/Docker/Source) security issues.
|
||||
- 64bit: Linux [Snap](https://github.com/wekan/wekan-snap/wiki/Install) or [Sandstorm](https://sandstorm.io) /
|
||||
[Mac](https://github.com/wekan/wekan/wiki/Mac) / [Windows](https://github.com/wekan/wekan/wiki/Install-Wekan-from-source-on-Windows).
|
||||
[More Platforms](https://github.com/wekan/wekan/wiki/Platforms). [ARM progress](https://github.com/wekan/wekan/issues/1053#issuecomment-410919264).
|
||||
- 1 GB RAM minimum free for Wekan. Production server should have minimum total 4 GB RAM.
|
||||
For thousands of users, for example with [Docker](https://github.com/wekan/wekan/blob/devel/docker-compose.yml): 3 frontend servers,
|
||||
each having 2 CPU and 2 wekan-app containers. One backend wekan-db server with many CPUs.
|
||||
- Enough disk space and alerts about low disk space. If you run out disk space, MongoDB database gets corrupted.
|
||||
- SECURITY: Updating to newest Wekan version very often. Please check you do not have automatic updates of Sandstorm or Snap turned off.
|
||||
Old versions have security issues because of old versions Node.js etc. Only newest Wekan is supported.
|
||||
Wekan on Sandstorm is not usually affected by any Standalone Wekan (Snap/Docker/Source) security issues.
|
||||
- [Reporting all new bugs immediately](https://github.com/wekan/wekan/issues).
|
||||
New features and fixes are added to WeKan ® [many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- [Backups](https://github.com/wekan/wekan/blob/main/docs/Backup/Backup.md) of WeKan ® database once a day minimum.
|
||||
New features and fixes are added to Wekan [many times a day](https://github.com/wekan/wekan/blob/devel/CHANGELOG.md).
|
||||
- [Backups](https://github.com/wekan/wekan/wiki/Backup) of Wekan database once a day miminum.
|
||||
Bugs, updates, users deleting list or card, harddrive full, harddrive crash etc can eat your data. There is no undo yet.
|
||||
Some bugs can cause WeKan ® board to not load at all, requiring manual fixing of database content.
|
||||
Some bug can cause Wekan board to not load at all, requiring manual fixing of database content.
|
||||
|
||||
## Roadmap and Demo
|
||||
## Roadmap
|
||||
|
||||
[Roadmap](https://boards.wekan.team/b/D2SzJKZDS4Z48yeQH/wekan-r-open-source-kanban-board-with-mit-license) - Public read-only board at WeKan ® demo.
|
||||
[Roadmap Milestones](https://github.com/wekan/wekan/milestones)
|
||||
|
||||
[Developer Documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md)
|
||||
[Developer Documentation][dev_docs]
|
||||
|
||||
- There are many companies and individuals contributing code to WeKan ®, to add features and bugfixes
|
||||
[many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- There is many companies and individuals contributing code to Wekan, to add features and bugfixes
|
||||
[many times a day](https://github.com/wekan/wekan/blob/devel/CHANGELOG.md).
|
||||
- [Please add Add new Feature Requests and Bug Reports immediately](https://github.com/wekan/wekan/issues).
|
||||
- [Commercial Support](https://wekan.fi/commercial-support/).
|
||||
- [Commercial Support](https://wekan.team).
|
||||
- [Bounties](https://wekan.team/bounties/index.html).
|
||||
|
||||
We also welcome sponsors for features and bugfixes.
|
||||
By working directly with WeKan ® you get the benefit of active maintenance and new features added by growing WeKan ® developer community.
|
||||
By working directly with Wekan you get the benefit of active maintenance and new features added by growing Wekan developer community.
|
||||
|
||||
## Getting Started with Development
|
||||
## Demo
|
||||
|
||||
The main branch uses Meteor 3.5 with Node.js 24.x.
|
||||
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
|
||||
[Wekan demo][roadmap_wefork]
|
||||
|
||||
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./build.sh` (or `./build.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-install-dependencies-build-wekan-and-run-the-dev-server). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
|
||||
## Screenshot
|
||||
|
||||
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
|
||||
[More screenshots at Features page](https://github.com/wekan/wekan/wiki/Features)
|
||||
|
||||
## First-Time Setup for Development
|
||||
[![Screenshot of Wekan][screenshot_wefork]][roadmap_wefork]
|
||||
|
||||
### Prerequisites
|
||||
## Stable
|
||||
|
||||
Before building WeKan from source, ensure you have:
|
||||
- master+devel branch. At release, devel is merged to master.
|
||||
- Receives fixes and features that have been tested at edge that they work.
|
||||
- If you want automatic updates, [use Snap](https://github.com/wekan/wekan-snap/wiki/Install).
|
||||
- If you want to test before update, [use Docker quay.io release tags](https://github.com/wekan/wekan/wiki/Docker).
|
||||
|
||||
- **Git** - for cloning the repository
|
||||
- **Node.js 24.x** - WeKan requires Node.js 24.x
|
||||
- **Meteor** - the JavaScript framework WeKan is built with
|
||||
## Edge
|
||||
|
||||
### Building WeKan
|
||||
|
||||
The `build.sh` script shows a two-level menu. The top level groups options into categories:
|
||||
|
||||
```
|
||||
1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit
|
||||
```
|
||||
|
||||
You pick a category number, then the item number inside it (each submenu also has a `Back` entry). Building from source is a three-stage process:
|
||||
|
||||
1. **Setup -> Install dependencies** - Downloads all required Meteor packages and npm modules
|
||||
2. **Setup -> Build WeKan** - Compiles the application
|
||||
3. **Dev server -> localhost:3000** - Starts the development server at http://localhost:3000
|
||||
|
||||
```bash
|
||||
# Clone your fork
|
||||
git clone git@github.com:YOUR_USERNAME/wekan.git
|
||||
cd wekan
|
||||
|
||||
# Make the script executable
|
||||
chmod +x build.sh
|
||||
|
||||
# Step 1: Install dependencies (Setup -> Install dependencies)
|
||||
./build.sh
|
||||
# Press 1 (Setup) and Enter, then 1 (Install dependencies) and Enter
|
||||
|
||||
# Step 2: Build WeKan (Setup -> Build WeKan, after dependencies complete)
|
||||
./build.sh
|
||||
# Press 1 (Setup) and Enter, then 2 (Build WeKan) and Enter
|
||||
|
||||
# Step 3: Run WeKan in development mode (Dev server -> localhost:3000)
|
||||
./build.sh
|
||||
# Press 2 (Dev server) and Enter, then 1 (localhost:3000) and Enter
|
||||
```
|
||||
|
||||
If a dev server is already running on that port, the **Dev server** options stop it automatically and start a fresh server on the same port.
|
||||
|
||||
### WSL Users
|
||||
|
||||
WSL users can use Snap Candidate. See [install docs](https://wekan.fi/install/).
|
||||
- edge branch. All new fixes and features are added to here first. [Testing Edge](https://github.com/wekan/wekan-snap/wiki/Snap-Developer-Docs).
|
||||
|
||||
## License
|
||||
|
||||
WeKan ® is released under the very permissive [MIT license](https://github.com/wekan/wekan/blob/main/LICENSE), and made
|
||||
Wekan is released under the very permissive [MIT license](LICENSE), and made
|
||||
with [Meteor](https://www.meteor.com).
|
||||
|
||||
[platforms]: https://github.com/wekan/wekan/wiki/Platforms
|
||||
[dev_docs]: https://github.com/wekan/wekan/wiki/Developer-Documentation
|
||||
[screenshot_wekan]: http://i.imgur.com/cI4jW2h.png
|
||||
[screenshot_wefork]: https://wekan.github.io/wekan-markdown.png
|
||||
[features]: https://github.com/wekan/wekan/wiki/Features
|
||||
[integrations]: https://github.com/wekan/wekan/wiki/Integrations
|
||||
[roadmap_wekan]: http://try.wekan.io/b/MeSsFJaSqeuo9M6bs/wekan-roadmap
|
||||
[roadmap_wefork]: https://wekan.indie.host/b/t2YaGmyXgNkppcFBq/wekan-fork-roadmap
|
||||
[wekan_issues]: https://github.com/wekan/wekan/issues
|
||||
[wefork_issues]: https://github.com/wefork/wekan/issues
|
||||
[docker_image]: https://hub.docker.com/r/wekanteam/wekan/
|
||||
[travis_badge]: https://travis-ci.org/wekan/wekan.svg?branch=devel
|
||||
[travis_status]: https://travis-ci.org/wekan/wekan
|
||||
[wekan_wiki]: https://github.com/wekan/wekan/wiki
|
||||
[translate_wekan]: https://www.transifex.com/wekan/wekan/
|
||||
[open_source]: https://en.wikipedia.org/wiki/Open-source_software
|
||||
[free_software]: https://en.wikipedia.org/wiki/Free_software
|
||||
[vanila_badge]: https://vanila.io/img/join-chat-button2.png
|
||||
[wekan_chat]: https://community.vanila.io/wekan
|
||||
|
||||
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2Fwekan%2Fwekan?ref=badge_large)
|
||||
@@ -1,113 +0,0 @@
|
||||
# 🔐 WeKan — Login System Overview
|
||||
|
||||
This document provides a detailed overview of WeKan’s **login and authentication system**, covering client-side UI, server-side logic, external authentication methods, and potential upgrade paths.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Login Web UI
|
||||
|
||||
WeKan's login interface is implemented using a combination of:
|
||||
|
||||
- `layouts.jade` – Login HTML structure
|
||||
- `layouts.js` – Login logic and interactivity
|
||||
- `layouts.css` – Styling and layout
|
||||
|
||||
📁 Source: [`client/components/main`](https://github.com/wekan/wekan/tree/main/client/components/main)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Server-Side Authentication
|
||||
|
||||
Server-side login functionality is handled in:
|
||||
|
||||
- [`server/authentication.js`](https://github.com/wekan/wekan/blob/main/server/authentication.js)
|
||||
|
||||
Other related configurations:
|
||||
|
||||
- 🔧 Account config: [`config/accounts.js`](https://github.com/wekan/wekan/blob/main/config/accounts.js)
|
||||
- 📨 Sign-up invitations: [`models/settings.js#L275`](https://github.com/wekan/wekan/blob/main/models/settings.js#L275)
|
||||
- 👤 User creation logic: [`models/users.js#L1339`](https://github.com/wekan/wekan/blob/main/models/users.js#L1339)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Meteor User Accounts
|
||||
|
||||
WeKan utilizes Meteor’s `accounts` system. Relevant resources:
|
||||
|
||||
- 📚 Meteor 2.x Accounts Docs: [v2-docs.meteor.com/api/accounts](https://v2-docs.meteor.com/api/accounts)
|
||||
- 🔍 Meteor Packages:
|
||||
- [`packages`](https://github.com/wekan/wekan/blob/main/.meteor/packages)
|
||||
- [`versions`](https://github.com/wekan/wekan/blob/main/.meteor/versions)
|
||||
- 📦 Meteor 2.14 core packages: [Meteor 2.14 packages](https://github.com/meteor/meteor/tree/release/METEOR%402.14/packages)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 External Authentication (OIDC, LDAP, etc.)
|
||||
|
||||
WeKan supports external authentication methods via internal packages.
|
||||
|
||||
📁 See [`packages/`](https://github.com/wekan/wekan/tree/main/packages) for:
|
||||
- OpenID Connect (OIDC)
|
||||
- LDAP
|
||||
- OAuth and other integrations
|
||||
|
||||
---
|
||||
|
||||
## 📦 NPM & AtmosphereJS Dependencies
|
||||
|
||||
- 🔗 `package.json`: [Dependencies list](https://github.com/wekan/wekan/blob/main/package.json)
|
||||
- 🧩 WekanTeam scoped NPM packages: [@wekanteam on npm](https://www.npmjs.com/search?q=%40wekanteam)
|
||||
- ☁️ AtmosphereJS Meteor packages: [atmospherejs.com](https://atmospherejs.com)
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Meteor Version & Upgrade Notes
|
||||
|
||||
- 📌 Current Version: **Meteor 2.14**
|
||||
- [`.meteor/release`](https://github.com/wekan/wekan/blob/main/.meteor/release)
|
||||
- 🔧 Maintained with only **critical fixes** until ~Summer 2025
|
||||
- 🚀 Migration to **Meteor 3** or a new framework is under consideration
|
||||
|
||||
📘 Meteor 3 API: [docs.meteor.com/api/accounts](https://docs.meteor.com/api/accounts)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Prototypes & Examples
|
||||
|
||||
### 🐘 PHP Prototype Sign-Up
|
||||
|
||||
Used in experimental versions:
|
||||
|
||||
- Step 1: [`sign-up1.php`](https://github.com/wekan/php/blob/main/page/sign-up1.php)
|
||||
- Step 2: [`sign-up2.php`](https://github.com/wekan/php/blob/main/page/sign-up2.php)
|
||||
- Main entry: [`index.php#L72-L83`](https://github.com/wekan/php/blob/main/public/index.php#L72-L83)
|
||||
|
||||
---
|
||||
|
||||
### 🎨 WeKan Studio Prototype
|
||||
|
||||
Sign-up logic in the **WeKan Studio** version:
|
||||
|
||||
- [`signUp.fmt`](https://github.com/wekan/wekanstudio/blob/main/srv/templates/login/signUp.fmt)
|
||||
|
||||
---
|
||||
|
||||
## 📎 Future Considerations
|
||||
|
||||
- Upgrading to **Meteor 3.x**
|
||||
- Refactoring frontend logic to fix translation rendering order
|
||||
- Exploring **simplified authentication systems** in future prototypes
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Project Links
|
||||
|
||||
- 🔧 Main Repo: [github.com/wekan/wekan](https://github.com/wekan/wekan)
|
||||
- 🌐 Website: [wekan.github.io](https://wekan.github.io)
|
||||
- 📚 Documentation: [Wekan Wiki](https://github.com/wekan/wekan/wiki)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
+83
-250
@@ -1,279 +1,95 @@
|
||||
## About Money: No bounty
|
||||
Security is very important to us. If you discover any issue regarding security, please disclose
|
||||
the information responsibly by sending an email to security (at) wekan.team and not by
|
||||
creating a GitHub issue. We will respond swiftly to fix verifiable security issues.
|
||||
|
||||
There is no bounty, like described at [CONTRIBUTING.md](CONTRIBUTING.md),
|
||||
because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
We thank you with a place at our hall of fame page, that is
|
||||
at https://wekan.github.io/hall-of-fame . Others have just posted public GitHub issue,
|
||||
so they are not at that hall-of-fame page.
|
||||
|
||||
## Steps for Coordinated Vulnerability Disclosure
|
||||
## How should reports be formatted?
|
||||
|
||||
### 1. Security Researcher:
|
||||
|
||||
- **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.
|
||||
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
|
||||
- Email is NOT secure or private.
|
||||
- Using GitHub directly verifies that:
|
||||
- You are:
|
||||
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
|
||||
- A correct GitHub user
|
||||
- Really reading this SECURITY.md
|
||||
- Knowing what you are doing
|
||||
- You are not:
|
||||
- Someone that tries to waste my time
|
||||
- Trying to impersonate as some other GitHub user
|
||||
- Spammer
|
||||
- Bot
|
||||
|
||||
### 2. WeKan Security Team:
|
||||
|
||||
- **Remediation**: Please wait for a new WeKan release that addresses the issue.
|
||||
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 requests CVE at GitHub when releasing Security Advisory.
|
||||
|
||||
### 3. Post-Release and Public Disclosure:
|
||||
|
||||
- Advisories reported at GitHub are listed at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
|
||||
- Email is NOT secure or private.
|
||||
- Using GitHub directly verifies that:
|
||||
- You are:
|
||||
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
|
||||
- A correct GitHub user
|
||||
- Really reading this SECURITY.md
|
||||
- Knowing what you are doing
|
||||
- You are not:
|
||||
- Someone that tries to waste my time
|
||||
- Trying to impersonate as some other GitHub user
|
||||
- Spammer
|
||||
- Bot
|
||||
```
|
||||
Name: %name
|
||||
Twitter: %twitter
|
||||
Bug type: %bugtype
|
||||
Domain: %domain
|
||||
Severity: %severity
|
||||
URL: %url
|
||||
PoC: %poc
|
||||
CVSS (optional): %cvss
|
||||
CWSS (optional): %cwss
|
||||
```
|
||||
|
||||
## Who can participate in the program
|
||||
|
||||
Anyone who reports a unique security issue in scope and does not disclose it to
|
||||
a third party before we have patched, and is able to add
|
||||
"New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
a third party before we have patched and updated may be upon their approval
|
||||
added to the Wekan Hall of Fame.
|
||||
|
||||
## Which domains are in scope?
|
||||
|
||||
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.
|
||||
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.
|
||||
Please don't perform research that could impact other users. Secondly, please keep
|
||||
the reports short and succinct. If we fail to understand the logics of your bug, we will tell you.
|
||||
|
||||
You can [Install WeKan](https://wekan.fi/install/) on your own computer
|
||||
You can [Install Wekan](https://github.com/wekan/wekan/releases) to 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 only 2 versions of Wekan: Standalone Wekan, and Sandstorm Wekan.
|
||||
|
||||
## Meteor Security
|
||||
### Standalone Wekan Security
|
||||
|
||||
https://guide.meteor.com/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.
|
||||
WeKan is used in most countries of the world https://snapcraft.io/wekan
|
||||
and by companies that have 30k users.
|
||||
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 by companies that have [thousands of users](https://github.com/wekan/wekan/wiki/AWS) and at healthcare.
|
||||
|
||||
- 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.
|
||||
Wekan uses xss package for input fields like cards, as you can see from
|
||||
[package.json](https://github.com/wekan/wekan/blob/devel/package.json). Other used versions can be seen from
|
||||
[Meteor versions file](https://github.com/wekan/wekan/blob/devel/.meteor/versions).
|
||||
Forms can include markdown links, html, image tags etc like you see at https://wekan.github.io .
|
||||
It's possible to add attachments to cards, and markdown/html links to files.
|
||||
|
||||
## SSL/TLS
|
||||
Wekan attachments are not accessible without logging in. Import from Trello works by copying
|
||||
Trello export JSON to Wekan Trello import page, and in Trello JSON file there is direct links to all publicly
|
||||
accessible Trello attachment files, that Standalone Wekan downloads directly to Wekan MongoDB database in
|
||||
[CollectionFS](https://github.com/wekan/wekan/pull/875) format. When Wekan board is exported in
|
||||
Wekan JSON format, all board attachments are included in Wekan JSON file as base64 encoded text.
|
||||
That Wekan JSON format file can be imported to Sandstorm Wekan with all the attachments, when we get
|
||||
latest Wekan version working on Sandstorm, only couple of bugs are left before that. In Sandstorm it's not
|
||||
possible yet to import from Trello with attachments, because Wekan does not implement Sandstorm-compatible
|
||||
access to outside of Wekan grain.
|
||||
|
||||
- SSL/TLS encrypts traffic between webbrowser and webserver.
|
||||
- If you are thinking about TLS MITM, look at https://github.com/caddyserver/caddy/issues/2530
|
||||
- Let's Encrypt TLS requires publicly accessible webserver, that Let's Encrypt TLS validation servers check.
|
||||
- If firewall limits to only allowed IP addresses, you may need non-Let's Encrypt TLS cert.
|
||||
Standalone Wekan only has password auth currently, there is work in progress to add
|
||||
[oauth2](https://github.com/wekan/wekan/pull/1578), [Openid](https://github.com/wekan/wekan/issues/538),
|
||||
[LDAP](https://github.com/wekan/wekan/issues/119) etc. If you need more login security for Standalone Wekan now,
|
||||
it's possible add additional [Google Auth proxybouncer](https://github.com/wekan/wekan/wiki/Let's-Encrypt-and-Google-Auth) in front of password auth, and then use Google Authenticator for Google Auth. Standalone Wekan does have [brute force protection with eluck:accounts-lockout and browser-policy clickjacking protection](https://github.com/wekan/wekan/blob/devel/CHANGELOG.md#v080-2018-04-04-wekan-release). You can also optionally use some [WAF](https://en.wikipedia.org/wiki/Web_application_firewall)
|
||||
like for example [AWS WAF](https://aws.amazon.com/waf/).
|
||||
|
||||
## XSS
|
||||
[All Wekan Platforms](https://github.com/wekan/wekan/wiki/Platforms)
|
||||
|
||||
- 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.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.fi/hall-of-fame/fieldbleed/
|
||||
- Reaction in comment is now checked, that it does not have extra added code
|
||||
- https://wekan.fi/hall-of-fame/reactionbleed/
|
||||
- https://github.com/wekan/wekan/blob/main/packages/markdown/src/template-integration.js#L76
|
||||
### Sandstorm Wekan Security
|
||||
|
||||
## QA About PubSub
|
||||
On Sandstorm platform using environment variable Standalone Wekan features like Admin Panel etc are
|
||||
turned off, because Sandstorm platform provides SSO for all apps running on Sandstorm.
|
||||
|
||||
Q:
|
||||
|
||||
Hello,
|
||||
I have just seen the Meteor DevTools Evolved extension and was wondering if anyone
|
||||
had asked themselves the question of security.
|
||||
Insofar as all data is shown in the minimongo tab in plain text.
|
||||
How can data be hidden from this extension?
|
||||
|
||||
A:
|
||||
|
||||
## PubSub
|
||||
|
||||
- It is not a security issue to show text or images that the user has permission to see.
|
||||
It is a security issue, if browserside is some text or image that user should not see.
|
||||
- Meteor has browserside minimongo database, made with JavaScript, updated with Publish/Subscribe, PubSub.
|
||||
- Publish/Subscribe means, that realtime web framework reads database changes stream,
|
||||
and then immediately updates webpage, like dashboards, chat, kanban.
|
||||
That is the point of any realtime web framework in any programming language.
|
||||
- Yes, you should check with Meteor DevTools Evolved Chromium/Firefox extension that
|
||||
at minimongo is only text that user has permission to see.
|
||||
- Check as a logged-in user and as a logged-out user.
|
||||
- Check permissions and sanitize before allowing some change, because someone could modify content of input field,
|
||||
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.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
|
||||
PubSub https://github.com/wekan/wekan/tree/main/server/publications or calls meteor methods
|
||||
at https://github.com/wekan/wekan/tree/main/models
|
||||
- For Admin:
|
||||
- You can have input field for password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- You can save password to database https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- Check that only current user or Admin can change password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- Note that currentUser uses code like Meteor.user() in .js file
|
||||
- Do not include password hashes in PubSub https://github.com/wekan/wekan/blob/main/server/publications/users.js
|
||||
- Only show Admin Panel to Admin https://github.com/wekan/wekan/blob/main/client/components/settings/settingBody.jade#L3
|
||||
- If there is a large amount of data, use pagination https://github.com/wekan/wekan/blob/main/client/components/settings/peopleBody.js
|
||||
- Only publish a limited amount of data in PubSub. Limit in MongoDB query in publications how much is published. Too much could make browser too slow.
|
||||
- Environment variables for email and other passwords.
|
||||
- But what if you would like to remove Minimongo and only use Meteor methods for saving? In that case, you don't have realtime updates,
|
||||
and you need to write much more code to load and save data yourself, handle any multi user data saving conflicts yourself,
|
||||
and many Meteor Atmospherejs.com PubSub using packages would not work anymore https://github.com/wekan/we
|
||||
|
||||
## PubSub: Fix that user can not change to Admin
|
||||
|
||||
- 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.fi/hall-of-fame/adminbleed/
|
||||
|
||||
## Permissions and Roles
|
||||
|
||||
- For any user permissions, it's best to use Meteor package https://github.com/Meteor-Community-Packages/meteor-roles .
|
||||
- Currently, WeKan has custom hardcoded permissions and does not yet use the meteor-roles package.
|
||||
- Using permissions at WeKan sidebar https://github.com/wekan/wekan/blob/main/client/components/sidebar/sidebar.js#L1854-L1875
|
||||
- List of roles: https://github.com/wekan/wekan/wiki/REST-API-Role . Change at board or Admin Panel. Also Organizations/Teams.
|
||||
- Worker role: https://github.com/wekan/wekan/issues/2788
|
||||
- Not implemented yet: Granular Roles https://github.com/wekan/wekan/issues/3022
|
||||
- Check is user logged in, with `if (Meteor.user()) {`
|
||||
- Check is code running at server `if (Meteor.isServer()) {` or client `if Meteor.isClient()) {` .
|
||||
- Here is some authentication code https://github.com/wekan/wekan/blob/main/server/authentication.js
|
||||
|
||||
## Environment variables
|
||||
|
||||
- For any passwords, use environment variables; those are serverside
|
||||
- Do not copy environment variables to public variables that are visible on the browserside https://github.com/wekan/wekan/blob/main/server/max-size.js
|
||||
|
||||
```
|
||||
Meteor.startup(() => {
|
||||
if (process.env.HEADER_LOGIN_ID) {
|
||||
Meteor.settings.public.attachmentsUploadMaxSize = process.env.ATTACHMENTS_UPLOAD_MAX_SIZE;
|
||||
Meteor.settings.public.attachmentsUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES;
|
||||
Meteor.settings.public.avatarsUploadMaxSize = process.env.AVATARS_UPLOAD_MAX_SIZE;
|
||||
```
|
||||
|
||||
- For serverside, you can set Meteor.settings.variablename, without text public
|
||||
- For WeKan kanban, there is a feature for setting a board to public; it can be viewed by anyone, with realtime updates. But
|
||||
- Some of those permissions are checked at users.js models at https://github.com/wekan/wekan/tree/main/models
|
||||
- Environment variables are used for email server passwords, etc, at all platforms https://github.com/wekan/wekan/commit/a781c0e7dcfdbe34c1483ee83cec12455b7026f7
|
||||
|
||||
## Escape HTML comment tags so that HTML comments are visible
|
||||
|
||||
- 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.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.fi/hall-of-fame/filebleed/
|
||||
|
||||
### Attachments: Forced download to prevent stored XSS
|
||||
|
||||
- To prevent browser-side execution of uploaded content under the app origin, all attachment downloads are served with safe headers:
|
||||
- `Content-Type: application/octet-stream`
|
||||
- `Content-Disposition: attachment`
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- A restrictive `Content-Security-Policy` with `sandbox`
|
||||
- This means attachments are downloaded instead of rendered inline by default. This mitigates HTML/JS/SVG based stored XSS vectors.
|
||||
- Avatars and inline images remain supported but SVG uploads are blocked and never rendered inline.
|
||||
|
||||
## Users: Client update restrictions
|
||||
|
||||
- Client-side updates to user documents are limited to safe fields only:
|
||||
- `username`
|
||||
- `profile.*`
|
||||
- Sensitive fields are blocked from any client updates and can only be modified by server methods with authorization:
|
||||
- `orgs`, `teams`, `roles`, `isAdmin`, `createdThroughApi`, `loginDisabled`, `authenticationMethod`, `services.*`, `emails.*`, `sessionData.*`
|
||||
- Attempts to update forbidden fields from the client are denied.
|
||||
- Admin operations like managing org/team membership or toggling flags must use server methods that check permissions.
|
||||
|
||||
## Voting: integrity and authorization
|
||||
|
||||
- Client updates to card `vote` fields are blocked to prevent forged votes and inconsistent policy enforcement.
|
||||
- Voting is performed via a server method that enforces:
|
||||
- Authentication and board membership, or an explicit per-card flag allowing non-members to vote.
|
||||
- Only the caller's own userId is added/removed from `vote.positive`/`vote.negative`.
|
||||
- This prevents members from fabricating other users' votes and ensures non-members cannot vote unless explicitly allowed.
|
||||
|
||||
## Planning Poker: integrity and authorization
|
||||
|
||||
- Client updates to card `poker` fields are blocked. All poker actions go through server methods that enforce:
|
||||
- Authentication and board membership for configuration and results.
|
||||
- For casting a poker vote, either board membership or an explicit per-card flag allowing non-members to participate.
|
||||
- Only the caller's own userId is added/removed from the selected estimation bucket (e.g., one, two, five, etc.).
|
||||
- Methods cover setting/unsetting poker question/end, casting votes, replaying, and setting final estimation.
|
||||
|
||||
## Attachment API: authentication and DoS prevention
|
||||
|
||||
- The attachment API (`/api/attachment/*`) requires proper authentication using `X-User-Id` and `X-Auth-Token` headers.
|
||||
- Authentication validates tokens by hashing with `Accounts._hashLoginToken` and matching against stored login tokens, preventing identity spoofing.
|
||||
- Request handlers implement:
|
||||
- 30-second timeout to prevent hanging connections.
|
||||
- Request body size limits (50MB for uploads, 10MB for metadata operations).
|
||||
- Proper error handling and guaranteed response completion.
|
||||
- Request error event handlers to clean up failed connections.
|
||||
- This prevents:
|
||||
- DoS attacks via concurrent unauthenticated or malformed requests.
|
||||
- Identity spoofing by using arbitrary bearer tokens or user IDs.
|
||||
- Resource exhaustion from hanging connections or excessive payloads.
|
||||
- Access control: all attachment operations verify board membership before allowing access.
|
||||
|
||||
## Brute force login protection
|
||||
|
||||
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d
|
||||
- https://github.com/wekan/wekan/tree/main/packages/wekan-accounts-lockout
|
||||
|
||||
### 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.
|
||||
|
||||
[Sandstorm](https://sandstorm.io) is a separate Open Source platform that has been
|
||||
[Sandstorm](https://sandstorm.io) is separate Open Source platform that has been
|
||||
[security audited](https://sandstorm.io/news/2017-03-02-security-review) and found bugs fixed.
|
||||
Sandstorm also has passwordless login, LDAP, SAML, Google, and other auth options already.
|
||||
On Sandstorm, code is read-only and signed by app maintainers; only grain content can be modified.
|
||||
WeKan on Sandstorm runs in a sandboxed grain; it does not have access elsewhere without a user-visible PowerBox request or opening a randomly-generated API key URL.
|
||||
Sandstorm also has passwordless login, LDAP, SAML, Google etc auth options already.
|
||||
At Sandstorm code is read-only and signed by app maintainers, only grain content can be modified.
|
||||
Wekan at Sandstorm runs in sandboxed grain, it does not have access elsewhere without user-visible
|
||||
PowerBox request or opening randomly-generated API key URL.
|
||||
Also read [Sandstorm Security Practices](https://docs.sandstorm.io/en/latest/using/security-practices/) and
|
||||
[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.
|
||||
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:
|
||||
@@ -285,12 +101,29 @@ 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:
|
||||
Typical already known or "no impact" bugs such as:
|
||||
|
||||
- Brute force password guessign. Currently there is
|
||||
[brute force protection with eluck:accounts-lockout](https://github.com/wekan/wekan/blob/devel/CHANGELOG.md#v080-2018-04-04-wekan-release).
|
||||
- Security issues related to that Wekan uses Meteor 1.6.0.1 related packages, and upgrading to newer
|
||||
Meteor 1.6.1 is complicated process that requires lots of changes to many dependency packages.
|
||||
Upgrading [has been tried many times, spending a lot of time](https://github.com/meteor/meteor/issues/9609)
|
||||
but there still is issues. Helping with package upgrades is very welcome.
|
||||
- [Wekan API old tokens not replaced correctly](https://github.com/wekan/wekan/issues/1437)
|
||||
- Missing Cookie flags on non-session cookies or 3rd party cookies
|
||||
- Logout CSRF
|
||||
- Social engineering
|
||||
- Denial of service
|
||||
- SSL BEAST/CRIME/etc. WeKan does not have SSL built-in; it uses Caddy/Nginx/Apache on the front end.
|
||||
- SSL BEAST/CRIME/etc. Wekan does not have SSL built-in, it uses Caddy/Nginx/Apache etc at front.
|
||||
Integrated Caddy support is updated often.
|
||||
- Email spoofing, SPF, DMARC & DKIM. Wekan does not include email server.
|
||||
|
||||
WeKan is Open Source with MIT license, and free to use also for commercial use.
|
||||
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 (at) wekan.team .
|
||||
|
||||
## Bonus Points
|
||||
|
||||
If your Responsible Security Disclosure includes code for fixing security issue,
|
||||
you get bonus points, as seen on [Hall of Fame](https://wekan.github.io/hall-of-fame).
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v11.65.0"
|
||||
appVersion: "v2.97.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// Fix dynamic-import chunk loading when Wekan runs under a sub-path (ROOT_URL includes a pathname,
|
||||
// e.g. https://example.com/wekan). Rspack's runtime builds every chunk URL as
|
||||
// __webpack_public_path__ + chunkName, and the chunk name ALREADY includes the "build-chunks/"
|
||||
// prefix (its runtime does `u = e => "build-chunks/" + e + "." + hash + ".js"`). The default
|
||||
// public path is "/", so at the site root chunks correctly resolve to "/build-chunks/<chunk>".
|
||||
// Under a sub-path we must prefix that sub-path — but must NOT re-add "build-chunks/" ourselves,
|
||||
// or the URL becomes "/wekan/build-chunks/build-chunks/<chunk>" and 404s (issue #6445, which broke
|
||||
// the lazy-loaded language chunks). So set the public path to "<sub-path>/" and let rspack append
|
||||
// "build-chunks/" itself. Done here at module evaluation time, before any import() expression runs
|
||||
// (those are deferred to Meteor.startup callbacks).
|
||||
/* global __webpack_public_path__:writable */
|
||||
try {
|
||||
const _cfg = typeof window !== 'undefined' && window.__meteor_runtime_config__;
|
||||
const _rootUrl = _cfg && _cfg.ROOT_URL;
|
||||
if (_rootUrl) {
|
||||
const _rootPath = new URL(_rootUrl).pathname.replace(/\/+$/, '');
|
||||
if (_rootPath && _rootPath !== '/') {
|
||||
__webpack_public_path__ = _rootPath + '/';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Expose Meteor on window. Under the Meteor 3.5 + rspack build, bare `Meteor`
|
||||
// references in app code are rewritten by rspack's ProvidePlugin into per-module
|
||||
// imports, so `Meteor` is NOT placed on the browser `window` the way the classic
|
||||
// Meteor linker did. Re-expose it so the browser console and the Playwright e2e
|
||||
// tests (which call Meteor.loginWithToken / Meteor.userId / Meteor.subscribe etc.
|
||||
// via page.evaluate in window scope) can reach it. This matches the long-standing
|
||||
// classic-Meteor behaviour where `Meteor` is a global.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.Meteor = Meteor;
|
||||
}
|
||||
|
||||
// Fix bug in jam:offline 0.4.1: s?.message.includes() crashes when s.message is
|
||||
// undefined (e.g. during WebSocket reconnect errors that don't carry a .message).
|
||||
// Wrap _debug so the optional chain is complete: s?.message?.includes().
|
||||
(function patchJamOfflineDebug() {
|
||||
const patched = Meteor._debug;
|
||||
Meteor._debug = function (m, s) {
|
||||
try {
|
||||
return patched.call(this, m, s);
|
||||
} catch (e) {
|
||||
// jam:offline's override threw — fall back to the bare console log.
|
||||
if (typeof console !== 'undefined') {
|
||||
console.log(m, s);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// PWA — use Meteor.absoluteUrl so the path is correct under sub-URL deployments
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
|
||||
navigator.serviceWorker.register(swPath);
|
||||
});
|
||||
}
|
||||
|
||||
// Import Blaze helpers (replaces removed raix:handlebar-helpers package)
|
||||
import '/client/config/blazeHelpers';
|
||||
|
||||
// Import board converter for on-demand conversion
|
||||
import '/client/lib/boardConverter';
|
||||
import '/client/features/boardConversion';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// Import migration manager and progress UI - COMMENTED OUT
|
||||
// import '/client/lib/attachmentMigrationManager';
|
||||
// import '/client/components/settings/migrationProgress';
|
||||
|
||||
// Custom head tags
|
||||
|
||||
// Accounts may already have resumed a legacy localStorage session before the
|
||||
// application module loads. Move its credentials to memory without a second
|
||||
// login: that login rebuilds subscriptions and can discard the current profile
|
||||
// (#6677). Keep the poller's token in sync and let the native endpoint validate
|
||||
// the token when creating the HttpOnly cookie. An in-flight initial login still
|
||||
// finishes through Accounts' normal callback.
|
||||
const legacyResumeToken = Accounts._storedLoginToken();
|
||||
const legacyUserId = Accounts._storedUserId();
|
||||
const legacyTokenExpires = Accounts._storedLoginTokenExpires();
|
||||
// Remove the old persistent copy; subsequent Accounts writes go to memory.
|
||||
if (legacyResumeToken) Accounts._unstoreLoginToken();
|
||||
Accounts.config({ clientStorage: 'none', useHttpOnlyCookies: true });
|
||||
if (legacyResumeToken) {
|
||||
Accounts._storeLoginToken(legacyUserId, legacyResumeToken, legacyTokenExpires);
|
||||
Accounts._setHttpOnlyCookie(legacyResumeToken, legacyTokenExpires);
|
||||
} else {
|
||||
Accounts.loginWithCookie();
|
||||
}
|
||||
|
||||
// Subscribe to per-user small publications
|
||||
Meteor.startup(() => {
|
||||
Tracker.autorun(() => {
|
||||
if (Meteor.userId()) {
|
||||
Meteor.subscribe('userDesktopDragHandles');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize mobile mode on startup for iOS devices
|
||||
// This ensures mobile mode is applied correctly on page load
|
||||
Tracker.afterFlush(() => {
|
||||
if (typeof Utils !== 'undefined' && Utils.initializeUserSettings) {
|
||||
Utils.initializeUserSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/* The board sidebar's Activities heading. It is the section's own control now -
|
||||
a caret, the icon and the name, clicked to show the activities under it - so
|
||||
it is a row of three things beside each other rather than a heading with a
|
||||
toggle pushed to the far end. client/components/sidebar/sidebar.jade */
|
||||
.activity-title {
|
||||
margin: 0 10px 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.reactions-popup .add-comment-reaction {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
width: 70px;
|
||||
}
|
||||
.reactions-popup .add-comment-reaction:hover {
|
||||
background-color: #b0c4de;
|
||||
}
|
||||
.activities {
|
||||
clear: both;
|
||||
}
|
||||
.activities .activity {
|
||||
margin: 1px 0;
|
||||
padding: 7px 0;
|
||||
display: flex;
|
||||
}
|
||||
.activities .activity .member {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.activities .activity .activity-member {
|
||||
font-weight: 700;
|
||||
}
|
||||
.activities .activity .activity-desc {
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
margin-inline-start: 6px;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.activities .activity .activity-desc .activity-comment {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 3px rgba(0,0,0,0.2);
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.activities .activity .activity-desc .activity-checklist {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
margin-top: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
.activities .activity .activity-desc .activity-meta {
|
||||
font-size: 0.8em;
|
||||
color: #999;
|
||||
}
|
||||
@@ -1,209 +1,223 @@
|
||||
template(name="activities")
|
||||
if showActivities
|
||||
.activities.js-sidebar-activities
|
||||
//- We should use Template.dynamic here but there is a bug with
|
||||
//- blaze-components: https://github.com/peerlibrary/meteor-blaze-components/issues/30
|
||||
if $eq mode "board"
|
||||
+boardActivities
|
||||
else
|
||||
+cardActivities
|
||||
.activities.js-sidebar-activities
|
||||
//- We should use Template.dynamic here but there is a bug with
|
||||
//- blaze-components: https://github.com/peerlibrary/meteor-blaze-components/issues/30
|
||||
if $eq mode "board"
|
||||
+boardActivities
|
||||
else
|
||||
+cardActivities
|
||||
|
||||
template(name="boardActivities")
|
||||
each activityData in boardActivitiesList
|
||||
+activity(activity=activityData card=card mode=mode)
|
||||
each currentBoard.activities
|
||||
.activity
|
||||
+userAvatar(userId=user._id)
|
||||
p.activity-desc
|
||||
+memberName(user=user)
|
||||
|
||||
if($eq activityType 'deleteAttachment')
|
||||
| {{{_ 'activity-delete-attach' cardLink}}}.
|
||||
|
||||
if($eq activityType 'addAttachment')
|
||||
| {{{_ 'activity-attached' attachmentLink cardLink}}}.
|
||||
|
||||
if($eq activityType 'addBoardMember')
|
||||
| {{{_ 'activity-added' memberLink boardLabel}}}.
|
||||
|
||||
if($eq activityType 'addComment')
|
||||
| {{{_ 'activity-on' cardLink}}}
|
||||
a.activity-comment(href="{{ card.absoluteUrl }}")
|
||||
+viewer
|
||||
= comment.text
|
||||
|
||||
if($eq activityType 'addChecklist')
|
||||
| {{{_ 'activity-checklist-added' cardLink}}}.
|
||||
.activity-checklist(href="{{ card.absoluteUrl }}")
|
||||
+viewer
|
||||
= checklist.title
|
||||
if($eq activityType 'removeChecklist')
|
||||
| {{{_ 'activity-checklist-removed' cardLink}}}.
|
||||
|
||||
if($eq activityType 'checkedItem')
|
||||
| {{{_ 'activity-checked-item' checkItem checklist.title cardLink}}}.
|
||||
|
||||
if($eq activityType 'uncheckedItem')
|
||||
| {{{_ 'activity-unchecked-item' checkItem checklist.title cardLink}}}.
|
||||
|
||||
if($eq activityType 'checklistCompleted')
|
||||
| {{{_ 'activity-checklist-completed' checklist.title cardLink}}}.
|
||||
|
||||
if($eq activityType 'checklistUncompleted')
|
||||
| {{{_ 'activity-checklist-uncompleted' checklist.title cardLink}}}.
|
||||
|
||||
if($eq activityType 'addChecklistItem')
|
||||
| {{{_ 'activity-checklist-item-added' checklist.title cardLink}}}.
|
||||
.activity-checklist(href="{{ card.absoluteUrl }}")
|
||||
+viewer
|
||||
= checklistItem.title
|
||||
if($eq activityType 'removedChecklistItem')
|
||||
| {{{_ 'activity-checklist-item-removed' checklist.title cardLink}}}.
|
||||
|
||||
if($eq activityType 'archivedCard')
|
||||
| {{{_ 'activity-archived' cardLink}}}.
|
||||
|
||||
if($eq activityType 'archivedList')
|
||||
| {{_ 'activity-archived' list.title}}.
|
||||
|
||||
if($eq activityType 'archivedSwimlane')
|
||||
| {{_ 'activity-archived' swimlane.title}}.
|
||||
|
||||
if($eq activityType 'createBoard')
|
||||
| {{_ 'activity-created' boardLabel}}.
|
||||
|
||||
if($eq activityType 'createCard')
|
||||
| {{{_ 'activity-added' cardLink boardLabel}}}.
|
||||
|
||||
if($eq activityType 'createCustomField')
|
||||
| {{_ 'activity-customfield-created' customField}}.
|
||||
|
||||
if($eq activityType 'createList')
|
||||
| {{_ 'activity-added' list.title boardLabel}}.
|
||||
|
||||
if($eq activityType 'createSwimlane')
|
||||
| {{_ 'activity-added' swimlane.title boardLabel}}.
|
||||
|
||||
if($eq activityType 'removeList')
|
||||
| {{_ 'activity-removed' title boardLabel}}.
|
||||
|
||||
if($eq activityType 'importBoard')
|
||||
| {{{_ 'activity-imported-board' boardLabel sourceLink}}}.
|
||||
|
||||
if($eq activityType 'importCard')
|
||||
| {{{_ 'activity-imported' cardLink boardLabel sourceLink}}}.
|
||||
|
||||
if($eq activityType 'importList')
|
||||
| {{{_ 'activity-imported' listLabel boardLabel sourceLink}}}.
|
||||
|
||||
if($eq activityType 'joinMember')
|
||||
if($eq user._id member._id)
|
||||
| {{{_ 'activity-joined' cardLink}}}.
|
||||
else
|
||||
| {{{_ 'activity-added' memberLink cardLink}}}.
|
||||
|
||||
if($eq activityType 'moveCardBoard')
|
||||
| {{{_ 'activity-moved' cardLink oldBoardName boardName}}}.
|
||||
|
||||
if($eq activityType 'moveCard')
|
||||
| {{{_ 'activity-moved' cardLink oldList.title list.title}}}.
|
||||
|
||||
if($eq activityType 'removeBoardMember')
|
||||
| {{{_ 'activity-excluded' memberLink boardLabel}}}.
|
||||
|
||||
if($eq activityType 'restoredCard')
|
||||
| {{{_ 'activity-sent' cardLink boardLabel}}}.
|
||||
|
||||
if($eq activityType 'addedLabel')
|
||||
| {{{_ 'activity-added-label' lastLabel cardLink}}}.
|
||||
|
||||
if($eq activityType 'removedLabel')
|
||||
| {{{_ 'activity-removed-label' lastLabel cardLink}}}.
|
||||
|
||||
if($eq activityType 'setCustomField')
|
||||
| {{{_ 'activity-set-customfield' lastCustomField lastCustomFieldValue cardLink}}}.
|
||||
|
||||
if($eq activityType 'unsetCustomField')
|
||||
| {{{_ 'activity-unset-customfield' lastCustomField cardLink}}}.
|
||||
|
||||
if($eq activityType 'unjoinMember')
|
||||
if($eq user._id member._id)
|
||||
| {{{_ 'activity-unjoined' cardLink}}}.
|
||||
else
|
||||
| {{{_ 'activity-removed' memberLink cardLink}}}.
|
||||
|
||||
span(title=createdAt).activity-meta {{ moment createdAt }}
|
||||
|
||||
template(name="cardActivities")
|
||||
each activityData in cardActivitiesList
|
||||
+activity(activity=activityData card=card mode=mode)
|
||||
each currentCard.activities
|
||||
.activity
|
||||
+userAvatar(userId=user._id)
|
||||
p.activity-desc
|
||||
+memberName(user=user)
|
||||
if($eq activityType 'createCard')
|
||||
| {{_ 'activity-added' cardLabel listName}}.
|
||||
if($eq activityType 'importCard')
|
||||
| {{{_ 'activity-imported' cardLabel list.title sourceLink}}}.
|
||||
if($eq activityType 'joinMember')
|
||||
if($eq user._id member._id)
|
||||
| {{_ 'activity-joined' cardLabel}}.
|
||||
else
|
||||
| {{{_ 'activity-added' memberLink cardLabel}}}.
|
||||
if($eq activityType 'unjoinMember')
|
||||
if($eq user._id member._id)
|
||||
| {{_ 'activity-unjoined' cardLabel}}.
|
||||
else
|
||||
| {{{_ 'activity-removed' cardLabel memberLink}}}.
|
||||
if($eq activityType 'archivedCard')
|
||||
| {{_ 'activity-archived' cardLabel}}.
|
||||
|
||||
template(name="activity")
|
||||
.activity(data-id=activity._id)
|
||||
+userAvatar(userId=activity.user._id)
|
||||
p.activity-desc
|
||||
span.activity-member
|
||||
+memberName(user=activity.user)
|
||||
if($eq activityType 'addedLabel')
|
||||
| {{{_ 'activity-added-label-card' lastLabel }}}.
|
||||
|
||||
//- attachment activity -------------------------------------------------
|
||||
if($eq activity.activityType 'deleteAttachment')
|
||||
| {{{_ 'activity-delete-attach' cardLink}}}.
|
||||
if($eq activityType 'removedLabel')
|
||||
| {{{_ 'activity-removed-label-card' lastLabel }}}.
|
||||
|
||||
if($eq activity.activityType 'addAttachment')
|
||||
| {{{_ 'activity-attached' attachmentLink cardLink}}}.
|
||||
if($neq mode 'board')
|
||||
if activity.attachment.isImage
|
||||
img.attachment-image-preview(src=activity.attachment.url alt="")
|
||||
if($eq activityType 'removeChecklist')
|
||||
| {{{_ 'activity-checklist-removed' cardLabel}}}.
|
||||
|
||||
//- board activity ------------------------------------------------------
|
||||
if($eq activity.activityType 'createBoard')
|
||||
| {{{_ 'activity-created' boardLabelLink}}}.
|
||||
if($eq activityType 'checkedItem')
|
||||
| {{{_ 'activity-checked-item-card' checkItem checklist.title }}}.
|
||||
|
||||
if($eq activity.activityType 'importBoard')
|
||||
| {{{_ 'activity-imported-board' boardLabelLink sourceLink}}}.
|
||||
if($eq activityType 'uncheckedItem')
|
||||
| {{{_ 'activity-unchecked-item-card' checkItem checklist.title }}}.
|
||||
|
||||
if($eq activity.activityType 'addBoardMember')
|
||||
| {{{_ 'activity-added' memberLink boardLabelLink}}}.
|
||||
if($eq activityType 'checklistCompleted')
|
||||
| {{{_ 'activity-checklist-completed-card' checklist.title }}}.
|
||||
|
||||
if($eq activity.activityType 'removeBoardMember')
|
||||
| {{{_ 'activity-excluded' memberLink boardLabelLink}}}.
|
||||
if($eq activityType 'checklistUncompleted')
|
||||
| {{{_ 'activity-checklist-uncompleted-card' checklist.title }}}.
|
||||
|
||||
//- card activity -------------------------------------------------------
|
||||
if($eq activity.activityType 'createCard')
|
||||
if($eq mode 'card')
|
||||
| {{{_ 'activity-added' cardLabelLink (sanitize activity.listName)}}}.
|
||||
else
|
||||
| {{{_ 'activity-added' cardLabelLink boardLabelLink}}}.
|
||||
if($eq activityType 'restoredCard')
|
||||
| {{_ 'activity-sent' cardLabel boardLabel}}.
|
||||
if($eq activityType 'moveCard')
|
||||
| {{_ 'activity-moved' cardLabel oldList.title list.title}}.
|
||||
|
||||
if($eq activity.activityType 'importCard')
|
||||
| {{{_ 'activity-imported' cardLink boardLabelLink sourceLink}}}.
|
||||
if($eq activityType 'moveCardBoard')
|
||||
| {{{_ 'activity-moved' cardLink oldBoardName boardName}}}.
|
||||
|
||||
if($eq activity.activityType 'moveCard')
|
||||
| {{{_ 'activity-moved' cardLabelLink (sanitize activity.oldList.title) (sanitize activity.list.title)}}}.
|
||||
|
||||
if($eq activity.activityType 'moveCardBoard')
|
||||
| {{{_ 'activity-moved' cardLink (sanitize activity.oldBoardName) (sanitize activity.boardName)}}}.
|
||||
|
||||
if($eq activity.activityType 'archivedCard')
|
||||
| {{{_ 'activity-archived' cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'restoredCard')
|
||||
| {{{_ 'activity-sent' cardLink boardLabelLink}}}.
|
||||
|
||||
//- checklist activity --------------------------------------------------
|
||||
if($eq activity.activityType 'addChecklist')
|
||||
| {{{_ 'activity-checklist-added' cardLink}}}.
|
||||
if($eq mode 'card')
|
||||
if($eq activityType 'addAttachment')
|
||||
| {{{_ 'activity-attached' attachmentLink cardLabel}}}.
|
||||
if attachment.isImage
|
||||
img.attachment-image-preview(src=attachment.url)
|
||||
if($eq activityType 'deleteAttachment')
|
||||
| {{{_ 'activity-delete-attach' cardLabel}}}.
|
||||
if($eq activityType 'removedChecklist')
|
||||
| {{{_ 'activity-checklist-removed' cardLabel}}}.
|
||||
if($eq activityType 'addChecklist')
|
||||
| {{{_ 'activity-checklist-added' cardLabel}}}.
|
||||
.activity-checklist
|
||||
+viewer
|
||||
= activity.checklist.title
|
||||
else
|
||||
a.activity-checklist(href="{{ activity.card.originRelativeUrl }}")
|
||||
= checklist.title
|
||||
if($eq activityType 'addChecklistItem')
|
||||
| {{{_ 'activity-checklist-item-added' checklist.title cardLink}}}.
|
||||
.activity-checklist(href="{{ card.absoluteUrl }}")
|
||||
+viewer
|
||||
= activity.checklist.title
|
||||
= checklistItem.title
|
||||
|
||||
if($eq activity.activityType 'removedChecklist')
|
||||
| {{{_ 'activity-checklist-removed' cardLink}}}.
|
||||
if($eq activityType 'addComment')
|
||||
+inlinedForm(classNames='js-edit-comment')
|
||||
+editor(autofocus=true)
|
||||
= comment.text
|
||||
.edit-controls
|
||||
button.primary(type="submit") {{_ 'edit'}}
|
||||
else
|
||||
.activity-comment
|
||||
+viewer
|
||||
= comment.text
|
||||
span(title=createdAt).activity-meta {{ moment createdAt }}
|
||||
if ($eq currentUser._id comment.userId)
|
||||
= ' - '
|
||||
a.js-open-inlined-form {{_ "edit"}}
|
||||
= ' - '
|
||||
a.js-delete-comment {{_ "delete"}}
|
||||
|
||||
if($eq activity.activityType 'completeChecklist')
|
||||
| {{{_ 'activity-checklist-completed' (sanitize activity.checklist.title) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'uncompleteChecklist')
|
||||
| {{{_ 'activity-checklist-uncompleted' (sanitize activity.checklist.title) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'checkedItem')
|
||||
| {{{_ 'activity-checked-item' (sanitize checkItem) (sanitize activity.checklist.title) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'uncheckedItem')
|
||||
| {{{_ 'activity-unchecked-item' (sanitize checkItem) (sanitize activity.checklist.title) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'addChecklistItem')
|
||||
| {{{_ 'activity-checklist-item-added' (sanitize activity.checklist.title) cardLink}}}.
|
||||
.activity-checklist(href="{{ activity.card.originRelativeUrl }}")
|
||||
+viewer
|
||||
= activity.checklistItem.title
|
||||
|
||||
if($eq activity.activityType 'removedChecklistItem')
|
||||
| {{{_ 'activity-checklist-item-removed' (sanitize activity.checklist.title) cardLink}}}.
|
||||
|
||||
//- comment activity ----------------------------------------------------
|
||||
if($eq activity.activityType 'deleteComment')
|
||||
| {{{_ 'activity-deleteComment' (sanitize activity.commentDisplayText)}}}.
|
||||
|
||||
if($eq activity.activityType 'editComment')
|
||||
| {{{_ 'activity-editComment' (sanitize activity.commentDisplayText)}}}.
|
||||
|
||||
if($eq activity.activityType 'addComment')
|
||||
| {{{_ 'activity-on' cardLink}}}
|
||||
a.activity-comment(href="{{ activity.card.originRelativeUrl }}")
|
||||
+viewer
|
||||
= activity.comment.text
|
||||
|
||||
//- date activity ------------------------------------------------
|
||||
if($eq activity.activityType 'a-receivedAt')
|
||||
| {{{_ 'activity-receivedDate' (sanitize receivedDate) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'a-startAt')
|
||||
| {{{_ 'activity-startDate' (sanitize startDate) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'a-dueAt')
|
||||
| {{{_ 'activity-dueDate' (sanitize dueDate) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'a-endAt')
|
||||
| {{{_ 'activity-endDate' (sanitize endDate) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'a-changedTitle')
|
||||
| {{{_ 'activity-changedTitle' (sanitize activity.value) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'a-changedDescription')
|
||||
| {{{_ 'activity-changedDescription' cardLink}}}.
|
||||
|
||||
//- customField activity ------------------------------------------------
|
||||
if($eq activity.activityType 'createCustomField')
|
||||
| {{_ 'activity-customfield-created' customField}}.
|
||||
|
||||
if($eq activity.activityType 'setCustomField')
|
||||
| {{{_ 'activity-set-customfield' (sanitize lastCustomField) (sanitize lastCustomFieldValue) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'unsetCustomField')
|
||||
| {{{_ 'activity-unset-customfield' (sanitize lastCustomField) cardLink}}}.
|
||||
|
||||
//- label activity ------------------------------------------------------
|
||||
if($eq activity.activityType 'addedLabel')
|
||||
| {{{_ 'activity-added-label' (sanitize lastLabel) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'removedLabel')
|
||||
| {{{_ 'activity-removed-label' (sanitize lastLabel) cardLink}}}.
|
||||
|
||||
//- list activity -------------------------------------------------------
|
||||
if($neq mode 'card')
|
||||
if($eq activity.activityType 'createList')
|
||||
| {{{_ 'activity-added' (sanitize listLabel) boardLabelLink}}}.
|
||||
|
||||
if($eq activity.activityType 'importList')
|
||||
| {{{_ 'activity-imported' (sanitize listLabel) boardLabelLink sourceLink}}}.
|
||||
|
||||
if($eq activity.activityType 'removeList')
|
||||
| {{{_ 'activity-removed' (sanitize activity.title) boardLabelLink}}}.
|
||||
|
||||
if($eq activity.activityType 'archivedList')
|
||||
| {{_ 'activity-archived' (sanitize listLabel)}}.
|
||||
|
||||
if($eq activity.activityType 'changedListTitle')
|
||||
| {{_ 'activity-changedListTitle' (sanitize listLabel) boardLabelLink}}
|
||||
|
||||
//- member activity ----------------------------------------------------
|
||||
if($eq activity.activityType 'joinMember')
|
||||
if($eq user._id activity.member._id)
|
||||
| {{{_ 'activity-joined' cardLink}}}.
|
||||
else
|
||||
| {{{_ 'activity-added' memberLink cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'unjoinMember')
|
||||
if($eq user._id activity.member._id)
|
||||
| {{{_ 'activity-unjoined' cardLink}}}.
|
||||
else
|
||||
| {{{_ 'activity-removed' memberLink cardLink}}}.
|
||||
|
||||
//- swimlane activity --------------------------------------------------
|
||||
if($eq activity.activityType 'createSwimlane')
|
||||
| {{{_ 'activity-added' (sanitize activity.swimlane.title) boardLabelLink}}}.
|
||||
|
||||
if($eq activity.activityType 'archivedSwimlane')
|
||||
| {{{_ 'activity-archived' (sanitize activity.swimlane.title)}}}.
|
||||
|
||||
|
||||
//- I don't understand this part ----------------------------------------
|
||||
if(currentData.timeKey)
|
||||
| {{_ activity.activityType }}
|
||||
= ' '
|
||||
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
|
||||
if (currentData.timeOldValue)
|
||||
= ' '
|
||||
| {{{_ "previous_as" }}}
|
||||
= ' '
|
||||
i(title=currentData.timeOldValue).activity-meta {{ displayDate currentData.timeOldValue 'LLL' }}
|
||||
= ' @'
|
||||
else if(currentData.timeValue)
|
||||
| {{_ activity.activityType currentData.timeValue}}
|
||||
|
||||
if($neq mode 'none')
|
||||
div(title=activity.createdAt).activity-meta {{ displayDate activity.createdAt }}
|
||||
span(title=createdAt).activity-meta {{ moment createdAt }}
|
||||
@@ -1,428 +1,170 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Template } from 'meteor/templating';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { sanitizeHTML, sanitizeText } from '/imports/lib/secureDOMPurify';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { getSidebarInstance } from '/client/features/sidebar/service';
|
||||
import { activityCardLinkData } from '/models/lib/activityCardLink';
|
||||
const activitiesPerPage = 20;
|
||||
|
||||
// #6480/#6481: 500 was far more than the sidebar/card activity feed ever shows
|
||||
// at once, and with FerretDB (no oplog) every live cursor is re-run on a timer —
|
||||
// so a 500-row activities cursor was re-fetched and re-diffed on every poll for
|
||||
// each open board/card. 50 covers the visible feed; infinite scroll still pulls
|
||||
// the next page on demand (the server cursor is index-bounded either way).
|
||||
const activitiesPerPage = 50;
|
||||
BlazeComponent.extendComponent({
|
||||
onCreated() {
|
||||
// XXX Should we use ReactiveNumber?
|
||||
this.page = new ReactiveVar(1);
|
||||
this.loadNextPageLocked = false;
|
||||
const sidebar = this.parentComponent(); // XXX for some reason not working
|
||||
sidebar.callFirstWith(null, 'resetNextPeak');
|
||||
this.autorun(() => {
|
||||
let mode = this.data().mode;
|
||||
const capitalizedMode = Utils.capitalize(mode);
|
||||
let thisId, searchId;
|
||||
if (mode === 'linkedcard' || mode === 'linkedboard') {
|
||||
thisId = Session.get('currentCard');
|
||||
searchId = Cards.findOne({_id: thisId}).linkedId;
|
||||
mode = mode.replace('linked', '');
|
||||
} else {
|
||||
thisId = Session.get(`current${capitalizedMode}`);
|
||||
searchId = thisId;
|
||||
}
|
||||
const limit = this.page.get() * activitiesPerPage;
|
||||
const user = Meteor.user();
|
||||
const hideSystem = user ? user.hasHiddenSystemMessages() : false;
|
||||
if (searchId === null)
|
||||
return;
|
||||
|
||||
Template.activities.onCreated(function () {
|
||||
// Register with sidebar so it can call loadNextPage on us
|
||||
const Sidebar = getSidebarInstance();
|
||||
if (Sidebar) {
|
||||
Sidebar.activitiesInstance = this;
|
||||
}
|
||||
this.subscribe('activities', mode, searchId, limit, hideSystem, () => {
|
||||
this.loadNextPageLocked = false;
|
||||
|
||||
// XXX Should we use ReactiveNumber?
|
||||
this.page = new ReactiveVar(1);
|
||||
this.loadNextPageLocked = false;
|
||||
this.loadNextPage = () => {
|
||||
// If the sibear peak hasn't increased, that mean that there are no more
|
||||
// activities, and we can stop calling new subscriptions.
|
||||
// XXX This is hacky! We need to know excatly and reactively how many
|
||||
// activities there are, we probably want to denormalize this number
|
||||
// dirrectly into card and board documents.
|
||||
const nextPeakBefore = sidebar.callFirstWith(null, 'getNextPeak');
|
||||
sidebar.calculateNextPeak();
|
||||
const nextPeakAfter = sidebar.callFirstWith(null, 'getNextPeak');
|
||||
if (nextPeakBefore === nextPeakAfter) {
|
||||
sidebar.callFirstWith(null, 'resetNextPeak');
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
loadNextPage() {
|
||||
if (this.loadNextPageLocked === false) {
|
||||
this.page.set(this.page.get() + 1);
|
||||
this.loadNextPageLocked = true;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO is sidebar always available? E.g. on small screens/mobile devices
|
||||
const sidebar = getSidebarInstance();
|
||||
if (sidebar && sidebar.infiniteScrolling) {
|
||||
sidebar.infiniteScrolling.resetNextPeak();
|
||||
}
|
||||
this.autorun(() => {
|
||||
const data = Template.currentData();
|
||||
let mode = data?.mode;
|
||||
if (mode) {
|
||||
const capitalizedMode = Utils.capitalize(mode);
|
||||
let searchId;
|
||||
const showActivities = _showActivities(data);
|
||||
if (mode === 'linkedcard' || mode === 'linkedboard') {
|
||||
const currentCard = Utils.getCurrentCard();
|
||||
searchId = currentCard.linkedId;
|
||||
mode = mode.replace('linked', '');
|
||||
} else if (mode === 'card') {
|
||||
searchId = Utils.getCurrentCardId();
|
||||
} else {
|
||||
searchId = Session.get(`current${capitalizedMode}`);
|
||||
}
|
||||
const limit = this.page.get() * activitiesPerPage;
|
||||
if (searchId === null) return;
|
||||
|
||||
this.subscribe('activities', mode, searchId, limit, showActivities, () => {
|
||||
this.loadNextPageLocked = false;
|
||||
|
||||
// TODO the guard can be removed as soon as the TODO above is resolved
|
||||
if (!sidebar || !sidebar.infiniteScrolling) return;
|
||||
// If the sidebar peak hasn't increased, that means that there are no more
|
||||
// activities, and we can stop calling new subscriptions.
|
||||
const nextPeakBefore = sidebar.infiniteScrolling.getNextPeak();
|
||||
sidebar.calculateNextPeak();
|
||||
const nextPeakAfter = sidebar.infiniteScrolling.getNextPeak();
|
||||
if (nextPeakBefore === nextPeakAfter) {
|
||||
sidebar.infiniteScrolling.resetNextPeak();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function _showActivities(data) {
|
||||
// Instance-wide switch (Admin Panel / Settings / Hide board activities on all
|
||||
// boards). Read ONCE from the global settings; when it is on, no board shows
|
||||
// its activities and the per-board value is not consulted at all.
|
||||
if (ReactiveCache.getCurrentSetting()?.hideBoardActivitiesOnAllBoards) {
|
||||
return false;
|
||||
}
|
||||
let ret = false;
|
||||
let mode = data?.mode;
|
||||
if (mode) {
|
||||
if (mode === 'linkedcard' || mode === 'linkedboard') {
|
||||
const currentCard = Utils.getCurrentCard();
|
||||
ret = currentCard.showActivities ?? false;
|
||||
} else if (mode === 'card') {
|
||||
// The card's Activities section is COLLAPSED by default and renders this
|
||||
// template only when its caret has been opened - so being here means the
|
||||
// reader asked for the history, and the history is what they get.
|
||||
//
|
||||
// Until the section had a caret, the card carried a `showActivities` flag
|
||||
// toggled by an eye beside the heading, and `false` - the default -
|
||||
// subscribed to comments ONLY (server/publications/activities.js). The eye
|
||||
// is gone: one control for one idea. Nothing is fetched at all while the
|
||||
// section is closed, which is the cheaper half of what the flag was for,
|
||||
// and opening it now means the whole history rather than a filtered part
|
||||
// of it. Do not consult the obsolete per-card `showActivities` value here:
|
||||
// existing cards commonly persist its old default (`false`), which would
|
||||
// silently turn an explicitly opened Activities section into comments-only.
|
||||
ret = true;
|
||||
} else {
|
||||
ret = Utils.getCurrentBoard().showActivities ?? false;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
Template.activities.helpers({
|
||||
showActivities() {
|
||||
const data = Template.currentData();
|
||||
return _showActivities(data);
|
||||
},
|
||||
|
||||
activities() {
|
||||
const activities = this.card?.activities?.();
|
||||
return activities || [];
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardActivities.helpers({
|
||||
boardActivitiesList() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
const activities = board?.activities?.();
|
||||
return activities || [];
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardActivities.helpers({
|
||||
cardActivitiesList() {
|
||||
const data = Template.currentData();
|
||||
const card = data?.card;
|
||||
const activities = card?.activities?.();
|
||||
return activities || [];
|
||||
},
|
||||
});
|
||||
|
||||
Template.activity.helpers({
|
||||
checkItem() {
|
||||
const checkItemId = this.activity.checklistItemId;
|
||||
const checkItem = ReactiveCache.getChecklistItem(checkItemId);
|
||||
return checkItem && checkItem.title;
|
||||
checkItem(){
|
||||
const checkItemId = this.currentData().checklistItemId;
|
||||
const checkItem = ChecklistItems.findOne({_id:checkItemId});
|
||||
return checkItem.title;
|
||||
},
|
||||
|
||||
boardLabelLink() {
|
||||
const currentBoardId = Session.get('currentBoard');
|
||||
if (this.mode !== 'board') {
|
||||
return createBoardLink(this.activity.board(), this.activity.listName ? this.activity.listName : null);
|
||||
}
|
||||
else if (currentBoardId != this.activity.boardId) {
|
||||
return createBoardLink(this.activity.board(), this.activity.listName ? this.activity.listName : null);
|
||||
}
|
||||
boardLabel() {
|
||||
return TAPi18n.__('this-board');
|
||||
},
|
||||
|
||||
cardLabelLink() {
|
||||
const currentBoardId = Session.get('currentBoard');
|
||||
if (this.mode == 'card') {
|
||||
return TAPi18n.__('this-card');
|
||||
}
|
||||
else if (this.mode !== 'board') {
|
||||
return createCardLink(this.activity, this.activity.card(), null);
|
||||
}
|
||||
else if (currentBoardId != this.activity.boardId) {
|
||||
return createCardLink(this.activity, this.activity.card(), this.activity.board().title);
|
||||
}
|
||||
return createCardLink(this.activity, this.activity.card(), null);
|
||||
cardLabel() {
|
||||
return TAPi18n.__('this-card');
|
||||
},
|
||||
|
||||
cardLink() {
|
||||
const currentBoardId = Session.get('currentBoard');
|
||||
if (this.mode !== 'board') {
|
||||
return createCardLink(this.activity, this.activity.card(), null);
|
||||
}
|
||||
else if (currentBoardId != this.activity.boardId) {
|
||||
return createCardLink(this.activity, this.activity.card(), this.activity.board().title);
|
||||
}
|
||||
return createCardLink(this.activity, this.activity.card(), null);
|
||||
const card = this.currentData().card();
|
||||
return card && Blaze.toHTML(HTML.A({
|
||||
href: card.absoluteUrl(),
|
||||
'class': 'action-card',
|
||||
}, card.title));
|
||||
},
|
||||
|
||||
receivedDate() {
|
||||
const card = this.activity.card();
|
||||
if (!card) return null;
|
||||
return card.receivedAt;
|
||||
},
|
||||
|
||||
startDate() {
|
||||
const card = this.activity.card();
|
||||
if (!card) return null;
|
||||
return card.startAt;
|
||||
},
|
||||
|
||||
dueDate() {
|
||||
const card = this.activity.card();
|
||||
if (!card) return null;
|
||||
return card.dueAt;
|
||||
},
|
||||
|
||||
endDate() {
|
||||
const card = this.activity.card();
|
||||
if (!card) return null;
|
||||
return card.endAt;
|
||||
},
|
||||
|
||||
lastLabel() {
|
||||
const lastLabelId = this.activity.labelId;
|
||||
if (!lastLabelId) return null;
|
||||
const lastLabel = ReactiveCache.getBoard(
|
||||
this.activity.boardId,
|
||||
).getLabelById(lastLabelId);
|
||||
if (lastLabel && (lastLabel.name === undefined || lastLabel.name === '')) {
|
||||
return lastLabel.color;
|
||||
} else if (lastLabel.name !== undefined && lastLabel.name !== '') {
|
||||
return lastLabel.name;
|
||||
} else {
|
||||
lastLabel(){
|
||||
const lastLabelId = this.currentData().labelId;
|
||||
if (!lastLabelId)
|
||||
return null;
|
||||
const lastLabel = Boards.findOne(Session.get('currentBoard')).getLabelById(lastLabelId);
|
||||
if(lastLabel.name === undefined || lastLabel.name === ''){
|
||||
return lastLabel.color;
|
||||
}else{
|
||||
return lastLabel.name;
|
||||
}
|
||||
},
|
||||
|
||||
lastCustomField() {
|
||||
const lastCustomField = ReactiveCache.getCustomField(
|
||||
this.activity.customFieldId,
|
||||
);
|
||||
if (!lastCustomField) return null;
|
||||
lastCustomField(){
|
||||
const lastCustomField = CustomFields.findOne(this.currentData().customFieldId);
|
||||
if (!lastCustomField)
|
||||
return null;
|
||||
return lastCustomField.name;
|
||||
},
|
||||
|
||||
lastCustomFieldValue() {
|
||||
const lastCustomField = ReactiveCache.getCustomField(
|
||||
this.activity.customFieldId,
|
||||
);
|
||||
if (!lastCustomField) return null;
|
||||
const value = this.activity.value;
|
||||
if (
|
||||
lastCustomField.settings.dropdownItems &&
|
||||
lastCustomField.settings.dropdownItems.length > 0
|
||||
) {
|
||||
const dropDownValue = lastCustomField.settings.dropdownItems.find(
|
||||
item => {
|
||||
return item._id === value;
|
||||
},
|
||||
);
|
||||
if (dropDownValue) return dropDownValue.name;
|
||||
lastCustomFieldValue(){
|
||||
const lastCustomField = CustomFields.findOne(this.currentData().customFieldId);
|
||||
if (!lastCustomField)
|
||||
return null;
|
||||
const value = this.currentData().value;
|
||||
if (lastCustomField.settings.dropdownItems && lastCustomField.settings.dropdownItems.length > 0) {
|
||||
const dropDownValue = _.find(lastCustomField.settings.dropdownItems, (item) => {
|
||||
return item._id === value;
|
||||
});
|
||||
if (dropDownValue)
|
||||
return dropDownValue.name;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
listLabel() {
|
||||
const activity = this.activity;
|
||||
const list = activity.list();
|
||||
return (list && list.title) || activity.title;
|
||||
return this.currentData().list().title;
|
||||
},
|
||||
|
||||
sourceLink() {
|
||||
const source = this.activity.source;
|
||||
if (source) {
|
||||
// XSS fix (reported by meifukun): source.url is imported from an external
|
||||
// board (e.g. a Trello board's `url`) and was rendered as an <a href> with
|
||||
// no scheme check, so a `javascript:` (or data:/vbscript:) URL executed in
|
||||
// the board admin's session on click and could exfiltrate Meteor.loginToken.
|
||||
// Only render a link for an http(s) URL; otherwise show the plain (sanitized)
|
||||
// source name with no href.
|
||||
if (source.url && /^https?:\/\//i.test(String(source.url))) {
|
||||
return Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: source.url,
|
||||
},
|
||||
sanitizeHTML(source.system),
|
||||
),
|
||||
);
|
||||
const source = this.currentData().source;
|
||||
if(source) {
|
||||
if(source.url) {
|
||||
return Blaze.toHTML(HTML.A({
|
||||
href: source.url,
|
||||
}, source.system));
|
||||
} else {
|
||||
return source.system;
|
||||
}
|
||||
return sanitizeHTML(source.system);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
memberLink() {
|
||||
return Blaze.toHTMLWithData(Template.memberName, {
|
||||
user: this.activity.member(),
|
||||
user: this.currentData().member(),
|
||||
});
|
||||
},
|
||||
|
||||
attachmentLink() {
|
||||
const attachment = this.activity.attachment();
|
||||
const attachmentUrl = attachment && typeof attachment.link === 'function'
|
||||
? attachment.link()
|
||||
: '';
|
||||
const attachment = this.currentData().attachment();
|
||||
// trying to display url before file is stored generates js errors
|
||||
return (
|
||||
(attachment &&
|
||||
attachment.path &&
|
||||
attachmentUrl &&
|
||||
Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: `${attachmentUrl}?download=true`,
|
||||
target: '_blank',
|
||||
},
|
||||
sanitizeText(attachment.name),
|
||||
),
|
||||
)) ||
|
||||
sanitizeText(this.activity.attachmentName)
|
||||
);
|
||||
return attachment && attachment.url({ download: true }) && Blaze.toHTML(HTML.A({
|
||||
href: attachment.url({ download: true }),
|
||||
target: '_blank',
|
||||
}, attachment.name()));
|
||||
},
|
||||
|
||||
customField() {
|
||||
const customField = this.activity.customField();
|
||||
if (!customField) return null;
|
||||
const customField = this.currentData().customField();
|
||||
if (!customField)
|
||||
return null;
|
||||
return customField.name;
|
||||
},
|
||||
});
|
||||
|
||||
Template.activity.helpers({
|
||||
sanitize(value) {
|
||||
return sanitizeHTML(value);
|
||||
},
|
||||
});
|
||||
|
||||
Template.commentReactions.events({
|
||||
'click .reaction'(event) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && user.isBoardMember()) {
|
||||
const codepoint = event.currentTarget.dataset['codepoint'];
|
||||
const commentId = Template.instance().data.commentId;
|
||||
const cardComment = ReactiveCache.getCardComment(commentId);
|
||||
cardComment.toggleReaction(codepoint);
|
||||
}
|
||||
},
|
||||
'click .open-comment-reaction-popup': Popup.open('addReaction'),
|
||||
})
|
||||
|
||||
Template.addReactionPopup.events({
|
||||
'click .add-comment-reaction'(event) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && user.isBoardMember()) {
|
||||
const codepoint = event.currentTarget.dataset['codepoint'];
|
||||
const commentId = Template.instance().data.commentId;
|
||||
const cardComment = ReactiveCache.getCardComment(commentId);
|
||||
cardComment.toggleReaction(codepoint);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
})
|
||||
|
||||
Template.addReactionPopup.helpers({
|
||||
codepoints() {
|
||||
// Starting set of unicode codepoints as comment reactions
|
||||
return [
|
||||
'👍',
|
||||
'👎',
|
||||
'👀',
|
||||
'✅',
|
||||
'❌',
|
||||
'🙏',
|
||||
'👏',
|
||||
'🎉',
|
||||
'🚀',
|
||||
'😊',
|
||||
'🤔',
|
||||
'😔'];
|
||||
}
|
||||
})
|
||||
|
||||
Template.commentReactions.helpers({
|
||||
isSelected(userIds) {
|
||||
return Meteor.userId() && userIds.includes(Meteor.userId());
|
||||
},
|
||||
userNames(userIds) {
|
||||
const ret = ReactiveCache.getUsers({_id: {$in: userIds}})
|
||||
.map(user => user.profile.fullname)
|
||||
.join(', ');
|
||||
return ret;
|
||||
}
|
||||
})
|
||||
|
||||
// #3144: the card is not always here. An ARCHIVED card is not published to the
|
||||
// client, so `activity.card()` returned nothing and every sentence that named it
|
||||
// was rendered around a hole - "Activities for archived card displayed as
|
||||
// undefined on board settings".
|
||||
//
|
||||
// The activity itself recorded the title in most cases (`cardTitle`), and a card
|
||||
// URL can be built from the ids it carries, so the link survives the card not
|
||||
// being here. models/lib/activityCardLink.js decides what to show;
|
||||
// this turns it into the anchor, and marks an archived card the way the reporter
|
||||
// asked for: "{{ card title }} [archived]".
|
||||
function createCardLink(activity, card, boardTitle) {
|
||||
const link = activityCardLinkData(activity, card, card ? card.board && card.board() : null);
|
||||
if (!link) {
|
||||
// Nothing named the card - an old activity from before the titles were
|
||||
// recorded, on a card this client cannot see. Say "this card" rather than
|
||||
// leaving a gap in the middle of a sentence.
|
||||
return sanitizeHTML(TAPi18n.__('this-card'));
|
||||
}
|
||||
let text = link.title;
|
||||
if (link.archived) text = `${text} [${TAPi18n.__('archived')}]`;
|
||||
if (boardTitle) text = `${boardTitle} > ` + text;
|
||||
if (!link.url) return sanitizeHTML(text);
|
||||
return Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: link.url,
|
||||
class: 'action-card',
|
||||
events() {
|
||||
return [{
|
||||
// XXX We should use Popup.afterConfirmation here
|
||||
'click .js-delete-comment'() {
|
||||
const commentId = this.currentData().commentId;
|
||||
CardComments.remove(commentId);
|
||||
},
|
||||
sanitizeHTML(text),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createBoardLink(board, list) {
|
||||
let text = board.title;
|
||||
if (list) text += `: ${list}`;
|
||||
return (
|
||||
board &&
|
||||
Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: board.originRelativeUrl(),
|
||||
class: 'action-board',
|
||||
},
|
||||
sanitizeHTML(text),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
'submit .js-edit-comment'(evt) {
|
||||
evt.preventDefault();
|
||||
const commentText = this.currentComponent().getValue().trim();
|
||||
const commentId = Template.parentData().commentId;
|
||||
if (commentText) {
|
||||
CardComments.update(commentId, {
|
||||
$set: {
|
||||
text: commentText,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}];
|
||||
},
|
||||
}).register('activities');
|
||||
@@ -0,0 +1,48 @@
|
||||
@import 'nib'
|
||||
|
||||
.activity-title
|
||||
margin: 0 0.5em 0.8em
|
||||
display: flex
|
||||
justify-content:space-between
|
||||
|
||||
.activities
|
||||
clear: both
|
||||
|
||||
.activity
|
||||
margin: 10px 0
|
||||
display: flex
|
||||
|
||||
.member
|
||||
width: 24px
|
||||
height: @width
|
||||
|
||||
.activity-desc
|
||||
word-wrap: break-word
|
||||
overflow: hidden
|
||||
flex: 1
|
||||
align-self: center
|
||||
margin: 0
|
||||
margin-left: 3px
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
|
||||
.activity-comment
|
||||
display: block
|
||||
border-radius: 3px
|
||||
background: white
|
||||
text-decoration: none
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.2)
|
||||
margin-top: 5px
|
||||
padding: 5px
|
||||
|
||||
.activity-checklist
|
||||
display: block
|
||||
border-radius: 3px
|
||||
background: white
|
||||
text-decoration: none
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.2)
|
||||
margin-top: 5px
|
||||
padding: 5px
|
||||
.activity-meta
|
||||
font-size: 0.8em
|
||||
color: darken(white, 40%)
|
||||
@@ -1,183 +0,0 @@
|
||||
.new-comment {
|
||||
position: relative;
|
||||
margin: 0 0 20px 38px;
|
||||
}
|
||||
.new-comment .member {
|
||||
opacity: 0.7;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
inset-inline-start: -38px;
|
||||
}
|
||||
.new-comment.is-open .member {
|
||||
opacity: 1;
|
||||
}
|
||||
.new-comment.is-open .helper {
|
||||
display: inline-block;
|
||||
}
|
||||
.new-comment.is-open textarea {
|
||||
min-height: 100px;
|
||||
color: #4d4d4d;
|
||||
cursor: auto;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.new-comment .too-long {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.new-comment textarea {
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
padding: 9px 11px;
|
||||
width: 100%;
|
||||
}
|
||||
.new-comment textarea:hover,
|
||||
.new-comment textarea:is-open {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.33);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.new-comment textarea:is-open {
|
||||
cursor: auto;
|
||||
}
|
||||
.comment-item {
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
width: 92%;
|
||||
}
|
||||
.comment-item:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.comment-item.add-comment {
|
||||
display: flex;
|
||||
margin: 5px;
|
||||
}
|
||||
.comment-item.add-comment a {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
.comments {
|
||||
clear: both;
|
||||
}
|
||||
.comments .comment {
|
||||
margin: 0.5px 0;
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
}
|
||||
.comments .comment .member {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.comments .comment .comment-member {
|
||||
font-weight: 700;
|
||||
}
|
||||
.comments .comment .comment-desc {
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
margin-inline-start: 3px;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.comments .comment .comment-desc .comment-text {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
margin-top: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions {
|
||||
display: flex;
|
||||
margin-top: 5px;
|
||||
gap: 5px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .open-comment-reaction-popup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
height: 24px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .open-comment-reaction-popup span {
|
||||
display: inline-block;
|
||||
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .reaction {
|
||||
cursor: pointer;
|
||||
border: 1px solid #808080;
|
||||
border-radius: 15px;
|
||||
display: flex;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .reaction.selected {
|
||||
background-color: #b0c4de;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .reaction:hover {
|
||||
background-color: #b0c4de;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .reaction .reaction-count {
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
.comments .comment .comment-desc .comment-meta {
|
||||
font-size: 0.8em;
|
||||
color: #999;
|
||||
}
|
||||
/* Threaded replies (issue #5907): indent replies under their parent. */
|
||||
.comments .comment.is-reply {
|
||||
margin-inline-start: 24px;
|
||||
border-inline-start: 2px solid #e0e0e0;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
.comments .comment .comment-desc .comment-in-reply-to {
|
||||
font-size: 0.8em;
|
||||
color: #999;
|
||||
border-inline-start: 2px solid #ccc;
|
||||
padding-inline-start: 6px;
|
||||
margin: 3px 0;
|
||||
display: block;
|
||||
}
|
||||
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-label {
|
||||
font-weight: 700;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-text {
|
||||
font-style: italic;
|
||||
}
|
||||
/* "In reply to" banner above the new-comment form. */
|
||||
.new-comment .comment-reply-banner {
|
||||
background: #f4f4f4;
|
||||
border-inline-start: 2px solid #ccc;
|
||||
padding: 4px 6px;
|
||||
margin: 0 4px 6px 0;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.new-comment .comment-reply-banner .comment-reply-banner-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
.new-comment .comment-reply-banner .comment-reply-banner-text {
|
||||
flex: 1;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.new-comment .comment-reply-banner a {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,85 +1,9 @@
|
||||
template(name="commentForm")
|
||||
.new-comment.js-new-comment(
|
||||
class="{{#if commentFormIsOpen}}is-open{{/if}}")
|
||||
+userAvatar(userId=currentUser._id noRemove=true)
|
||||
if replyToComment
|
||||
.comment-reply-banner.js-reply-banner
|
||||
span.comment-reply-banner-label {{_ 'comment-in-reply-to'}}:
|
||||
span.comment-reply-banner-text
|
||||
+viewer
|
||||
= replyToComment.text
|
||||
a.js-cancel-reply(title="{{_ 'close' }}")
|
||||
i.fa.fa-times-thin
|
||||
+userAvatar(userId=currentUser._id)
|
||||
form.js-new-comment-form
|
||||
+editor(class="js-new-comment-input")
|
||||
| {{getUnsavedValue 'cardComment' currentCard._id}}
|
||||
.add-controls
|
||||
button.primary.confirm.clear.js-add-comment(type="submit") {{_ 'comment'}}
|
||||
|
||||
template(name="comments")
|
||||
.comments
|
||||
each commentData in getComments
|
||||
+comment(commentData)
|
||||
|
||||
template(name="comment")
|
||||
.comment(class="{{#if parentId}}is-reply{{/if}}")
|
||||
+userAvatar(userId=userId)
|
||||
p.comment-desc
|
||||
span.comment-member
|
||||
+memberName(user=user)
|
||||
|
||||
if parentComment
|
||||
.comment-in-reply-to
|
||||
span.comment-in-reply-to-label {{_ 'comment-in-reply-to'}}:
|
||||
span.comment-in-reply-to-text
|
||||
+viewer
|
||||
= parentComment.text
|
||||
|
||||
+inlinedForm(classNames='js-edit-comment')
|
||||
+editor(autofocus=true)
|
||||
= text
|
||||
.edit-controls
|
||||
button.primary(type="submit" title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
|
||||
i.fa.fa-pencil-square-o(aria-hidden="true")
|
||||
a.js-close-inlined-form(title="{{_ 'close' }}")
|
||||
i.fa.fa-times-thin
|
||||
else
|
||||
.comment-text
|
||||
+viewer
|
||||
= text
|
||||
+commentReactions(reactions=reactions commentId=_id)
|
||||
span(title=createdAt).comment-meta {{ displayDate createdAt }}
|
||||
if currentUser.isBoardMember
|
||||
= ' - '
|
||||
a.js-reply-comment {{_ "comment-reply"}}
|
||||
if canEditThisComment
|
||||
+editOrDeleteComment
|
||||
|
||||
template(name="editOrDeleteComment")
|
||||
= ' - '
|
||||
a.js-open-inlined-form(title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
|
||||
i.fa.fa-pencil-square-o(aria-hidden="true")
|
||||
= ' - '
|
||||
a.js-delete-comment {{_ "delete"}}
|
||||
|
||||
template(name="deleteCommentPopup")
|
||||
p {{_ "comment-delete"}}
|
||||
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
|
||||
|
||||
template(name="commentReactions")
|
||||
.reactions
|
||||
each reaction in reactions
|
||||
span.reaction(class="{{#if isSelected reaction.userIds}}selected{{/if}}" data-codepoint="#{reaction.reactionCodepoint}" title="{{userNames reaction.userIds}}")
|
||||
span.reaction-codepoint !{reaction.reactionCodepoint}
|
||||
span.reaction-count #{reaction.userIds.length}
|
||||
if (currentUser.isBoardMember)
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
a.open-comment-reaction-popup(title="{{_ 'addReactionPopup-title'}}")
|
||||
i.fa.fa-smile-o(title="{{_ 'reaction' }}")
|
||||
i.fa.fa-plus(title="{{_ 'add' }}")
|
||||
|
||||
template(name="addReactionPopup")
|
||||
.reactions-popup
|
||||
each codepoint in codepoints
|
||||
span.add-comment-reaction(data-codepoint="#{codepoint}") !{codepoint}
|
||||
@@ -1,210 +1,58 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import CardComments, { canEditComment } from '/models/cardComments';
|
||||
import { UnsavedEdits } from '/client/lib/unsavedEdits';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import autosize from 'autosize';
|
||||
|
||||
const commentFormIsOpen = new ReactiveVar(false);
|
||||
// _id of the comment currently being replied to (threaded replies, issue #5907).
|
||||
// Empty string means a new top-level comment.
|
||||
const replyToCommentId = new ReactiveVar('');
|
||||
|
||||
// #5547: persist the comment draft (debounced) into UnsavedEdits while the user
|
||||
// types, so a comment in progress survives closing the card, clicking outside
|
||||
// it, or navigating away — the form prefills from {{getUnsavedValue
|
||||
// 'cardComment' ...}} on the next open. Historical note: the click-open flag
|
||||
// that armed the old escape-time draft save was removed in 2019 (3b3950369)
|
||||
// because the escape handler CLEARED the visible text; since then nothing saved
|
||||
// the draft at all, so any close of the card discarded the comment.
|
||||
let commentDraftTimer = null;
|
||||
let commentDraftPending = null; // { cardId, value }
|
||||
BlazeComponent.extendComponent({
|
||||
onDestroyed() {
|
||||
commentFormIsOpen.set(false);
|
||||
},
|
||||
|
||||
function commentDraftWrite({ cardId, value }) {
|
||||
if (!cardId) return;
|
||||
const draftKey = { fieldName: 'cardComment', docId: cardId };
|
||||
if ((value || '').trim()) {
|
||||
UnsavedEdits.set(draftKey, value);
|
||||
} else {
|
||||
UnsavedEdits.reset(draftKey);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCommentDraftSave(cardId, value) {
|
||||
commentDraftPending = { cardId, value };
|
||||
if (commentDraftTimer) clearTimeout(commentDraftTimer);
|
||||
commentDraftTimer = setTimeout(() => {
|
||||
commentDraftTimer = null;
|
||||
const pending = commentDraftPending;
|
||||
commentDraftPending = null;
|
||||
if (pending) commentDraftWrite(pending);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Run a pending (debounced) save immediately — e.g. the card is closing.
|
||||
function flushCommentDraftSave() {
|
||||
if (commentDraftTimer) {
|
||||
clearTimeout(commentDraftTimer);
|
||||
commentDraftTimer = null;
|
||||
}
|
||||
const pending = commentDraftPending;
|
||||
commentDraftPending = null;
|
||||
if (pending) commentDraftWrite(pending);
|
||||
}
|
||||
|
||||
// Drop any pending save — the comment was submitted, so there is no draft.
|
||||
function cancelCommentDraftSave() {
|
||||
if (commentDraftTimer) {
|
||||
clearTimeout(commentDraftTimer);
|
||||
commentDraftTimer = null;
|
||||
}
|
||||
commentDraftPending = null;
|
||||
}
|
||||
|
||||
Template.commentForm.onDestroyed(function () {
|
||||
// #5547: the card can close without the escape handler ever running (the X
|
||||
// button, a route change); make sure the last keystrokes reach the draft.
|
||||
flushCommentDraftSave();
|
||||
commentFormIsOpen.set(false);
|
||||
$('.note-popover').hide();
|
||||
});
|
||||
|
||||
Template.commentForm.helpers({
|
||||
commentFormIsOpen() {
|
||||
return commentFormIsOpen.get();
|
||||
},
|
||||
// The comment being replied to, if any, so the form can show an
|
||||
// "In reply to ..." banner with a way to cancel.
|
||||
replyToComment() {
|
||||
const id = replyToCommentId.get();
|
||||
return id ? ReactiveCache.getCardComment(id) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
Template.commentForm.events({
|
||||
// #5547: arm the escape handler below (it saves the draft and closes the
|
||||
// form); this flag had no setter since 2019, so the handler never ran.
|
||||
'focus .js-new-comment-input'() {
|
||||
commentFormIsOpen.set(true);
|
||||
getInput() {
|
||||
return this.$('.js-new-comment-input');
|
||||
},
|
||||
// #5547: keep the draft continuously saved (debounced) while typing.
|
||||
'input .js-new-comment-input'(evt) {
|
||||
scheduleCommentDraftSave(Utils.getCurrentCardId(), evt.currentTarget.value);
|
||||
},
|
||||
'submit .js-new-comment-form'(evt, tpl) {
|
||||
const input = tpl.$('.js-new-comment-input');
|
||||
const text = input.val().trim();
|
||||
const card = Template.currentData();
|
||||
let boardId = card.boardId;
|
||||
let cardId = card._id;
|
||||
if (card.isLinkedCard()) {
|
||||
boardId = ReactiveCache.getCard(card.linkedId).boardId;
|
||||
cardId = card.linkedId;
|
||||
} else if (card.isLinkedBoard()) {
|
||||
boardId = card.linkedId;
|
||||
}
|
||||
if (text) {
|
||||
const parentId = replyToCommentId.get();
|
||||
CardComments.insert({
|
||||
text,
|
||||
boardId,
|
||||
cardId,
|
||||
parentId: parentId || '',
|
||||
});
|
||||
// #5547: the comment is saved — cancel any debounced draft save still in
|
||||
// flight and remove the stored draft so it does not resurface next open.
|
||||
cancelCommentDraftSave();
|
||||
UnsavedEdits.reset({
|
||||
fieldName: 'cardComment',
|
||||
docId: Utils.getCurrentCardId(),
|
||||
});
|
||||
replyToCommentId.set('');
|
||||
resetCommentInput(input);
|
||||
Tracker.flush();
|
||||
autosize.update(input);
|
||||
input.trigger('submitted');
|
||||
}
|
||||
evt.preventDefault();
|
||||
},
|
||||
'click .js-cancel-reply'(evt) {
|
||||
evt.preventDefault();
|
||||
replyToCommentId.set('');
|
||||
},
|
||||
// Pressing Ctrl+Enter should submit the form
|
||||
'keydown form textarea'(evt, tpl) {
|
||||
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
|
||||
tpl.find('button[type=submit]').click();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Template.comments.helpers({
|
||||
getComments() {
|
||||
const data = Template.currentData();
|
||||
if (!data || typeof data.comments !== 'function') return [];
|
||||
return data.comments();
|
||||
events() {
|
||||
return [{
|
||||
'click .js-new-comment:not(.focus)'() {
|
||||
commentFormIsOpen.set(true);
|
||||
},
|
||||
'submit .js-new-comment-form'(evt) {
|
||||
const input = this.getInput();
|
||||
const text = input.val().trim();
|
||||
const card = this.currentData();
|
||||
let boardId = card.boardId;
|
||||
let cardId = card._id;
|
||||
if (card.isLinkedCard()) {
|
||||
boardId = Cards.findOne(card.linkedId).boardId;
|
||||
cardId = card.linkedId;
|
||||
}
|
||||
if (text) {
|
||||
CardComments.insert({
|
||||
text,
|
||||
boardId,
|
||||
cardId,
|
||||
});
|
||||
resetCommentInput(input);
|
||||
Tracker.flush();
|
||||
autosize.update(input);
|
||||
}
|
||||
evt.preventDefault();
|
||||
},
|
||||
// Pressing Ctrl+Enter should submit the form
|
||||
'keydown form textarea'(evt) {
|
||||
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
|
||||
this.find('button[type=submit]').click();
|
||||
}
|
||||
},
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
Template.comment.helpers({
|
||||
// Whether the current user may edit/delete this comment, honouring the
|
||||
// board's restrictCommentEditing setting (issue #5906). Mirrors the
|
||||
// server-side enforcement so the UI hides buttons the server would reject.
|
||||
canEditThisComment() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return false;
|
||||
const isAuthor = user._id === this.userId;
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
const isBoardAdmin = !!board && board.hasAdmin(user._id);
|
||||
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
|
||||
return canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing });
|
||||
},
|
||||
// The parent comment this one replies to, or undefined.
|
||||
parentComment() {
|
||||
return this.parentId ? ReactiveCache.getCardComment(this.parentId) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
Template.comment.events({
|
||||
'click .js-reply-comment'(evt) {
|
||||
evt.preventDefault();
|
||||
replyToCommentId.set(this._id);
|
||||
// Open and focus the new-comment form.
|
||||
const input = $('.js-new-comment-input');
|
||||
input.click();
|
||||
Tracker.afterFlush(() => {
|
||||
$('.js-new-comment-input').focus();
|
||||
});
|
||||
},
|
||||
'click .js-delete-comment': Popup.afterConfirm('deleteComment', function () {
|
||||
const commentId = this._id;
|
||||
// #3252: only remove if the doc is still in the local cache. Under heavy
|
||||
// archive/delete churn the comment can already be evicted from Minimongo,
|
||||
// and CardComments.remove() of a missing _id throws "Removed nonexistent
|
||||
// document" on the client.
|
||||
if (commentId && CardComments.findOne(commentId)) {
|
||||
CardComments.remove(commentId);
|
||||
}
|
||||
Popup.back();
|
||||
}),
|
||||
'submit .js-edit-comment'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const textarea = tpl.find('.js-edit-comment textarea,input[type=text]');
|
||||
const commentText = textarea && textarea.value ? textarea.value.trim() : '';
|
||||
const commentId = tpl.data._id;
|
||||
if (commentText) {
|
||||
CardComments.update(commentId, {
|
||||
$set: {
|
||||
text: commentText,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}).register('commentForm');
|
||||
|
||||
// XXX This should be a static method of the `commentForm` component
|
||||
function resetCommentInput(input) {
|
||||
input.val(''); // without manually trigger, input event won't be fired
|
||||
input.val('');
|
||||
input.blur();
|
||||
commentFormIsOpen.set(false);
|
||||
}
|
||||
@@ -215,39 +63,28 @@ function resetCommentInput(input) {
|
||||
// Tracker.autorun to register the component dependencies, and re-run when these
|
||||
// dependencies are invalidated. A better component API would remove this hack.
|
||||
Tracker.autorun(() => {
|
||||
Utils.getCurrentCardId();
|
||||
Session.get('currentCard');
|
||||
Tracker.afterFlush(() => {
|
||||
autosize.update($('.js-new-comment-input'));
|
||||
});
|
||||
});
|
||||
|
||||
EscapeActions.register(
|
||||
'inlinedForm',
|
||||
EscapeActions.register('inlinedForm',
|
||||
() => {
|
||||
const draftKey = {
|
||||
fieldName: 'cardComment',
|
||||
docId: Utils.getCurrentCardId(),
|
||||
docId: Session.get('currentCard'),
|
||||
};
|
||||
const commentInput = $('.js-new-comment-input');
|
||||
const draft = (commentInput.val() || '').trim();
|
||||
cancelCommentDraftSave();
|
||||
const draft = commentInput.val().trim();
|
||||
if (draft) {
|
||||
UnsavedEdits.set(draftKey, draft);
|
||||
} else {
|
||||
UnsavedEdits.reset(draftKey);
|
||||
}
|
||||
// #5547: do NOT clear the input — clearing it on every click outside the
|
||||
// comment box is exactly the "comment text disappearing" bug that got this
|
||||
// handler disarmed in 2019 (3b3950369). The draft is saved above; if the
|
||||
// card stays open, the text stays visible; if the card closes, the next
|
||||
// open prefills the form from the draft.
|
||||
commentInput.blur();
|
||||
commentFormIsOpen.set(false);
|
||||
resetCommentInput(commentInput);
|
||||
},
|
||||
() => {
|
||||
return commentFormIsOpen.get();
|
||||
},
|
||||
{
|
||||
() => { return commentFormIsOpen.get(); }, {
|
||||
noClickEscapeOn: '.js-new-comment',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
@import 'nib'
|
||||
|
||||
.new-comment
|
||||
position: relative
|
||||
margin: 0 0 20px 38px
|
||||
|
||||
.member
|
||||
opacity: .7
|
||||
position: absolute
|
||||
top: 1px
|
||||
left: -38px
|
||||
|
||||
&.is-open
|
||||
.member
|
||||
opacity: 1
|
||||
|
||||
.helper
|
||||
display: inline-block
|
||||
|
||||
textarea
|
||||
min-height: 100px
|
||||
color: #4d4d4d
|
||||
cursor: auto
|
||||
overflow: hidden
|
||||
word-wrap: break-word
|
||||
|
||||
.too-long
|
||||
margin-top: 8px
|
||||
|
||||
textarea
|
||||
background-color: #fff
|
||||
border: 0
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .23)
|
||||
color: #8c8c8c
|
||||
height: 36px
|
||||
margin: 4px 4px 6px 0
|
||||
padding: 9px 11px
|
||||
width: 100%
|
||||
|
||||
&:hover,
|
||||
&:is-open
|
||||
background-color: #fff
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .33)
|
||||
border: 0
|
||||
cursor: pointer
|
||||
|
||||
&:is-open
|
||||
cursor: auto
|
||||
@@ -1,184 +0,0 @@
|
||||
/* Board Conversion Progress Styles */
|
||||
.board-conversion-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.board-conversion-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.board-conversion-modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.board-conversion-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board-conversion-header h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.board-conversion-header h3 i {
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.board-conversion-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
|
||||
.board-conversion-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.conversion-progress {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2196F3, #21CBF3);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #2196F3;
|
||||
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
|
||||
.conversion-status {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
|
||||
.conversion-status i {
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.conversion-time {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
|
||||
background-color: #f5f5f5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.conversion-time i {
|
||||
margin-inline-end: 6px;
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
.board-conversion-footer {
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.conversion-info {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.conversion-info i {
|
||||
margin-inline-end: 6px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 600px) {
|
||||
.board-conversion-modal {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.board-conversion-header,
|
||||
.board-conversion-content,
|
||||
.board-conversion-footer {
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
.board-conversion-header h3 {
|
||||
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
template(name="boardConversionProgress")
|
||||
.board-conversion-overlay(class="{{#if isConverting}}active{{/if}}")
|
||||
.board-conversion-modal
|
||||
.board-conversion-header
|
||||
h3
|
||||
i.fa.fa-cog
|
||||
| {{_ 'converting-board'}}
|
||||
p {{_ 'converting-board-description'}}
|
||||
|
||||
.board-conversion-content
|
||||
.conversion-progress
|
||||
.progress-bar
|
||||
.progress-fill(style="width: {{conversionProgress}}%")
|
||||
.progress-text {{conversionProgress}}%
|
||||
|
||||
.conversion-status
|
||||
i.fa.fa-cog
|
||||
| {{conversionStatus}}
|
||||
|
||||
.conversion-time(style="{{#unless conversionEstimatedTime}}display: none;{{/unless}}")
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'estimated-time-remaining'}}: {{conversionEstimatedTime}}
|
||||
|
||||
.board-conversion-footer
|
||||
.conversion-info
|
||||
i.fa.fa-info-circle
|
||||
| {{_ 'conversion-info-text'}}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Template } from 'meteor/templating';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import {
|
||||
boardConverter,
|
||||
isConverting,
|
||||
conversionProgress,
|
||||
conversionStatus,
|
||||
conversionEstimatedTime
|
||||
} from '/client/lib/boardConverter';
|
||||
|
||||
Template.boardConversionProgress.helpers({
|
||||
isConverting() {
|
||||
return isConverting.get();
|
||||
},
|
||||
|
||||
conversionProgress() {
|
||||
return conversionProgress.get();
|
||||
},
|
||||
|
||||
conversionStatus() {
|
||||
return conversionStatus.get();
|
||||
},
|
||||
|
||||
conversionEstimatedTime() {
|
||||
return conversionEstimatedTime.get();
|
||||
}
|
||||
});
|
||||
|
||||
Template.boardConversionProgress.onCreated(function() {
|
||||
// Subscribe to conversion state changes
|
||||
this.autorun(() => {
|
||||
isConverting.get();
|
||||
conversionProgress.get();
|
||||
conversionStatus.get();
|
||||
conversionEstimatedTime.get();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
//- The All Boards right sidebar.
|
||||
//-
|
||||
//- The board sidebar (client/components/sidebar/) is built around a board - its
|
||||
//- members, labels, activities and settings - and All Boards has no board, so
|
||||
//- this is its own. What it borrows is the SHELL: the same
|
||||
//- `.board-sidebar.sidebar` classes, the same `.sidebar-actions` row with the ✕,
|
||||
//- the same `.sidebar-content` with a title and a back arrow above the view. So
|
||||
//- it looks and behaves like the one on a board without pretending to be it.
|
||||
//-
|
||||
//- docs/Features/Page/Search.md, docs/Features/Page/Multi-Selection.md
|
||||
|
||||
template(name="allBoardsSidebar")
|
||||
//- The theme class goes on an ANCESTOR of `.sidebar`, never on it: every
|
||||
themed sidebar rule is a DESCENDANT selector -
|
||||
`.board-color-belize .sidebar .sidebar-content .sidebar-btn` - so a class on
|
||||
the sidebar element itself matches nothing at all, which is how the first
|
||||
attempt at this left the buttons white on light grey anyway. On a board the
|
||||
class sits on a container around the sidebar; this is that container.
|
||||
|
||||
It wraps ONLY the sidebar, so the theme cannot bleed into the board icons
|
||||
behind it. It is out of flow (the sidebar inside is fixed), so it costs no
|
||||
layout.
|
||||
.all-boards-sidebar-theme(class="{{themeClass}}")
|
||||
.board-sidebar.sidebar.all-boards-sidebar(class="{{#if isSidebarOpen}}is-open{{/if}}")
|
||||
.sidebar-actions
|
||||
a.sidebar-xmark.js-close-all-boards-sidebar(aria-label="{{_ 'close'}}") ✕
|
||||
.sidebar-content.js-all-boards-sidebar-content
|
||||
//- A title and a back arrow on every view EXCEPT home, which is where
|
||||
back would go - exactly as the board sidebar does it.
|
||||
if sidebarViewTitle
|
||||
h2
|
||||
a.fa.fa-arrow-left.js-all-boards-sidebar-home(aria-label="{{_ 'go-back'}}")
|
||||
= sidebarViewTitle
|
||||
if isSidebarOpen
|
||||
+Template.dynamic(template=sidebarViewTemplate)
|
||||
|
||||
//- What the hamburger opens: the page's own menu, the counterpart of
|
||||
//- `homeSidebar` on a board.
|
||||
template(name="allBoardsHomeSidebar")
|
||||
//- Sort, which was a button in this page's second header bar. The VIEW menu
|
||||
was too, and it is in the first top header bar instead - a view menu says
|
||||
what you are looking at, which belongs beside the page's name rather than
|
||||
behind a panel you have to open. Search and Multi-Selection were in that bar
|
||||
as well and are NOT repeated here: this view has offered both as rows since
|
||||
it was written. docs/Features/Page/All-Boards.md
|
||||
a.sidebar-btn.js-open-boards-sort(class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
|
||||
i.fa.fa-sort
|
||||
span {{_ 'sort-boards'}}
|
||||
hr
|
||||
ul.sidebar-list
|
||||
li
|
||||
a.js-all-boards-sidebar-search
|
||||
i.fa.fa-search
|
||||
span.sidebar-list-item-description {{_ 'search-boards'}}
|
||||
if canMultiSelectBoards
|
||||
li
|
||||
a.js-all-boards-sidebar-multiselection
|
||||
i.fa.fa-check-square-o
|
||||
span.sidebar-list-item-description {{_ 'multi-selection'}}
|
||||
hr
|
||||
//- Archived boards had a handler in the header bar and no button to reach it.
|
||||
a.sidebar-btn.js-open-archived-board
|
||||
i.fa.fa-archive
|
||||
span {{_ 'archived-boards'}}
|
||||
|
||||
template(name="allBoardsSearchSidebar")
|
||||
//- The SAME term the page filters by, so the board list behind the sidebar
|
||||
narrows as you type - which is what the search field in the header bar did
|
||||
before this became a sidebar view.
|
||||
input.js-board-search-input(
|
||||
type="text"
|
||||
aria-label="{{_ 'search-boards'}}"
|
||||
value="{{boardSearch}}"
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
dir="auto")
|
||||
if boardSearch
|
||||
a.sidebar-btn.js-board-search-clear
|
||||
i.fa.fa-times
|
||||
span {{_ 'filter-clear'}}
|
||||
|
||||
//- The actions on a selection, as sidebar rows - the same place a board puts
|
||||
//- them. They were four icons crowded into the header bar; here each one has
|
||||
//- its name beside it.
|
||||
template(name="allBoardsMultiSelectionSidebar")
|
||||
if isArchiveSelection
|
||||
if canPermanentlyDeleteArchivedBoards
|
||||
p.sidebar-multiselection-hint {{_ 'multi-selection-active'}}
|
||||
button.sidebar-btn.negate.wide.js-delete-selected-boards(type="button")
|
||||
i.fa.fa-trash
|
||||
span {{_ 'delete'}}
|
||||
else
|
||||
p.sidebar-multiselection-hint {{_ 'archive-permanent-delete-disabled-hint'}}
|
||||
else
|
||||
p.sidebar-multiselection-hint {{_ 'multi-selection-active'}}
|
||||
a.sidebar-btn.js-star-selected
|
||||
i.fa.fa-star
|
||||
span {{selectedStarTitle}}
|
||||
a.sidebar-btn.js-home-selected
|
||||
i.fa.fa-home
|
||||
if isHomeSelection
|
||||
span {{_ 'unset-selected-home'}}
|
||||
else
|
||||
span {{_ 'set-selected-home'}}
|
||||
unless isHomeSelection
|
||||
hr
|
||||
a.sidebar-btn.js-archive-selected-boards
|
||||
i.fa.fa-archive
|
||||
span {{_ 'archive-board'}}
|
||||
a.sidebar-btn.js-duplicate-selected-boards
|
||||
i.fa.fa-clipboard
|
||||
span {{_ 'duplicate-board'}}
|
||||
hr
|
||||
a.sidebar-btn.js-multiselection-reset
|
||||
i.fa.fa-times-thin
|
||||
span {{_ 'multi-selection-off'}}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
const {
|
||||
allBoardsPath,
|
||||
SECTION_HOME,
|
||||
SECTION_ARCHIVE,
|
||||
} = require('/models/lib/allBoardsUrls');
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
|
||||
import { allBoardsSearchVar, allBoardsMenuVar } from '/client/lib/allBoardsView';
|
||||
import {
|
||||
allBoardsSidebarView,
|
||||
isAllBoardsSidebarOpen,
|
||||
openAllBoardsSidebar,
|
||||
closeAllBoardsSidebar,
|
||||
} from '/client/lib/allBoardsSidebar';
|
||||
import {
|
||||
SIDEBAR_HOME,
|
||||
SIDEBAR_SEARCH,
|
||||
SIDEBAR_MULTISELECTION,
|
||||
sidebarViewTitleKey,
|
||||
sidebarViewTemplate,
|
||||
} from '/models/lib/allBoardsSidebar';
|
||||
import { selectedStarTitleKey, selectedStarAction } from '/models/lib/selectedStars';
|
||||
|
||||
// The All Boards right sidebar. Its shell mirrors the board sidebar's; what it
|
||||
// shows is this page's own. docs/Features/Page/Search.md,
|
||||
// docs/Features/Page/Multi-Selection.md
|
||||
|
||||
Template.allBoardsSidebar.helpers({
|
||||
isSidebarOpen() {
|
||||
return isAllBoardsSidebarOpen();
|
||||
},
|
||||
// The theme the panel is painted with.
|
||||
//
|
||||
// `.sidebar .sidebar-content .sidebar-btn` is a light grey box whose text is
|
||||
// WHITE; what makes that readable on a board is a `.board-color-*` ancestor
|
||||
// replacing the grey with a themed colour. This page has no board, so without
|
||||
// a class here every button was white on light grey - unreadable, which is
|
||||
// exactly what it looked like.
|
||||
//
|
||||
// `board-color-belize` is the established default for a themed thing outside
|
||||
// a board: client/components/main/globalSearch.js falls back to the same one.
|
||||
themeClass() {
|
||||
return 'board-color-belize';
|
||||
},
|
||||
sidebarViewTemplate() {
|
||||
return sidebarViewTemplate(allBoardsSidebarView());
|
||||
},
|
||||
// Home has no title and no back arrow: there is nothing behind it.
|
||||
sidebarViewTitle() {
|
||||
const key = sidebarViewTitleKey(allBoardsSidebarView());
|
||||
return key ? TAPi18n.__(key) : null;
|
||||
},
|
||||
});
|
||||
|
||||
Template.allBoardsSidebar.events({
|
||||
'click .js-close-all-boards-sidebar'(evt) {
|
||||
evt.preventDefault();
|
||||
// Selection mode deliberately keeps this sidebar visible, so its X must
|
||||
// turn the mode off before closeAllBoardsSidebar() will accept the close.
|
||||
if (BoardMultiSelection.isActive()) BoardMultiSelection.disable();
|
||||
closeAllBoardsSidebar();
|
||||
},
|
||||
'click .js-all-boards-sidebar-home'(evt) {
|
||||
evt.preventDefault();
|
||||
openAllBoardsSidebar(SIDEBAR_HOME);
|
||||
},
|
||||
});
|
||||
|
||||
Template.allBoardsHomeSidebar.helpers({
|
||||
canMultiSelectBoards() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser && !currentUser.isCommentOnly();
|
||||
},
|
||||
});
|
||||
|
||||
Template.allBoardsHomeSidebar.events({
|
||||
'click .js-all-boards-sidebar-search'(evt) {
|
||||
evt.preventDefault();
|
||||
openAllBoardsSidebar(SIDEBAR_SEARCH);
|
||||
},
|
||||
'click .js-all-boards-sidebar-multiselection'(evt) {
|
||||
evt.preventDefault();
|
||||
BoardMultiSelection.activate();
|
||||
openAllBoardsSidebar(SIDEBAR_MULTISELECTION);
|
||||
},
|
||||
// Boards in Archive is a page. This row had no handler at all: the one it
|
||||
// relied on lived in the header bar's events map and went when that map was
|
||||
// rewritten, so the button did nothing.
|
||||
'click .js-open-archived-board'(evt) {
|
||||
evt.preventDefault();
|
||||
closeAllBoardsSidebar();
|
||||
// The SECTION of this page, not the full-width page it replaced.
|
||||
// docs/Features/Page/Archive.md
|
||||
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
|
||||
},
|
||||
});
|
||||
|
||||
Template.allBoardsSearchSidebar.helpers({
|
||||
boardSearch() {
|
||||
return allBoardsSearchVar.get();
|
||||
},
|
||||
});
|
||||
|
||||
// The search term is the page's, not this template's: the board list behind the
|
||||
// sidebar filters as you type, which is what the header bar's search field did
|
||||
// before it became a sidebar view.
|
||||
Template.allBoardsSearchSidebar.events({
|
||||
'input .js-board-search-input'(evt) {
|
||||
allBoardsSearchVar.set(evt.currentTarget.value);
|
||||
},
|
||||
'keydown .js-board-search-input'(evt) {
|
||||
if (evt.key === 'Escape') {
|
||||
// Clear first, close second: Escape on a search box that has text in it
|
||||
// means "undo the search", and only an already-empty box falls through to
|
||||
// the sidebar's own Escape.
|
||||
if (allBoardsSearchVar.get()) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
allBoardsSearchVar.set('');
|
||||
evt.currentTarget.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
'click .js-board-search-clear'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
allBoardsSearchVar.set('');
|
||||
const input = tpl.find('.js-board-search-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// What the "Selected: ★" row would do if clicked right now, and to which
|
||||
// boards. The same function the header bar used - see models/lib/selectedStars.
|
||||
function currentSelectedStarAction() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return selectedStarAction(BoardMultiSelection.getSelectedBoardIds(), id =>
|
||||
Boolean(user && user.hasStarred(id)),
|
||||
);
|
||||
}
|
||||
|
||||
Template.allBoardsMultiSelectionSidebar.helpers({
|
||||
selectedStarTitle() {
|
||||
return TAPi18n.__(selectedStarTitleKey(currentSelectedStarAction().action));
|
||||
},
|
||||
isArchiveSelection() {
|
||||
return allBoardsMenuVar.get() === SECTION_ARCHIVE;
|
||||
},
|
||||
isHomeSelection() {
|
||||
return allBoardsMenuVar.get() === SECTION_HOME;
|
||||
},
|
||||
canPermanentlyDeleteArchivedBoards() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const setting = ReactiveCache.getCurrentSetting();
|
||||
return allBoardsMenuVar.get() === SECTION_ARCHIVE
|
||||
&& user?.isAdmin === true
|
||||
&& !!(setting && setting.enablePermanentDelete);
|
||||
},
|
||||
});
|
||||
|
||||
function selectedBoardIdsOrWarn() {
|
||||
const ids = BoardMultiSelection.getSelectedBoardIds();
|
||||
if (!ids.length) {
|
||||
alert(TAPi18n.__('no-boards-selected'));
|
||||
return null;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function selectedHomeBoardIdOrWarn() {
|
||||
const ids = BoardMultiSelection.getSelectedBoardIds();
|
||||
if (ids.length !== 1) {
|
||||
alert(TAPi18n.__('select-only-one-board'));
|
||||
return null;
|
||||
}
|
||||
return ids[0];
|
||||
}
|
||||
|
||||
Template.allBoardsMultiSelectionSidebar.events({
|
||||
'click .js-delete-selected-boards'(evt) {
|
||||
evt.preventDefault();
|
||||
const ids = selectedBoardIdsOrWarn();
|
||||
if (!ids || !confirm(TAPi18n.__('delete-board-confirm-popup'))) return;
|
||||
Meteor.call('permanentlyDeleteArchivedBoards', ids, (err) => {
|
||||
if (err) {
|
||||
alert(err.reason || err.message || 'Failed to permanently delete boards');
|
||||
return;
|
||||
}
|
||||
BoardMultiSelection.reset();
|
||||
});
|
||||
},
|
||||
// Only the boards that must CHANGE are toggled: toggleBoardStar flips one
|
||||
// board, so calling it for an already-starred one in the mixed case would
|
||||
// un-star it.
|
||||
'click .js-star-selected'(evt) {
|
||||
evt.preventDefault();
|
||||
const ids = selectedBoardIdsOrWarn();
|
||||
if (!ids) return;
|
||||
currentSelectedStarAction().boardIds.forEach((id) => {
|
||||
Meteor.call('toggleBoardStar', id);
|
||||
});
|
||||
},
|
||||
'click .js-home-selected'(evt) {
|
||||
evt.preventDefault();
|
||||
const boardId = selectedHomeBoardIdOrWarn();
|
||||
if (!boardId) return;
|
||||
Meteor.call('toggleDefaultBoard', boardId);
|
||||
},
|
||||
'click .js-archive-selected-boards'(evt) {
|
||||
evt.preventDefault();
|
||||
const selectedBoards = selectedBoardIdsOrWarn();
|
||||
if (!selectedBoards) return;
|
||||
if (
|
||||
confirm(TAPi18n.__('archive-board-confirm'))
|
||||
) {
|
||||
selectedBoards.forEach((boardId) => {
|
||||
Meteor.call('archiveBoard', boardId, (err) => {
|
||||
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
|
||||
});
|
||||
});
|
||||
BoardMultiSelection.reset();
|
||||
}
|
||||
},
|
||||
'click .js-duplicate-selected-boards'(evt) {
|
||||
evt.preventDefault();
|
||||
const selectedBoards = selectedBoardIdsOrWarn();
|
||||
if (!selectedBoards) return;
|
||||
if (
|
||||
confirm(TAPi18n.__('duplicate-board-confirm'))
|
||||
) {
|
||||
selectedBoards.forEach((boardId) => {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (board) {
|
||||
Meteor.call(
|
||||
'copyBoard',
|
||||
boardId,
|
||||
{
|
||||
sort: ReactiveCache.getBoards({ archived: false }).length,
|
||||
type: 'board',
|
||||
title: board.title,
|
||||
},
|
||||
(err) => {
|
||||
if (err) console.error(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
BoardMultiSelection.reset();
|
||||
}
|
||||
},
|
||||
'click .js-multiselection-reset'(evt) {
|
||||
evt.preventDefault();
|
||||
BoardMultiSelection.disable();
|
||||
closeAllBoardsSidebar();
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,22 @@
|
||||
template(name="archivedBoards")
|
||||
.table-page-controls
|
||||
input.js-archived-boards-search(type="search" placeholder="{{_ 'search'}}")
|
||||
.table-page-pagination
|
||||
button.js-archived-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.table-page-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-archived-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
h2
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archived-boards'}}
|
||||
|
||||
ul.archived-lists
|
||||
each archivedBoards
|
||||
li.archived-lists-item
|
||||
div.board-header-btns
|
||||
button.primary.board-header-btn.js-restore-board(type="submit")
|
||||
button.board-header-btn.js-delete-board
|
||||
i.fa.fa-trash-o
|
||||
| {{_ 'delete-board'}}
|
||||
button.board-header-btn.js-restore-board
|
||||
i.fa.fa-undo
|
||||
| {{_ 'restore-board'}}
|
||||
= title
|
||||
span {{ displayDate archivedAt 'LLL' }}
|
||||
else
|
||||
li.no-items-message {{_ 'no-archived-boards'}}
|
||||
|
||||
template(name="boardDeletePopup")
|
||||
p {{_ 'delete-board-confirm-popup'}}
|
||||
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
|
||||
@@ -1,82 +1,39 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Boards from '/models/boards';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { TABLE_PAGE_ROWS_PER_PAGE } from '/models/lib/tablePage';
|
||||
|
||||
// The app's one rows-per-page (docs/Features/Page/Table.md): the archive pages
|
||||
// like every other paginated page, whatever pager it draws.
|
||||
const ARCHIVED_BOARDS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
|
||||
Template.archivedBoards.onCreated(function () {
|
||||
// Server-side pagination: only the current page of archived boards is ever
|
||||
// published/loaded, so a long-lived instance's archive stays fast.
|
||||
this.page = new ReactiveVar(1);
|
||||
this.total = new ReactiveVar(0);
|
||||
this.searchQuery = new ReactiveVar('');
|
||||
this.autorun(() => {
|
||||
const searchTerm = this.searchQuery.get();
|
||||
const skip = (this.page.get() - 1) * ARCHIVED_BOARDS_PER_PAGE;
|
||||
this.subscribe('archivedBoards', searchTerm, ARCHIVED_BOARDS_PER_PAGE, skip);
|
||||
Meteor.call('getArchivedBoardsCount', searchTerm, (err, count) => {
|
||||
if (!err) this.total.set(count || 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Template.archivedBoards.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
BlazeComponent.extendComponent({
|
||||
onCreated() {
|
||||
this.subscribe('archivedBoards');
|
||||
},
|
||||
|
||||
archivedBoards() {
|
||||
// Only the current page is in minimongo (the publication is limited).
|
||||
return ReactiveCache.getBoards(
|
||||
{ archived: true },
|
||||
{ sort: { archivedAt: -1, modifiedAt: -1 } },
|
||||
);
|
||||
return Boards.find({ archived: true }, {
|
||||
sort: ['title'],
|
||||
});
|
||||
},
|
||||
|
||||
currentPage() { return Template.instance().page.get(); },
|
||||
totalPages() { return Math.max(1, Math.ceil((Template.instance().total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE)); },
|
||||
hasPrevPage() { return Template.instance().page.get() > 1; },
|
||||
hasNextPage() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.page.get() < Math.max(1, Math.ceil((tpl.total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE));
|
||||
events() {
|
||||
return [{
|
||||
'click .js-restore-board'() {
|
||||
// TODO : Make isSandstorm variable global
|
||||
const isSandstorm = Meteor.settings && Meteor.settings.public &&
|
||||
Meteor.settings.public.sandstorm;
|
||||
if (isSandstorm && Session.get('currentBoard')) {
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
currentBoard.archive();
|
||||
}
|
||||
const board = this.currentData();
|
||||
board.restore();
|
||||
Utils.goBoardId(board._id);
|
||||
},
|
||||
'click .js-delete-board': Popup.afterConfirm('boardDelete', function() {
|
||||
Popup.close();
|
||||
const isSandstorm = Meteor.settings && Meteor.settings.public &&
|
||||
Meteor.settings.public.sandstorm;
|
||||
if (isSandstorm && Session.get('currentBoard')) {
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
Boards.remove(currentBoard._id);
|
||||
}
|
||||
Boards.remove(this._id);
|
||||
FlowRouter.go('home');
|
||||
}),
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
Template.archivedBoards.events({
|
||||
'keydown .js-archived-boards-search'(event, tpl) {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault();
|
||||
tpl.searchQuery.set(tpl.$('.js-archived-boards-search').val() || '');
|
||||
tpl.page.set(1);
|
||||
}
|
||||
},
|
||||
'click .js-archived-boards-prev-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const current = tpl.page.get();
|
||||
if (current > 1) tpl.page.set(current - 1);
|
||||
},
|
||||
'click .js-archived-boards-next-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const total = tpl.total.get() || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / ARCHIVED_BOARDS_PER_PAGE));
|
||||
const current = tpl.page.get();
|
||||
if (current < totalPages) tpl.page.set(current + 1);
|
||||
},
|
||||
async 'click .js-restore-board'() {
|
||||
// TODO : Make isSandstorm variable global
|
||||
const isSandstorm =
|
||||
Meteor.settings &&
|
||||
Meteor.settings.public &&
|
||||
Meteor.settings.public.sandstorm;
|
||||
if (isSandstorm && Utils.getCurrentBoardId()) {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
await currentBoard.archive();
|
||||
}
|
||||
const board = this;
|
||||
await board.restore();
|
||||
Utils.goBoardId(board._id);
|
||||
},
|
||||
});
|
||||
}).register('archivedBoards');
|
||||
@@ -1,606 +0,0 @@
|
||||
.board-wrapper {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* When zoom is 50% or lower, ensure full width like content */
|
||||
.board-wrapper[style*="transform: scale(0.5)"] {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.board-wrapper[style*="transform: scale(0.4)"] {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.board-wrapper[style*="transform: scale(0.3)"] {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.board-wrapper .board-canvas {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transition: margin 0.1s;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure horizontal scrollbar is visible for high zoom levels */
|
||||
.board-wrapper .board-canvas[style*="overflow-x: auto"] {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Style the scrollbar for better visibility */
|
||||
.board-wrapper .board-canvas::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.board-wrapper .board-canvas::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.board-wrapper .board-canvas::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.board-wrapper .board-canvas::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Ensure content area can scroll horizontally for high zoom levels */
|
||||
#content[style*="overflow-x: auto"] {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Style content area scrollbar */
|
||||
#content::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
#content::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#content::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#content::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Ensure scrollbars are always visible for high zoom levels */
|
||||
#content[style*="overflow-x: auto"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#content[style*="overflow-x: auto"]::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
/* Force vertical scrollbar to always be visible */
|
||||
#content[style*="overflow-y: auto"] {
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
/* Mobile - keep #content text at normal size (reverted #6426 oversizing) */
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 800px),
|
||||
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
|
||||
screen and (max-width: 800px) and (orientation: portrait),
|
||||
screen and (max-width: 800px) and (orientation: landscape) {
|
||||
#content {
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
/* Keep all text elements at normal size */
|
||||
#content h1, #content h2, #content h3, #content h4, #content h5, #content h6,
|
||||
#content p, #content span, #content div, #content a, #content button,
|
||||
#content .minicard, #content .list-header-name, #content .board-header-btn,
|
||||
#content .card-title, #content .card-details, #content .card-description,
|
||||
#content .swimlane-header, #content .list-title, #content .card-text,
|
||||
#content .member, #content .member-name, #content .member-initials,
|
||||
#content .checklist-item, #content .checklist-title, #content .comment,
|
||||
#content .activity, #content .activity-text, #content .activity-time,
|
||||
#content .board-title, #content .board-description, #content .list-name,
|
||||
#content .card-text, #content .card-title, #content .card-description,
|
||||
#content .swimlane-title, #content .swimlane-description,
|
||||
#content .board-header-title, #content .board-header-description,
|
||||
#content .card-detail-title, #content .card-detail-description,
|
||||
#content .list-header-title, #content .list-header-description,
|
||||
#content .swimlane-header-title, #content .swimlane-header-description,
|
||||
#content .minicard-title, #content .minicard-description,
|
||||
#content .card-comment, #content .card-comment-text,
|
||||
#content .checklist-item-text, #content .checklist-item-title,
|
||||
#content .activity-item, #content .activity-item-text,
|
||||
#content .board-member, #content .board-member-name,
|
||||
#content .team-member, #content .team-member-name,
|
||||
#content .org-member, #content .org-member-name,
|
||||
#content .template-member, #content .template-member-name,
|
||||
#content .user-name, #content .user-email, #content .user-role,
|
||||
#content .setting-title, #content .setting-description,
|
||||
#content .popup-title, #content .popup-description,
|
||||
#content .modal-title, #content .modal-description,
|
||||
#content .notification-title, #content .notification-text,
|
||||
#content .announcement-title, #content .announcement-text,
|
||||
#content .offline-warning-title, #content .offline-warning-text,
|
||||
#content .error-title, #content .error-text,
|
||||
#content .success-title, #content .success-text,
|
||||
#content .info-title, #content .info-text,
|
||||
#content .warning-title, #content .warning-text {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
/* Keep icons the same size (don't scale them) */
|
||||
#content .fa, #content .icon, #content i {
|
||||
font-size: 1em !important; /* Keep original icon size */
|
||||
}
|
||||
|
||||
/* Reset specific icon sizes to prevent double scaling */
|
||||
#content .fa-home, #content .fa-bars, #content .fa-search,
|
||||
#content .fa-bell, #content .fa-user, #content .fa-cog,
|
||||
#content .fa-plus, #content .fa-minus, #content .fa-edit,
|
||||
#content .fa-trash, #content .fa-save, #content .fa-cancel,
|
||||
#content .fa-arrow-left, #content .fa-arrow-right,
|
||||
#content .fa-arrow-up, #content .fa-arrow-down,
|
||||
#content .fa-check, #content .fa-times, #content .fa-close,
|
||||
#content .fa-star, #content .fa-heart, #content .fa-thumbs-up,
|
||||
#content .fa-thumbs-down, #content .fa-comment, #content .fa-reply,
|
||||
#content .fa-share, #content .fa-download, #content .fa-upload,
|
||||
#content .fa-copy, #content .fa-paste, #content .fa-cut,
|
||||
#content .fa-undo, #content .fa-redo, #content .fa-refresh,
|
||||
#content .fa-sync, #content .fa-spinner, #content .fa-loading,
|
||||
#content .fa-info, #content .fa-question, #content .fa-exclamation,
|
||||
#content .fa-warning, #content .fa-error, #content .fa-success,
|
||||
#content .fa-check-circle, #content .fa-times-circle,
|
||||
#content .fa-exclamation-circle, #content .fa-question-circle,
|
||||
#content .fa-info-circle, #content .fa-warning-circle,
|
||||
#content .fa-error-circle, #content .fa-success-circle {
|
||||
font-size: 1em !important; /* Keep original icon size */
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for iPhone devices using JavaScript detection */
|
||||
.iphone-device #content {
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
.iphone-device #content h1, .iphone-device #content h2, .iphone-device #content h3, .iphone-device #content h4, .iphone-device #content h5, .iphone-device #content h6,
|
||||
.iphone-device #content p, .iphone-device #content span, .iphone-device #content div, .iphone-device #content a, .iphone-device #content button,
|
||||
.iphone-device #content .minicard, .iphone-device #content .list-header-name, .iphone-device #content .board-header-btn,
|
||||
.iphone-device #content .card-title, .iphone-device #content .card-details, .iphone-device #content .card-description,
|
||||
.iphone-device #content .swimlane-header, .iphone-device #content .list-title, .iphone-device #content .card-text,
|
||||
.iphone-device #content .member, .iphone-device #content .member-name, .iphone-device #content .member-initials,
|
||||
.iphone-device #content .checklist-item, .iphone-device #content .checklist-title, .iphone-device #content .comment,
|
||||
.iphone-device #content .activity, .iphone-device #content .activity-text, .iphone-device #content .activity-time,
|
||||
.iphone-device #content .board-title, .iphone-device #content .board-description, .iphone-device #content .list-name,
|
||||
.iphone-device #content .card-text, .iphone-device #content .card-title, .iphone-device #content .card-description,
|
||||
.iphone-device #content .swimlane-title, .iphone-device #content .swimlane-description,
|
||||
.iphone-device #content .board-header-title, .iphone-device #content .board-header-description,
|
||||
.iphone-device #content .card-detail-title, .iphone-device #content .card-detail-description,
|
||||
.iphone-device #content .list-header-title, .iphone-device #content .list-header-description,
|
||||
.iphone-device #content .swimlane-header-title, .iphone-device #content .swimlane-header-description,
|
||||
.iphone-device #content .minicard-title, .iphone-device #content .minicard-description,
|
||||
.iphone-device #content .card-comment, .iphone-device #content .card-comment-text,
|
||||
.iphone-device #content .checklist-item-text, .iphone-device #content .checklist-item-title,
|
||||
.iphone-device #content .activity-item, .iphone-device #content .activity-item-text,
|
||||
.iphone-device #content .board-member, .iphone-device #content .board-member-name,
|
||||
.iphone-device #content .team-member, .iphone-device #content .team-member-name,
|
||||
.iphone-device #content .org-member, .iphone-device #content .org-member-name,
|
||||
.iphone-device #content .template-member, .iphone-device #content .template-member-name,
|
||||
.iphone-device #content .user-name, .iphone-device #content .user-email, .iphone-device #content .user-role,
|
||||
.iphone-device #content .setting-title, .iphone-device #content .setting-description,
|
||||
.iphone-device #content .popup-title, .iphone-device #content .popup-description,
|
||||
.iphone-device #content .modal-title, .iphone-device #content .modal-description,
|
||||
.iphone-device #content .notification-title, .iphone-device #content .notification-text,
|
||||
.iphone-device #content .announcement-title, .iphone-device #content .announcement-text,
|
||||
.iphone-device #content .offline-warning-title, .iphone-device #content .offline-warning-text,
|
||||
.iphone-device #content .error-title, .iphone-device #content .error-text,
|
||||
.iphone-device #content .success-title, .iphone-device #content .success-text,
|
||||
.iphone-device #content .info-title, .iphone-device #content .info-text,
|
||||
.iphone-device #content .warning-title, .iphone-device #content .warning-text {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
/* Keep icons the same size for iPhone devices */
|
||||
.iphone-device #content .fa, .iphone-device #content .icon, .iphone-device #content i {
|
||||
font-size: 1em !important; /* Keep original icon size */
|
||||
}
|
||||
|
||||
/* Mobile iPhone: keep card details text/icons at normal size (reverted #6426 oversizing) */
|
||||
body.mobile-mode.iphone-device .card-details {
|
||||
font-size: 1em !important;
|
||||
}
|
||||
body.mobile-mode.iphone-device .card-details .fa,
|
||||
body.mobile-mode.iphone-device .card-details .icon,
|
||||
body.mobile-mode.iphone-device .card-details i,
|
||||
body.mobile-mode.iphone-device .card-details .emoji-icon,
|
||||
body.mobile-mode.iphone-device .card-details a,
|
||||
body.mobile-mode.iphone-device .card-details p,
|
||||
body.mobile-mode.iphone-device .card-details span,
|
||||
body.mobile-mode.iphone-device .card-details div,
|
||||
body.mobile-mode.iphone-device .card-details button,
|
||||
body.mobile-mode.iphone-device .card-details input,
|
||||
body.mobile-mode.iphone-device .card-details select,
|
||||
body.mobile-mode.iphone-device .card-details textarea {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
/* Section titles slightly larger than content but not as big as card title */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-item-title {
|
||||
font-size: 1.1em !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Ensure scrollbars are positioned correctly */
|
||||
#content[style*="overflow-x: auto"]::-webkit-scrollbar:vertical {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
#content[style*="overflow-x: auto"]::-webkit-scrollbar:horizontal {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Force both scrollbars to always be visible for high zoom levels */
|
||||
#content[style*="overflow-x: auto"][style*="overflow-y: auto"] {
|
||||
overflow-x: scroll !important;
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
/* Ensure the right scrollbar is always visible on the inner right edge */
|
||||
#content[style*="overflow-y: auto"] {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.board-wrapper .board-canvas .board-overlay {
|
||||
position: fixed;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
top: -100px;
|
||||
inset-inline-end: -400px;
|
||||
background: #000;
|
||||
opacity: 0.33;
|
||||
animation: fadeIn 0.2s;
|
||||
z-index: 16;
|
||||
}
|
||||
|
||||
/* Fix for mobile Safari: ensure overlay stays behind card details */
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-wrapper .board-canvas .board-overlay {
|
||||
z-index: 17 !important;
|
||||
}
|
||||
|
||||
/* In desktop mode on small screens, still keep overlay behind card */
|
||||
body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
z-index: 17 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* In mobile mode, lower the overlay z-index to stay behind card details */
|
||||
body.mobile-mode .board-wrapper .board-canvas .board-overlay {
|
||||
z-index: 17 !important;
|
||||
}
|
||||
|
||||
/* iPhone in desktop mode: remove overlay to avoid blocking card */
|
||||
body.desktop-mode.iphone-device .board-wrapper .board-canvas .board-overlay {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Desktop mode: hide overlay to allow multiple cards and board interaction */
|
||||
body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
/* #2877 "Wekan swimlane jumps when moving card": while a card is dragged the
|
||||
"+ Add Card" composer link is hidden, but hiding it with `display: none`
|
||||
collapsed the bottom row of EVERY list at drag start (and restored it on
|
||||
drop). With auto-height swimlanes the whole swimlane shrank/grew by the
|
||||
composer's height, so all swimlanes below visibly jumped up and back down.
|
||||
`visibility: hidden` hides the link just the same but keeps its box in the
|
||||
layout, so nothing moves when a drag starts or ends. */
|
||||
.board-wrapper .board-canvas.is-dragging-active .open-minicard-composer {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* Checked cards of a multi-selection ARE the payload being dragged, so
|
||||
removing them from the flow (showing the list as it will look after the
|
||||
drop) is intentional — keep display: none here. */
|
||||
.board-wrapper .board-canvas.is-dragging-active .minicard-wrapper.is-checked {
|
||||
display: none;
|
||||
}
|
||||
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
|
||||
.board-wrapper.mobile-view {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.board-wrapper.mobile-view .board-canvas {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.board-wrapper.mobile-view .board-canvas.mobile-view .swimlane {
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
/* MOBILE MODE, not a narrow window. A swimlane laid out as a BLOCK with
|
||||
`overflow-x: hidden` is the phone board: every list becomes a full-width row,
|
||||
one under the other, and the "add list" form lands under the last list rather
|
||||
than beside it. A phone whose user chose DESKTOP mode asked for the desktop
|
||||
board - lists side by side, scrolled sideways - and was getting the phone one.
|
||||
`.board-wrapper.mobile-view` above says the same thing for the mini-screen
|
||||
class; this says it for the mode the user picked. */
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
|
||||
body.mobile-mode .board-wrapper {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
body.mobile-mode .board-wrapper .board-canvas {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
body.mobile-mode .board-wrapper .board-canvas .swimlane {
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Desktop mode on a narrow window: the board scrolls SIDEWAYS, like a desktop
|
||||
board. Without this the canvas would keep the `overflow-x: hidden` it has
|
||||
from elsewhere and the lists past the right edge would be unreachable. */
|
||||
body:not(.mobile-mode) .board-wrapper,
|
||||
body:not(.mobile-mode) .board-wrapper .board-canvas {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
body:not(.mobile-mode) .board-wrapper .board-canvas .swimlane {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
}
|
||||
.calendar-event-green {
|
||||
background: #3cb500 !important;
|
||||
border-color: #2a8000;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-yellow {
|
||||
background: #fad900 !important;
|
||||
border-color: #c7ac00;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-orange {
|
||||
background: #ff9f19 !important;
|
||||
border-color: #cc7c14;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-red {
|
||||
background: #eb4646 !important;
|
||||
border-color: #b83737;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-purple {
|
||||
background: #a632db !important;
|
||||
border-color: #7d26a6;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-blue {
|
||||
background: #0079bf !important;
|
||||
border-color: #005a8a;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-pink {
|
||||
background: #ff78cb !important;
|
||||
border-color: #cc62a3;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-sky {
|
||||
background: #00c2e0 !important;
|
||||
border-color: #0094ab;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-black {
|
||||
background: #4d4d4d !important;
|
||||
border-color: #1a1a1a;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-lime {
|
||||
background: #51e898 !important;
|
||||
border-color: #3eb375;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-silver {
|
||||
background: #c0c0c0 !important;
|
||||
border-color: #8c8c8c;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-peachpuff {
|
||||
background: #ffdab9 !important;
|
||||
border-color: #ccaf95;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-crimson {
|
||||
background: #dc143c !important;
|
||||
border-color: #a8112f;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-plum {
|
||||
background: #dda0dd !important;
|
||||
border-color: #a87ba8;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-darkgreen {
|
||||
background: #006400 !important;
|
||||
border-color: #003000;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-slateblue {
|
||||
background: #6a5acd !important;
|
||||
border-color: #4f4399;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-magenta {
|
||||
background: #f0f !important;
|
||||
border-color: #c0c;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-gold {
|
||||
background: #ffd700 !important;
|
||||
border-color: #ca0;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-navy {
|
||||
background: #000080 !important;
|
||||
border-color: #003;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-gray {
|
||||
background: #808080 !important;
|
||||
border-color: #333;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-saddlebrown {
|
||||
background: #8b4513 !important;
|
||||
border-color: #572b0c;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calendar-event-paleturquoise {
|
||||
background: #afeeee !important;
|
||||
border-color: #8ababa;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-mistyrose {
|
||||
background: #ffe4e1 !important;
|
||||
border-color: #ccb8b6;
|
||||
color: #000 !important;
|
||||
}
|
||||
.calendar-event-indigo {
|
||||
background: #4b0082 !important;
|
||||
border-color: #2b004d;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 9999;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.modal-dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 25%; /* Adjust the height to make it smaller */
|
||||
position: relative;
|
||||
margin: 10% auto; /* This margin will help center the modal vertically */
|
||||
max-width: 400px; /* Adjust the max-width to make it smaller */
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-bottom: 1px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.modal-title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
.close {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
inset-inline-end: 5px;
|
||||
font-size: calc(25px * var(--wekan-ui-font-scale, 1));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Global file drag over state for board canvas */
|
||||
.board-canvas.file-drag-over {
|
||||
background-color: rgba(0, 123, 255, 0.05) !important;
|
||||
border: 2px dashed #007bff !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@@ -1,97 +1,42 @@
|
||||
template(name="board")
|
||||
|
||||
if isConverting
|
||||
+boardConversionProgress
|
||||
else if isBoardReady
|
||||
if isBoardReady.get
|
||||
if currentBoard
|
||||
if onlyShowCurrentCard
|
||||
+cardDetails(currentCard)
|
||||
else
|
||||
+boardBody
|
||||
else
|
||||
//-- XXX We need a better error message in case the board has been archived
|
||||
+message(label="board-not-found")
|
||||
//-- | {{goHome}}
|
||||
//- XXX We need a better error message in case the board has been archived
|
||||
//+message(label="board-not-found")
|
||||
{{goHome}}
|
||||
else
|
||||
+spinner
|
||||
|
||||
template(name="boardBody")
|
||||
if notDisplayThisBoard
|
||||
| {{_ 'tableVisibilityMode-allowPrivateOnly'}}
|
||||
else
|
||||
// Debug information (remove in production)
|
||||
if debugBoardState
|
||||
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
|
||||
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
|
||||
.board-wrapper(class="{{themeColorClass}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
.board-canvas.js-swimlanes.dragscroll(
|
||||
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
|
||||
class="{{#if draggingActive}}is-dragging-active{{/if}}"
|
||||
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}"
|
||||
class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
if currentBoard.showDependencies
|
||||
+dependencyOverlay
|
||||
if showOverlay
|
||||
.board-overlay
|
||||
if currentBoard.isTemplatesBoard
|
||||
each currentBoard.swimlanes
|
||||
+swimlane(this)
|
||||
else if isViewSwimlanes
|
||||
if hasSwimlanes
|
||||
each currentBoard.swimlanes
|
||||
+swimlane(this)
|
||||
else
|
||||
// Fallback: If no swimlanes exist, show lists instead of empty message
|
||||
+listsGroup(currentBoard)
|
||||
else if isViewLists
|
||||
+listsGroup(currentBoard)
|
||||
else if isViewCalendar
|
||||
+calendarView
|
||||
else if isViewGantt
|
||||
+ganttView
|
||||
else if isViewTable
|
||||
+tableView
|
||||
else if isViewStats
|
||||
+statsView
|
||||
else if isViewTime
|
||||
+timeView
|
||||
else if isViewDashboard
|
||||
+dashboardView
|
||||
else if isViewBurndown
|
||||
+burndownView
|
||||
else if isViewBurnup
|
||||
+burnupView
|
||||
else if isViewCumulativeFlow
|
||||
+cumulativeFlowView
|
||||
else if isViewControlChart
|
||||
+controlChartView
|
||||
else if isViewCycleTime
|
||||
+cycleTimeView
|
||||
else if isViewFlowEfficiency
|
||||
+flowEfficiencyView
|
||||
else if isViewLeadTime
|
||||
+leadTimeView
|
||||
else if isViewThroughputHistogram
|
||||
+throughputHistogramView
|
||||
else if isViewWipRun
|
||||
+wipRunView
|
||||
else
|
||||
// Default view - show swimlanes if they exist, otherwise show lists
|
||||
if hasSwimlanes
|
||||
each currentBoard.swimlanes
|
||||
+swimlane(this)
|
||||
else
|
||||
+listsGroup(currentBoard)
|
||||
//- Render multiple open cards in desktop mode
|
||||
unless isMiniScreen
|
||||
each openCards
|
||||
+cardDetails(this cardIndex=@index)
|
||||
+sidebar
|
||||
.board-wrapper(class=currentBoard.colorClass)
|
||||
+sidebar
|
||||
.board-canvas.js-swimlanes.js-perfect-scrollbar(
|
||||
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
|
||||
class="{{#if draggingActive.get}}is-dragging-active{{/if}}")
|
||||
if showOverlay.get
|
||||
.board-overlay
|
||||
if currentBoard.isTemplatesBoard
|
||||
each currentBoard.swimlanes
|
||||
+swimlane(this)
|
||||
else if isViewSwimlanes
|
||||
each currentBoard.swimlanes
|
||||
+swimlane(this)
|
||||
else if isViewLists
|
||||
+listsGroup(currentBoard)
|
||||
else if isViewCalendar
|
||||
+calendarView
|
||||
else
|
||||
+listsGroup(currentBoard)
|
||||
|
||||
template(name="calendarView")
|
||||
if isViewCalendar
|
||||
.calendar-view.swimlane.dragscroll
|
||||
.calendar-view.swimlane
|
||||
if currentCard
|
||||
+cardDetails(currentCard)
|
||||
+fullcalendar(calendarOptions)
|
||||
+fullcalendar(calendarOptions)
|
||||
+240
-1181
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
@import 'nib'
|
||||
|
||||
position()
|
||||
if arguments[0] == cover || arguments[0] == fixed-cover
|
||||
if arguments[0] == cover
|
||||
position: absolute
|
||||
else
|
||||
position: fixed
|
||||
left: 0
|
||||
right: 0
|
||||
top: 0
|
||||
bottom: 0
|
||||
else
|
||||
position: arguments
|
||||
|
||||
.board-wrapper
|
||||
position: cover
|
||||
overflow-x: hidden
|
||||
overflow-y: hidden
|
||||
|
||||
.board-canvas
|
||||
position: cover
|
||||
transition: margin .1s
|
||||
overflow-y: auto
|
||||
|
||||
&.is-sibling-sidebar-open
|
||||
margin-right: 248px
|
||||
|
||||
.board-overlay
|
||||
position: fixed-cover
|
||||
top: -100px
|
||||
right: -400px
|
||||
background: black
|
||||
opacity: 0.33
|
||||
animation: fadeIn 0.2s
|
||||
z-index: 16
|
||||
|
||||
&.is-dragging-active
|
||||
.open-minicard-composer,
|
||||
.minicard-wrapper.is-checked
|
||||
display: none
|
||||
|
||||
@media screen and (max-width: 800px)
|
||||
.board-wrapper
|
||||
|
||||
.board-canvas
|
||||
|
||||
.swimlane
|
||||
border-bottom: 1px solid #CCC
|
||||
display: flex
|
||||
flex-direction: column
|
||||
margin: 0
|
||||
padding: 0 0px 0px 0
|
||||
overflow-x: hidden
|
||||
overflow-y: auto
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,231 @@
|
||||
// We define a set of six board colors that we took from the FlatUI palette.
|
||||
// http://flatuicolors.com
|
||||
//
|
||||
// XXX Centralizing all these properties in a single file just because their
|
||||
// value is derived from the same color, doesn't make any sense. We should
|
||||
// create a mixin/macro that would generate 6 versions of a given property and
|
||||
// dispatch this list in the other stylus files.
|
||||
setBoardColor(color)
|
||||
&#header,
|
||||
&.sk-spinner div,
|
||||
.board-backgrounds-list &.background-box,
|
||||
.board-list & a
|
||||
background-color: color
|
||||
|
||||
.is-selected .minicard
|
||||
border-left: 3px solid color
|
||||
|
||||
button[type=submit].primary, input[type=submit].primary
|
||||
background-color: darken(color, 20%)
|
||||
|
||||
&.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.sidebar .sidebar-content .sidebar-btn:hover,
|
||||
.sidebar-list li a:hover
|
||||
background-color: lighten(color, 10%)
|
||||
|
||||
&#header ul li.current, &#header-quick-access ul li.current
|
||||
border-bottom: 2px solid lighten(color, 10%)
|
||||
|
||||
&#header-quick-access
|
||||
background: darken(color, 10%)
|
||||
color: white
|
||||
|
||||
&#header #header-main-bar .board-header-btn.emphasis
|
||||
background: complement(color)
|
||||
|
||||
&:hover,
|
||||
.board-header-btn-close
|
||||
background: darken(complement(color), 10%)
|
||||
|
||||
&:hover .board-header-btn-close
|
||||
background: darken(complement(color), 20%)
|
||||
|
||||
.materialCheckBox.is-checked
|
||||
border-bottom: 2px solid color
|
||||
border-right: 2px solid color
|
||||
|
||||
.is-multiselection-active .multi-selection-checkbox
|
||||
&.is-checked + .minicard
|
||||
background: lighten(color, 90%)
|
||||
|
||||
&:not(.is-checked) + .minicard:hover:not(.minicard-composer)
|
||||
background: lighten(color, 97%)
|
||||
|
||||
.toggle-label
|
||||
|
||||
&:after
|
||||
background-color: darken(color, 20%)
|
||||
|
||||
.toggle-switch:checked ~ .toggle-label
|
||||
background-color: lighten(color, 20%)
|
||||
|
||||
&:after
|
||||
background-color: darken(color, 20%)
|
||||
|
||||
@media screen and (max-width: 800px)
|
||||
&.pop-over .header
|
||||
background: color
|
||||
color: white
|
||||
|
||||
&#header ul li.current, &#header-quick-access ul li.current
|
||||
border-bottom: 4px solid lighten(color, 20%)
|
||||
|
||||
.board-color-nephritis
|
||||
setBoardColor(#27AE60)
|
||||
|
||||
.board-color-pomegranate
|
||||
setBoardColor(#C0392B)
|
||||
|
||||
.board-color-belize
|
||||
setBoardColor(#2980B9)
|
||||
|
||||
.board-color-wisteria
|
||||
setBoardColor(#8E44AD)
|
||||
|
||||
.board-color-midnight
|
||||
setBoardColor(#2C3E50)
|
||||
|
||||
.board-color-pumpkin
|
||||
setBoardColor(#E67E22)
|
||||
|
||||
.board-color-moderatepink
|
||||
setBoardColor(#CD5A91)
|
||||
|
||||
.board-color-strongcyan
|
||||
setBoardColor(#00AECC)
|
||||
|
||||
.board-color-limegreen
|
||||
setBoardColor(#4BBF6B)
|
||||
|
||||
.board-color-dark
|
||||
setBoardColor(#2C3E51)
|
||||
|
||||
/* Not hidden in dark mode.
|
||||
card fields: received, start, due, end, members, requested, assigned
|
||||
.card-details-item.card-details-item-received,
|
||||
.card-details-item.card-details-item-start,
|
||||
.card-details-item.card-details-item-due,
|
||||
.card-details-item.card-details-item-end,
|
||||
.card-details-item.card-details-item-members,
|
||||
.card-details-item.card-details-item-name { display:none; }
|
||||
.card-details-items:empty { display:none; }
|
||||
*/
|
||||
|
||||
// DARK MODE, when dark background mode selected.
|
||||
// Modified version from https://github.com/wekan/wekan/wiki/Custom-CSS-themes#dark-theme
|
||||
// In progress, please send pull requests to fix remaining visibility issues.
|
||||
.ui-sortable,
|
||||
.swimlane,
|
||||
.swimlane >.swimlane-header-wrap,
|
||||
.swimlane >.list.js-list,
|
||||
.swimlane >.list-composer.js-list-composer,
|
||||
.list-body,
|
||||
.list,
|
||||
.list-composer,
|
||||
.sidebar-content,
|
||||
.card-details
|
||||
background-color:#2C3E50
|
||||
|
||||
.card-details h3,
|
||||
.card-details-items,
|
||||
.card-checklist-items .ui-sortable,
|
||||
.card-subtasks-items,
|
||||
.activities,
|
||||
.material-toggle-switch
|
||||
color:#bbbbbb
|
||||
|
||||
.list-header
|
||||
background-color: #888888
|
||||
|
||||
.board-widget,
|
||||
.board-widget-labels,
|
||||
.board-widget-members
|
||||
color: #aaaaaa
|
||||
|
||||
/* popup menu titles (boards, swimlanes, lists, cards, labels) */
|
||||
.pop-over >.header
|
||||
display:none;
|
||||
|
||||
/* HIDE UNTIL HOVER -------------------------------------------------- */
|
||||
|
||||
/* header "+" button */
|
||||
#header-quick-access .fa-plus
|
||||
display:none
|
||||
#header-quick-access:hover .fa-plus
|
||||
display:inherit
|
||||
|
||||
/* "add card" links (use visibility rather than display so items don't jump) */
|
||||
.open-minicard-composer
|
||||
visibility:hidden
|
||||
|
||||
.list.js-list:hover .open-minicard-composer
|
||||
visibility:visible
|
||||
|
||||
.list-header-menu
|
||||
visibility:hidden
|
||||
|
||||
.list.js-list:hover .list-header-menu
|
||||
visibility:visible
|
||||
|
||||
/* "add list/swimlane" links (use visibility rather than display so items don't jump) */
|
||||
.list.js-list-composer >.list-header
|
||||
visibility:hidden
|
||||
|
||||
.list.js-list-composer:hover >.list-header
|
||||
visibility:visible
|
||||
|
||||
/* headers */
|
||||
#header-quick-access, #header
|
||||
background-color:rgba(0,0,0,.75) !important
|
||||
|
||||
#header .board-header-btn:hover
|
||||
background-color:rgba(255,255,255,0.3) !important
|
||||
|
||||
/* foregrounds: swimlanes, lists */
|
||||
.list >.list-header, .swimlane-header
|
||||
color:rgba(255,255,255,.7)
|
||||
|
||||
/* minicards */
|
||||
.minicard
|
||||
background-color:rgba(255,255,255,.4)
|
||||
|
||||
.minicard-wrapper.is-selected .minicard,
|
||||
.minicard:hover,
|
||||
.minicard-composer.js-composer,
|
||||
.open-minicard-composer:hover
|
||||
background-color:rgba(255,255,255,.8) !important
|
||||
color:#000
|
||||
|
||||
.minicard, .minicard .badge
|
||||
color:#fff
|
||||
|
||||
.minicard:hover .badge, .minicard-wrapper.is-selected .badge
|
||||
color:#000
|
||||
|
||||
/* cards */
|
||||
.card-details .card-details-header
|
||||
background-color:#ccc
|
||||
|
||||
/* sidebar */
|
||||
.sidebar-tongue, .sidebar-shadow
|
||||
background-color:#666 !important
|
||||
|
||||
.sidebar-content h3, .sidebar-content h2, .sidebar-content
|
||||
color:rgba(255,255,255,.7) !important
|
||||
|
||||
.board-color-relax
|
||||
setBoardColor(#27AE61)
|
||||
|
||||
// RELAX MODE: light green backgroud, when green background color selected.
|
||||
.ui-sortable
|
||||
background-color:#a7e366
|
||||
|
||||
.list-header
|
||||
background-color:#a7e366
|
||||
border-bottom: 6px solid #a7e366
|
||||
|
||||
.list-body
|
||||
background-color:#a7e366
|
||||
|
||||
.list
|
||||
border-left: 1px dotted #000000
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,132 +1,152 @@
|
||||
//- There is no pencil beside the board's name any more: the NAME renames the
|
||||
//- board. It is one target instead of a word and an icon that did the same
|
||||
//- thing, and the icon was the smaller of the two. client/components/main/header.jade
|
||||
//-
|
||||
//- Everything else the board's second header bar held, as icons in the FIRST
|
||||
//- bar, to the left of the notification bell.
|
||||
//-
|
||||
//- ICON ONLY, each named by a tooltip. The bar is a single row shared with the
|
||||
//- logo, the starred boards, the view menu, the bell and the user menu, and
|
||||
//- these are ten more controls - labelled, they would not fit at any width.
|
||||
//-
|
||||
//- There is no desktop/mobile split any more either. The second bar wrote the
|
||||
//- whole set out TWICE, once for each, because a labelled row does not fit on a
|
||||
//- phone; icons do, so one set serves both. docs/Features/Page/Header.md
|
||||
template(name="boardHeaderButtons")
|
||||
if currentBoard
|
||||
if currentUser
|
||||
.board-header-btns.board-header-controls
|
||||
//- The board's controls fold into one caret at the leading edge of the
|
||||
group - left of the lock in a left-to-right page, right of it in Arabic,
|
||||
because the caret's direction comes from the same rule as every other
|
||||
one. Folded, the seven controls after it are gone; open, they are back.
|
||||
The buttons a board's own Rules put in this bar are NOT folded away with
|
||||
them: somebody added those to this board on purpose.
|
||||
client/lib/foldState.js
|
||||
a.board-header-btn.js-toggle-fold(data-fold="board-controls"
|
||||
title="{{#if isFoldOpen 'board-controls'}}{{_ 'collapse'}}{{else}}{{_ 'uncollapse'}}{{/if}}" role="button" tabindex="0")
|
||||
i.fa(class="{{foldCaret 'board-controls'}}")
|
||||
if isFoldOpen 'board-controls'
|
||||
with currentBoard
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
//- The SAME expression the tooltip uses, so the two cannot say
|
||||
different things - and both follow the board from Private to
|
||||
Public without a second list of words to keep in step.
|
||||
span.board-header-btn-label {{_ currentBoard.permission}}
|
||||
template(name="boardHeaderBar")
|
||||
h1.header-board-menu
|
||||
with currentBoard
|
||||
a(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}")
|
||||
+viewer
|
||||
= title
|
||||
|
||||
unless watchDisabled
|
||||
a.board-header-btn.js-watch-board(title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
span.board-header-btn-label {{_ watchLevel }}
|
||||
.board-header-btns.left
|
||||
unless isMiniScreen
|
||||
if currentBoard
|
||||
if currentUser
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span
|
||||
= currentBoard.stars
|
||||
|
||||
//- The name beside the icon where the bar has room for it, dropped
|
||||
when it does not - one rule in header.css hides every label of this
|
||||
bar below the width they stop fitting at.
|
||||
docs/Features/Page/Header.md
|
||||
a.board-header-btn(title="{{#if isSortActive}}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
|
||||
i.fa.fa-sort
|
||||
span.board-header-btn-label {{#if isSortActive}}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
span {{_ currentBoard.permission}}
|
||||
|
||||
+boardButtons
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
span {{_ watchLevel}}
|
||||
|
||||
if isFoldOpen 'board-controls'
|
||||
a.board-header-btn.js-open-filter-view(
|
||||
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
|
||||
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
|
||||
i.fa.fa-filter
|
||||
span.board-header-btn-label {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
|
||||
if Filter.isActive
|
||||
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
span {{_ 'log-in'}}
|
||||
|
||||
.board-header-btns.right
|
||||
if currentBoard
|
||||
if isMiniScreen
|
||||
if currentUser
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span
|
||||
= currentBoard.stars
|
||||
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
span {{_ currentBoard.permission}}
|
||||
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
span {{_ watchLevel}}
|
||||
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
span {{_ 'log-in'}}
|
||||
|
||||
if isSandstorm
|
||||
if currentUser
|
||||
a.board-header-btn.js-open-archived-board
|
||||
i.fa.fa-archive
|
||||
span {{_ 'archives'}}
|
||||
|
||||
a.board-header-btn.js-open-filter-view(
|
||||
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
|
||||
class="{{#if Filter.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-filter
|
||||
span {{#if Filter.isActive}}{{_ 'filter-on'}}{{else}}{{_ 'filter'}}{{/if}}
|
||||
if Filter.isActive
|
||||
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
if currentUser.isAdmin
|
||||
a.board-header-btn.js-open-rules-view(title="{{_ 'rules'}}")
|
||||
i.fa.fa-magic
|
||||
span {{_ 'rules'}}
|
||||
else if currentUser.isBoardAdmin
|
||||
a.board-header-btn.js-open-rules-view(title="{{_ 'rules'}}")
|
||||
i.fa.fa-magic
|
||||
span {{_ 'rules'}}
|
||||
|
||||
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
|
||||
i.fa.fa-search
|
||||
span {{_ 'search'}}
|
||||
|
||||
unless currentBoard.isTemplatesBoard
|
||||
a.board-header-btn.js-toggle-board-view(
|
||||
title="{{_ 'board-view'}}")
|
||||
i.fa.fa-th-large
|
||||
span {{#if currentUser.profile.boardView}}{{_ currentUser.profile.boardView}}{{else}}{{_ 'board-view-lists'}}{{/if}}
|
||||
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
|
||||
if MultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
|
||||
i.fa.fa-search
|
||||
span.board-header-btn-label {{_ 'search'}}
|
||||
.separator
|
||||
a.board-header-btn.js-toggle-sidebar
|
||||
i.fa.fa-navicon
|
||||
|
||||
a.board-header-btn.js-toggle-dependencies(
|
||||
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
|
||||
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
|
||||
i.fa.fa-link
|
||||
span.board-header-btn-label {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
|
||||
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
//- Named, like the board's Multi-Selection on All Boards: a
|
||||
check-box outline says nothing about what the button does.
|
||||
span.board-header-btn-label {{_ 'multi-selection'}}
|
||||
if MultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'multi-selection-off'}}")
|
||||
i.fa.fa-times-thin
|
||||
else
|
||||
a.board-header-btn.js-log-in(title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
|
||||
//- `described-list`: every row is icon + name + description, and the three are laid
|
||||
//- out as three left-aligned columns (popup.css), so the descriptions of all the
|
||||
//- rows start at the same x whether or not a row carries the check mark.
|
||||
template(name="boardVisibilityList")
|
||||
ul.pop-over-list.described-list
|
||||
ul.pop-over-list
|
||||
li
|
||||
with "private"
|
||||
a.js-select-visibility
|
||||
i.fa.fa-lock
|
||||
i.fa.fa-lock.colorful
|
||||
| {{_ 'private'}}
|
||||
if visibilityCheck
|
||||
i.fa.fa-check
|
||||
span.sub-name {{_ 'private-desc'}}
|
||||
if notAllowPrivateVisibilityOnly
|
||||
li
|
||||
with "public"
|
||||
a.js-select-visibility
|
||||
i.fa.fa-globe
|
||||
| {{_ 'public'}}
|
||||
if visibilityCheck
|
||||
i.fa.fa-check
|
||||
span.sub-name {{_ 'public-desc'}}
|
||||
li
|
||||
with "public"
|
||||
a.js-select-visibility
|
||||
i.fa.fa-globe.colorful
|
||||
| {{_ 'public'}}
|
||||
if visibilityCheck
|
||||
i.fa.fa-check
|
||||
span.sub-name {{_ 'public-desc'}}
|
||||
|
||||
template(name="boardChangeVisibilityPopup")
|
||||
+boardVisibilityList
|
||||
|
||||
template(name="boardChangeWatchPopup")
|
||||
ul.pop-over-list.described-list
|
||||
ul.pop-over-list
|
||||
li
|
||||
with "watching"
|
||||
a.js-select-watch
|
||||
i.fa.fa-eye
|
||||
i.fa.fa-eye.colorful
|
||||
| {{_ 'watching'}}
|
||||
if watchCheck
|
||||
i.fa.fa-check
|
||||
@@ -134,7 +154,7 @@ template(name="boardChangeWatchPopup")
|
||||
li
|
||||
with "tracking"
|
||||
a.js-select-watch
|
||||
i.fa.fa-bell
|
||||
i.fa.fa-bell.colorful
|
||||
| {{_ 'tracking'}}
|
||||
if watchCheck
|
||||
i.fa.fa-check
|
||||
@@ -142,166 +162,22 @@ template(name="boardChangeWatchPopup")
|
||||
li
|
||||
with "muted"
|
||||
a.js-select-watch
|
||||
i.fa.fa-bell-slash
|
||||
i.fa.fa-bell-slash.colorful
|
||||
| {{_ 'muted'}}
|
||||
if watchCheck
|
||||
i.fa.fa-check
|
||||
span.sub-name {{_ 'muted-info'}}
|
||||
|
||||
template(name="boardChangeViewPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
with "board-view-swimlanes"
|
||||
a.js-open-swimlanes-view
|
||||
i.fa.fa-th-large
|
||||
| {{_ 'swimlanes'}}
|
||||
if $eq Utils.boardView "board-view-swimlanes"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-lists"
|
||||
a.js-open-lists-view
|
||||
i.fa.fa-trello
|
||||
| {{_ 'board-view-lists'}}
|
||||
if $eq Utils.boardView "board-view-lists"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-table"
|
||||
a.js-open-table-view
|
||||
i.fa.fa-table
|
||||
| {{_ 'board-view-table'}}
|
||||
if $eq Utils.boardView "board-view-table"
|
||||
i.fa.fa-check
|
||||
hr
|
||||
li
|
||||
with "board-view-cal"
|
||||
a.js-open-cal-view
|
||||
i.fa.fa-calendar
|
||||
| {{_ 'board-view-cal'}}
|
||||
if $eq Utils.boardView "board-view-cal"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-time"
|
||||
a.js-open-time-view
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'board-view-time'}}
|
||||
if $eq Utils.boardView "board-view-time"
|
||||
i.fa.fa-check
|
||||
hr
|
||||
li
|
||||
with "board-view-stats"
|
||||
a.js-open-stats-view
|
||||
i.fa.fa-pie-chart
|
||||
| {{_ 'board-view-stats'}}
|
||||
if $eq Utils.boardView "board-view-stats"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-gantt"
|
||||
a.js-open-gantt-view
|
||||
i.fa.fa-bar-chart
|
||||
| {{_ 'board-view-gantt'}}
|
||||
if $eq Utils.boardView "board-view-gantt"
|
||||
i.fa.fa-check
|
||||
hr
|
||||
li
|
||||
with "board-view-dashboard"
|
||||
a.js-open-dashboard-view
|
||||
i.fa.fa-tachometer
|
||||
//- The parentheses, not the words, say "not implemented yet" -
|
||||
//- see the note above (Burndown) below. The label itself is
|
||||
//- still translated like every other entry.
|
||||
| ({{_ 'board-view-dashboard'}})
|
||||
if $eq Utils.boardView "board-view-dashboard"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-burndown"
|
||||
a.js-open-burndown-view
|
||||
i.fa.fa-line-chart
|
||||
//- The PARENTHESES say the view is not fully implemented yet -
|
||||
//- not, as an earlier version of this had it, leaving the word
|
||||
//- itself untranslated. On an otherwise fully translated menu, an
|
||||
//- English-only label read as a bug, not a "coming soon" marker
|
||||
//- (docs/Features, .tools/board-view.png). The label is
|
||||
//- translated like every other entry; only the wrapping parens
|
||||
//- are literal.
|
||||
| ({{_ 'board-view-burndown'}})
|
||||
if $eq Utils.boardView "board-view-burndown"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-burnup"
|
||||
a.js-open-burnup-view
|
||||
i.fa.fa-area-chart
|
||||
| ({{_ 'board-view-burnup'}})
|
||||
if $eq Utils.boardView "board-view-burnup"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-cumulative-flow"
|
||||
a.js-open-cumulative-flow-view
|
||||
i.fa.fa-signal
|
||||
| ({{_ 'board-view-cumulative-flow'}})
|
||||
if $eq Utils.boardView "board-view-cumulative-flow"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-control-chart"
|
||||
a.js-open-control-chart-view
|
||||
i.fa.fa-crosshairs
|
||||
| ({{_ 'board-view-control-chart'}})
|
||||
if $eq Utils.boardView "board-view-control-chart"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-cycle-time"
|
||||
a.js-open-cycle-time-view
|
||||
i.fa.fa-refresh
|
||||
| ({{_ 'board-view-cycle-time'}})
|
||||
if $eq Utils.boardView "board-view-cycle-time"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-flow-efficiency"
|
||||
a.js-open-flow-efficiency-view
|
||||
i.fa.fa-percent
|
||||
| ({{_ 'board-view-flow-efficiency'}})
|
||||
if $eq Utils.boardView "board-view-flow-efficiency"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-lead-time"
|
||||
a.js-open-lead-time-view
|
||||
i.fa.fa-hourglass-half
|
||||
| ({{_ 'board-view-lead-time'}})
|
||||
if $eq Utils.boardView "board-view-lead-time"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-throughput-histogram"
|
||||
a.js-open-throughput-histogram-view
|
||||
i.fa.fa-columns
|
||||
| ({{_ 'board-view-throughput-histogram'}})
|
||||
if $eq Utils.boardView "board-view-throughput-histogram"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-wip-run"
|
||||
a.js-open-wip-run-view
|
||||
i.fa.fa-flag-checkered
|
||||
| ({{_ 'board-view-wip-run'}})
|
||||
if $eq Utils.boardView "board-view-wip-run"
|
||||
i.fa.fa-check
|
||||
|
||||
//- The Create Board form: a title, what the board is visible to, and Create.
|
||||
Four templates drew it letter for letter - the one on the All Boards page and
|
||||
three popups, one of which creates a TEMPLATE board and says so with a Session
|
||||
flag rather than with different markup.
|
||||
//-
|
||||
//- The two things it shows about visibility are passed IN. The state itself
|
||||
stays with each of the four owners and is passed in explicitly. Blaze scopes
|
||||
events to this included template, so this form owns the handlers.
|
||||
client/components/boards/boardHeader.js
|
||||
template(name="createBoardForm")
|
||||
template(name="createBoard")
|
||||
form
|
||||
label
|
||||
| {{_ 'title'}}
|
||||
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
|
||||
if visibilityMenuIsOpen
|
||||
if visibilityMenuIsOpen.get
|
||||
+boardVisibilityList
|
||||
else
|
||||
p.quiet
|
||||
if $eq visibility 'public'
|
||||
if $eq visibility.get 'public'
|
||||
span.fa.fa-globe.colorful
|
||||
= " "
|
||||
| {{{_ 'board-public-info'}}}
|
||||
@@ -311,52 +187,13 @@ template(name="createBoardForm")
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
unless importDisabled
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
a.js-import-board {{_ 'import'}}
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
a.js-import-board {{_ 'import'}}
|
||||
span.quiet
|
||||
| /
|
||||
a.js-board-template {{_ 'template'}}
|
||||
|
||||
template(name="createBoard")
|
||||
+createBoardForm(owner=createBoardOwner visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen)
|
||||
|
||||
template(name="createBoardPopup")
|
||||
+createBoardForm(owner=createBoardOwner visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen)
|
||||
|
||||
template(name="headerBarCreateBoardPopup")
|
||||
+createBoardForm(owner=createBoardOwner visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen starAfterCreate=true)
|
||||
|
||||
// New popup for Template Container creation; shares the same form content
|
||||
template(name="createTemplateContainerPopup")
|
||||
+createBoardForm(owner=createBoardOwner visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen)
|
||||
|
||||
//template(name="listsortPopup")
|
||||
//
|
||||
h2
|
||||
//
|
||||
| {{_ 'list-sort-by'}}
|
||||
//
|
||||
hr
|
||||
//
|
||||
ul.pop-over-list
|
||||
//
|
||||
each value in allowedSortValues
|
||||
//
|
||||
li
|
||||
//
|
||||
a.js-sort-by(name="{{value.name}}")
|
||||
//
|
||||
if $eq sortby value.name
|
||||
//
|
||||
i.fa(class="{{#if $eq Direction 'fa-arrow-up'}}fa-arrow-up{{else}}fa-arrow-down{{/if}}")
|
||||
//
|
||||
| {{_ value.label }}{{_ value.shortLabel}}
|
||||
//
|
||||
if $eq sortby value.name
|
||||
//
|
||||
i.fa.fa-check
|
||||
template(name="boardChangeTitlePopup")
|
||||
form
|
||||
label
|
||||
@@ -370,74 +207,3 @@ template(name="boardChangeTitlePopup")
|
||||
template(name="boardCreateRulePopup")
|
||||
p {{_ 'close-board-pop'}}
|
||||
button.js-confirm.negate.full(type="submit") {{_ 'archive'}}
|
||||
|
||||
|
||||
template(name="cardsSortPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-sort-due
|
||||
i.fa.fa-calendar
|
||||
| {{_ 'due-date'}}
|
||||
hr
|
||||
li
|
||||
a.js-sort-title
|
||||
i.fa.fa-sort-alpha-asc
|
||||
| {{_ 'title-alphabetically'}}
|
||||
hr
|
||||
li
|
||||
a.js-sort-created-desc
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'created-at-newest-first'}}
|
||||
hr
|
||||
li
|
||||
a.js-sort-created-asc
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'created-at-oldest-first'}}
|
||||
|
||||
|
||||
//- The board's view menu, in the FIRST top header bar.
|
||||
//-
|
||||
//- It was a button in the board's second header bar. A view menu says what you
|
||||
//- are looking AT, so it belongs beside the page's name in the one bar that is
|
||||
//- always on screen - the same place the All Boards Lists/Table menu is.
|
||||
//-
|
||||
//- `unless currentBoard.isTemplatesBoard`: there is nothing to look at six
|
||||
//- ways on a templates board. docs/Features/Page/Header.md
|
||||
//- Whether THIS board is starred, and the button that changes it. Its own
|
||||
//- template because the first header bar places it beside the starred-boards
|
||||
//- dropdown rather than among the board's other controls: the two are one pair -
|
||||
//- how many boards you have starred, and whether this is one of them.
|
||||
//-
|
||||
//- The outline star means not starred and the solid one means starred, so the
|
||||
//- button shows its state rather than the action - the tooltip says the action.
|
||||
//- docs/Features/Page/Header.md
|
||||
template(name="boardStarButton")
|
||||
if currentBoard
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span.board-star-counter {{currentBoard.stars}}
|
||||
|
||||
template(name="boardViewMenu")
|
||||
unless currentBoard.isTemplatesBoard
|
||||
//- The name is VISIBLE beside the icon, not only in the tooltip. Six view
|
||||
icons are six glyphs to learn, and a tooltip is the one place a name
|
||||
cannot be read without hovering - so the button says which view is on.
|
||||
The bar wraps to a second row when it runs out of width, which is what
|
||||
makes a word beside the icon affordable. docs/Features/Page/Header.md
|
||||
a.board-header-btn.js-toggle-board-view(title="{{boardViewName}}")
|
||||
i.fa.fa-caret-down
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
i.fa.fa-th-large
|
||||
if $eq boardView 'board-view-lists'
|
||||
i.fa.fa-trello
|
||||
if $eq boardView 'board-view-cal'
|
||||
i.fa.fa-calendar
|
||||
if $eq boardView 'board-view-gantt'
|
||||
i.fa.fa-bar-chart
|
||||
if $eq boardView 'board-view-table'
|
||||
i.fa.fa-table
|
||||
if $eq boardView 'board-view-stats'
|
||||
i.fa.fa-pie-chart
|
||||
span.board-header-btn-label= boardViewName
|
||||
@@ -1,725 +1,247 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
|
||||
import dragscroll from '@wekanteam/dragscroll';
|
||||
import getSlug from 'limax';
|
||||
import Boards from '/models/boards';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
// Which way a button that opens a sidebar view goes on a click - one answer,
|
||||
// in one place, for both Filter and Search.
|
||||
import {
|
||||
SIDEBAR_VIEW_CLOSE,
|
||||
sidebarViewButtonAction,
|
||||
} from '/models/lib/sidebarViewButton';
|
||||
import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { getSidebarInstance } from '/client/features/sidebar/service';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { toggleFold } from '/client/lib/foldState';
|
||||
Template.boardMenuPopup.events({
|
||||
'click .js-rename-board': Popup.open('boardChangeTitle'),
|
||||
'click .js-custom-fields'() {
|
||||
Sidebar.setView('customFields');
|
||||
Popup.close();
|
||||
},
|
||||
'click .js-open-archives'() {
|
||||
Sidebar.setView('archives');
|
||||
Popup.close();
|
||||
},
|
||||
'click .js-change-board-color': Popup.open('boardChangeColor'),
|
||||
'click .js-change-language': Popup.open('changeLanguage'),
|
||||
'click .js-archive-board ': Popup.afterConfirm('archiveBoard', function() {
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
currentBoard.archive();
|
||||
// XXX We should have some kind of notification on top of the page to
|
||||
// confirm that the board was successfully archived.
|
||||
FlowRouter.go('home');
|
||||
}),
|
||||
'click .js-delete-board': Popup.afterConfirm('deleteBoard', function() {
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
Popup.close();
|
||||
Boards.remove(currentBoard._id);
|
||||
FlowRouter.go('home');
|
||||
}),
|
||||
'click .js-outgoing-webhooks': Popup.open('outgoingWebhooks'),
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-subtask-settings': Popup.open('boardSubtaskSettings'),
|
||||
});
|
||||
|
||||
/*
|
||||
const DOWNCLS = 'fa-sort-down';
|
||||
const UPCLS = 'fa-sort-up';
|
||||
*/
|
||||
const sortCardsBy = new ReactiveVar('');
|
||||
|
||||
// Persist the card sort so it survives a page reload. Session is in-memory and
|
||||
// is reset on reload, which is why "sort by due date" kept reverting to the
|
||||
// default. See https://github.com/wekan/wekan/issues/5886
|
||||
const CARDS_SORT_BY_STORAGE_KEY = 'wekan-cards-sortBy';
|
||||
function setCardsSortBy(sortBy) {
|
||||
Session.set('sortBy', sortBy);
|
||||
try {
|
||||
if (sortBy) {
|
||||
window.localStorage.setItem(CARDS_SORT_BY_STORAGE_KEY, JSON.stringify(sortBy));
|
||||
} else {
|
||||
window.localStorage.removeItem(CARDS_SORT_BY_STORAGE_KEY);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
Template.boardMenuPopup.helpers({
|
||||
exportUrl() {
|
||||
const params = {
|
||||
boardId: Session.get('currentBoard'),
|
||||
};
|
||||
const queryParams = {
|
||||
authToken: Accounts._storedLoginToken(),
|
||||
};
|
||||
return FlowRouter.path('/api/boards/:boardId/export', params, queryParams);
|
||||
},
|
||||
exportFilename() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
return `wekan-export-board-${boardId}.json`;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeTitlePopup.events({
|
||||
async submit(event, templateInstance) {
|
||||
event.preventDefault();
|
||||
const newTitle = templateInstance
|
||||
.$('.js-board-name')
|
||||
.val()
|
||||
.trim();
|
||||
const newDesc = templateInstance
|
||||
.$('.js-board-desc')
|
||||
.val()
|
||||
.trim();
|
||||
submit(evt, tpl) {
|
||||
const newTitle = tpl.$('.js-board-name').val().trim();
|
||||
const newDesc = tpl.$('.js-board-desc').val().trim();
|
||||
if (newTitle) {
|
||||
// The board this popup was opened FOR, when it was opened for one: the
|
||||
// All Boards Table view opens this same popup from a row whose data
|
||||
// context is that row's board (docs/Features/Page/All-Boards.md), and on
|
||||
// that page there is no "current board" at all. The board header opens it
|
||||
// with the current board as its context, so that side is unchanged.
|
||||
const context = Template.currentData();
|
||||
const board =
|
||||
context && context._id && typeof context.rename === 'function'
|
||||
? context
|
||||
: Utils.getCurrentBoard();
|
||||
if (board) {
|
||||
await board.rename(newTitle);
|
||||
await board.setDescription(newDesc);
|
||||
}
|
||||
Popup.back();
|
||||
this.rename(newTitle);
|
||||
this.setDescription(newDesc);
|
||||
Popup.close();
|
||||
}
|
||||
evt.preventDefault();
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardHeaderButtons.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
// Restore the persisted card sort after a reload (Session is in-memory), so
|
||||
// both the sorting and the sort icon are remembered. See #5886.
|
||||
if (!Session.get('sortBy')) {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(CARDS_SORT_BY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
Session.set('sortBy', JSON.parse(stored));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
Template.boardHeaderButtons.helpers({
|
||||
notDisplayThisBoard() {
|
||||
const allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly');
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return (
|
||||
allowPrivateVisibilityOnly !== undefined &&
|
||||
allowPrivateVisibilityOnly.booleanValue &&
|
||||
currentBoard &&
|
||||
currentBoard.permission === 'public'
|
||||
);
|
||||
},
|
||||
|
||||
BlazeComponent.extendComponent({
|
||||
watchLevel() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
return currentBoard && currentBoard.getWatchLevel(Meteor.userId());
|
||||
},
|
||||
|
||||
boardView() {
|
||||
return Utils.boardView();
|
||||
},
|
||||
isSortActive() {
|
||||
return Session.get('sortBy') ? true : false;
|
||||
},
|
||||
// A Font Awesome class name for the active sort, not an emoji.
|
||||
sortCardsIcon() {
|
||||
const sortBy = Session.get('sortBy');
|
||||
if (!sortBy) {
|
||||
return 'fa-sort'; // nothing chosen
|
||||
}
|
||||
if (sortBy.dueAt) {
|
||||
return 'fa-clock-o'; // due date
|
||||
} else if (sortBy.title) {
|
||||
return 'fa-font'; // alphabetical
|
||||
} else if (sortBy.createdAt) {
|
||||
return sortBy.createdAt === 1 ? 'fa-arrow-up' : 'fa-arrow-down';
|
||||
}
|
||||
return 'fa-sort';
|
||||
},
|
||||
});
|
||||
|
||||
// The board star, in its own template because the first header bar places it
|
||||
// beside the starred-boards dropdown. A Blaze event map only sees events inside
|
||||
// its own template, so its helpers and its click come with its markup.
|
||||
Template.boardStarButton.helpers({
|
||||
isStarred() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const user = Meteor.user();
|
||||
return user && user.hasStarred(boardId);
|
||||
},
|
||||
|
||||
// Only show the star counter if the number of stars is greater than 2.
|
||||
// Only show the star counter if the number of star is greater than 2
|
||||
showStarCounter() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
return currentBoard && currentBoard.stars >= 2;
|
||||
},
|
||||
|
||||
currentBoard() {
|
||||
return Utils.getCurrentBoard();
|
||||
events() {
|
||||
return [{
|
||||
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
|
||||
'click .js-star-board'() {
|
||||
Meteor.user().toggleBoardStar(Session.get('currentBoard'));
|
||||
},
|
||||
'click .js-open-board-menu': Popup.open('boardMenu'),
|
||||
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
|
||||
'click .js-watch-board': Popup.open('boardChangeWatch'),
|
||||
'click .js-open-archived-board'() {
|
||||
Modal.open('archivedBoards');
|
||||
},
|
||||
'click .js-toggle-board-view'() {
|
||||
const currentUser = Meteor.user();
|
||||
if ((currentUser.profile || {}).boardView === 'board-view-swimlanes') {
|
||||
currentUser.setBoardView('board-view-cal');
|
||||
} else if ((currentUser.profile || {}).boardView === 'board-view-lists') {
|
||||
currentUser.setBoardView('board-view-swimlanes');
|
||||
} else if ((currentUser.profile || {}).boardView === 'board-view-cal') {
|
||||
currentUser.setBoardView('board-view-lists');
|
||||
} else {
|
||||
currentUser.setBoardView('board-view-swimlanes');
|
||||
}
|
||||
},
|
||||
'click .js-toggle-sidebar'() {
|
||||
Sidebar.toggle();
|
||||
},
|
||||
'click .js-open-filter-view'() {
|
||||
Sidebar.setView('filter');
|
||||
},
|
||||
'click .js-filter-reset'(evt) {
|
||||
evt.stopPropagation();
|
||||
Sidebar.setView();
|
||||
Filter.reset();
|
||||
},
|
||||
'click .js-open-search-view'() {
|
||||
Sidebar.setView('search');
|
||||
},
|
||||
'click .js-open-rules-view'() {
|
||||
Modal.openWide('rulesMain');
|
||||
},
|
||||
'click .js-multiselection-activate'() {
|
||||
const currentCard = Session.get('currentCard');
|
||||
MultiSelection.activate();
|
||||
if (currentCard) {
|
||||
MultiSelection.add(currentCard);
|
||||
}
|
||||
},
|
||||
'click .js-multiselection-reset'(evt) {
|
||||
evt.stopPropagation();
|
||||
MultiSelection.disable();
|
||||
},
|
||||
'click .js-log-in'() {
|
||||
FlowRouter.go('atSignIn');
|
||||
},
|
||||
}];
|
||||
},
|
||||
}).register('boardHeaderBar');
|
||||
|
||||
Template.boardHeaderBar.helpers({
|
||||
canModifyBoard() {
|
||||
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardStarButton.events({
|
||||
async 'click .js-star-board'() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (boardId) {
|
||||
try {
|
||||
await Meteor.callAsync('toggleBoardStar', boardId);
|
||||
} catch (error) {
|
||||
console.error('Could not toggle board favorite:', error);
|
||||
}
|
||||
}
|
||||
const CreateBoard = BlazeComponent.extendComponent({
|
||||
template() {
|
||||
return 'createBoard';
|
||||
},
|
||||
});
|
||||
|
||||
// Open the sidebar on `view`, or shut it when it is already showing that view.
|
||||
// `mustStayOpen` is Filter's exception: see models/lib/sidebarViewButton.js.
|
||||
function toggleSidebarView(view, mustStayOpen) {
|
||||
const sidebar = getSidebarInstance();
|
||||
if (!sidebar) {
|
||||
console.warn('Sidebar not available for setView');
|
||||
return;
|
||||
}
|
||||
// Open AND on this view: a sidebar open on Activities is showing neither, and
|
||||
// clicking either button there has to switch to it rather than close.
|
||||
const isShowingView = sidebar.isOpen() && sidebar.getView() === view;
|
||||
if (sidebarViewButtonAction(isShowingView, mustStayOpen) === SIDEBAR_VIEW_CLOSE) {
|
||||
sidebar.hide();
|
||||
return;
|
||||
}
|
||||
sidebar.setView(view);
|
||||
}
|
||||
onCreated() {
|
||||
this.visibilityMenuIsOpen = new ReactiveVar(false);
|
||||
this.visibility = new ReactiveVar('private');
|
||||
this.boardId = new ReactiveVar('');
|
||||
},
|
||||
|
||||
Template.boardHeaderButtons.events({
|
||||
// The board's controls fold into the caret at the start of the group. Named
|
||||
// by the words the app already has for it - Collapse and Uncollapse - rather
|
||||
// than a key of its own in 147 language files. client/lib/foldState.js
|
||||
'click .js-toggle-fold'(event) {
|
||||
event.preventDefault();
|
||||
toggleFold(event.currentTarget.dataset.fold);
|
||||
},
|
||||
'keydown .js-toggle-fold'(event) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
toggleFold(event.currentTarget.dataset.fold);
|
||||
},
|
||||
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
|
||||
'click .js-watch-board': Popup.open('boardChangeWatch'),
|
||||
// Boards in Archive is a SECTION of All Boards, not a page of its own: the
|
||||
// row in its left menu. `/archive` is the full-width page that section
|
||||
// replaced. docs/Features/Page/Archive.md
|
||||
'click .js-open-archived-board'() {
|
||||
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
|
||||
},
|
||||
// The button that opens the filter sidebar also shuts it. It only ever
|
||||
// opened, so a second click did nothing visible and the only way back was the
|
||||
// sidebar's own X - somewhere else on screen from the thing you just clicked.
|
||||
//
|
||||
// Not while a filter is ON, though: the sidebar is then the one place that
|
||||
// says what is being hidden from the board, and closing it would leave a
|
||||
// board showing a subset of its cards with nothing on screen to say so. The X
|
||||
// beside this button is what clears the filter.
|
||||
// models/lib/sidebarViewButton.js
|
||||
'click .js-open-filter-view'() {
|
||||
toggleSidebarView('filter', Filter.isActive());
|
||||
},
|
||||
'click .js-sort-cards': Popup.open('cardsSort'),
|
||||
/*
|
||||
'click .js-open-sort-view'(evt) {
|
||||
const target = evt.target;
|
||||
if (target.tagName === 'I') {
|
||||
// click on the text, popup choices
|
||||
this.changeDirection();
|
||||
} else {
|
||||
// change the sort order
|
||||
Popup.open('listsort')(evt);
|
||||
}
|
||||
},
|
||||
*/
|
||||
'click .js-filter-reset'(event) {
|
||||
event.stopPropagation();
|
||||
const sidebar = getSidebarInstance();
|
||||
if (sidebar) {
|
||||
sidebar.setView();
|
||||
} else {
|
||||
console.warn('Sidebar not available for setView');
|
||||
}
|
||||
Filter.reset();
|
||||
},
|
||||
'click .js-sort-reset'() {
|
||||
setCardsSortBy('');
|
||||
},
|
||||
// Search shuts what it opened too, with no exception: its results are inside
|
||||
// the panel, so closing it hides nothing from the board the way closing an
|
||||
// active filter's panel would. models/lib/sidebarViewButton.js
|
||||
'click .js-open-search-view'() {
|
||||
toggleSidebarView('search', false);
|
||||
},
|
||||
'click .js-toggle-dependencies'() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
currentBoard.setShowDependencies(!currentBoard.showDependencies);
|
||||
}
|
||||
},
|
||||
'click .js-multiselection-activate'() {
|
||||
const currentCard = Utils.getCurrentCardId();
|
||||
MultiSelection.activate();
|
||||
if (currentCard) {
|
||||
MultiSelection.add(currentCard);
|
||||
}
|
||||
},
|
||||
'click .js-multiselection-reset'(event) {
|
||||
event.stopPropagation();
|
||||
MultiSelection.disable();
|
||||
},
|
||||
'click .js-log-in'() {
|
||||
FlowRouter.go('atSignIn');
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeViewPopup.events({
|
||||
'click .js-open-lists-view'() {
|
||||
Utils.setBoardView('board-view-lists');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-swimlanes-view'() {
|
||||
Utils.setBoardView('board-view-swimlanes');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-cal-view'() {
|
||||
Utils.setBoardView('board-view-cal');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-gantt-view'() {
|
||||
Utils.setBoardView('board-view-gantt');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-table-view'() {
|
||||
Utils.setBoardView('board-view-table');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-stats-view'() {
|
||||
Utils.setBoardView('board-view-stats');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-time-view'() {
|
||||
Utils.setBoardView('board-view-time');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-dashboard-view'() {
|
||||
Utils.setBoardView('board-view-dashboard');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-burndown-view'() {
|
||||
Utils.setBoardView('board-view-burndown');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-burnup-view'() {
|
||||
Utils.setBoardView('board-view-burnup');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-cumulative-flow-view'() {
|
||||
Utils.setBoardView('board-view-cumulative-flow');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-control-chart-view'() {
|
||||
Utils.setBoardView('board-view-control-chart');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-cycle-time-view'() {
|
||||
Utils.setBoardView('board-view-cycle-time');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-flow-efficiency-view'() {
|
||||
Utils.setBoardView('board-view-flow-efficiency');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-lead-time-view'() {
|
||||
Utils.setBoardView('board-view-lead-time');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-throughput-histogram-view'() {
|
||||
Utils.setBoardView('board-view-throughput-histogram');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-wip-run-view'() {
|
||||
Utils.setBoardView('board-view-wip-run');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// Shared setup for all create board popups
|
||||
function setupCreateBoardState(tpl) {
|
||||
tpl.visibilityMenuIsOpen = new ReactiveVar(false);
|
||||
tpl.visibility = new ReactiveVar('private');
|
||||
tpl.boardId = new ReactiveVar('');
|
||||
// #5850: default to creating a regular board; the dedicated
|
||||
// createTemplateContainerPopup sets this true in its onRendered.
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
}
|
||||
|
||||
function createBoardHelpers() {
|
||||
return {
|
||||
createBoardOwner() {
|
||||
return Template.instance();
|
||||
},
|
||||
visibilityMenuIsOpen() {
|
||||
return Template.instance().visibilityMenuIsOpen.get();
|
||||
},
|
||||
visibility() {
|
||||
return Template.instance().visibility.get();
|
||||
},
|
||||
notAllowPrivateVisibilityOnly() {
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
visibilityCheck() {
|
||||
return Template.currentData() === Template.instance().visibility.get();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createBoardSubmit(tpl, event) {
|
||||
event.preventDefault();
|
||||
const titleInput = event.currentTarget.querySelector('.js-new-board-title');
|
||||
if (!titleInput) return;
|
||||
const title = titleInput.value;
|
||||
const slug = getSlug(title) || 'board';
|
||||
|
||||
// #5850: template boards are created via the dedicated "Add Template Board"
|
||||
// flow (createTemplateContainerPopup), signalled by this Session flag, rather
|
||||
// than a checkbox on the generic Create Board popup. Consume it immediately.
|
||||
const addTemplateContainer = Session.get('createBoardAsTemplate') === true;
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
if (addTemplateContainer) {
|
||||
tpl.boardId.set(
|
||||
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
|
||||
title,
|
||||
slug,
|
||||
permission: 'private',
|
||||
type: 'template-container',
|
||||
migrationVersion: 1,
|
||||
swimlanes: [
|
||||
{ title: 'Card Templates', sort: 1, type: 'template-container', role: 'card' },
|
||||
{ title: 'List Templates', sort: 2, type: 'template-container', role: 'list' },
|
||||
{ title: 'Board Templates', sort: 3, type: 'template-container', role: 'board' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Assign to space if one was selected
|
||||
const spaceId = Session.get('createBoardInWorkspace');
|
||||
if (spaceId) {
|
||||
Meteor.call('assignBoardToWorkspace', tpl.boardId.get(), spaceId, (err) => {
|
||||
if (err) console.error('Error assigning board to space:', err);
|
||||
});
|
||||
Session.set('createBoardInWorkspace', null); // Clear after use
|
||||
}
|
||||
|
||||
FlowRouter.go('board', { id: tpl.boardId.get(), slug });
|
||||
|
||||
} else {
|
||||
const visibility = tpl.visibility.get();
|
||||
|
||||
tpl.boardId.set(
|
||||
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
|
||||
title,
|
||||
slug,
|
||||
permission: visibility,
|
||||
migrationVersion: 1,
|
||||
swimlanes: [{ title: 'Default' }],
|
||||
}),
|
||||
);
|
||||
|
||||
// Assign to space if one was selected
|
||||
const spaceId = Session.get('createBoardInWorkspace');
|
||||
if (spaceId) {
|
||||
Meteor.call('assignBoardToWorkspace', tpl.boardId.get(), spaceId, (err) => {
|
||||
if (err) console.error('Error assigning board to space:', err);
|
||||
});
|
||||
Session.set('createBoardInWorkspace', null); // Clear after use
|
||||
}
|
||||
|
||||
FlowRouter.go('board', { id: tpl.boardId.get(), slug });
|
||||
}
|
||||
}
|
||||
|
||||
function createBoardOwner(tpl) {
|
||||
return tpl.data && tpl.data.owner;
|
||||
}
|
||||
|
||||
// Blaze events are scoped to the template whose rendered DOM contains the
|
||||
// selector. The form is an included child template, so attaching submit to its
|
||||
// parent popup leaves the Create button inert. Keep state on the parent, but
|
||||
// attach the shared form's events here and explicitly pass its owner in.
|
||||
Template.createBoardForm.events({
|
||||
'click .js-select-visibility'(event, tpl) {
|
||||
const owner = createBoardOwner(tpl);
|
||||
owner.visibility.set(this);
|
||||
owner.visibilityMenuIsOpen.set(false);
|
||||
},
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
const owner = createBoardOwner(tpl);
|
||||
owner.visibilityMenuIsOpen.set(!owner.visibilityMenuIsOpen.get());
|
||||
},
|
||||
async submit(event, tpl) {
|
||||
const owner = createBoardOwner(tpl);
|
||||
const starAfterCreate = tpl.data.starAfterCreate === true;
|
||||
await createBoardSubmit(owner, event);
|
||||
if (starAfterCreate) {
|
||||
await Meteor.callAsync('toggleBoardStar', owner.boardId.get());
|
||||
}
|
||||
},
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
});
|
||||
|
||||
// createBoard (non-popup version)
|
||||
Template.createBoard.onCreated(function () {
|
||||
setupCreateBoardState(this);
|
||||
});
|
||||
|
||||
Template.createBoard.helpers(createBoardHelpers());
|
||||
|
||||
// createBoardPopup
|
||||
Template.createBoardPopup.onCreated(function () {
|
||||
setupCreateBoardState(this);
|
||||
});
|
||||
|
||||
Template.createBoardPopup.helpers(createBoardHelpers());
|
||||
|
||||
// createTemplateContainerPopup
|
||||
Template.createTemplateContainerPopup.onCreated(function () {
|
||||
setupCreateBoardState(this);
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.onRendered(function () {
|
||||
// #5850: this dedicated popup always creates a template board.
|
||||
Session.set('createBoardAsTemplate', true);
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.helpers(createBoardHelpers());
|
||||
|
||||
// headerBarCreateBoardPopup
|
||||
Template.headerBarCreateBoardPopup.onCreated(function () {
|
||||
setupCreateBoardState(this);
|
||||
});
|
||||
|
||||
Template.headerBarCreateBoardPopup.helpers(createBoardHelpers());
|
||||
|
||||
Template.boardVisibilityList.helpers({
|
||||
notAllowPrivateVisibilityOnly() {
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeVisibilityPopup.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
});
|
||||
|
||||
Template.boardChangeVisibilityPopup.helpers({
|
||||
notAllowPrivateVisibilityOnly(){
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
visibilityCheck() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return this === currentBoard.permission;
|
||||
return this.currentData() === this.visibility.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeVisibilityPopup.events({
|
||||
'click .js-select-visibility'() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const visibility = this;
|
||||
if (typeof visibility === 'string') {
|
||||
currentBoard.setVisibility(visibility);
|
||||
Popup.back();
|
||||
}
|
||||
setVisibility(visibility) {
|
||||
this.visibility.set(visibility);
|
||||
this.visibilityMenuIsOpen.set(false);
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeWatchPopup.helpers({
|
||||
toggleVisibilityMenu() {
|
||||
this.visibilityMenuIsOpen.set(!this.visibilityMenuIsOpen.get());
|
||||
},
|
||||
|
||||
onSubmit(evt) {
|
||||
evt.preventDefault();
|
||||
const title = this.find('.js-new-board-title').value;
|
||||
const visibility = this.visibility.get();
|
||||
|
||||
this.boardId.set(Boards.insert({
|
||||
title,
|
||||
permission: visibility,
|
||||
}));
|
||||
|
||||
Swimlanes.insert({
|
||||
title: 'Default',
|
||||
boardId: this.boardId.get(),
|
||||
});
|
||||
|
||||
Utils.goBoardId(this.boardId.get());
|
||||
},
|
||||
|
||||
events() {
|
||||
return [{
|
||||
'click .js-select-visibility'() {
|
||||
this.setVisibility(this.currentData());
|
||||
},
|
||||
'click .js-change-visibility': this.toggleVisibilityMenu,
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
submit: this.onSubmit,
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
}];
|
||||
},
|
||||
}).register('createBoardPopup');
|
||||
|
||||
(class HeaderBarCreateBoard extends CreateBoard {
|
||||
onSubmit(evt) {
|
||||
super.onSubmit(evt);
|
||||
// Immediately star boards crated with the headerbar popup.
|
||||
Meteor.user().toggleBoardStar(this.boardId.get());
|
||||
}
|
||||
}).register('headerBarCreateBoardPopup');
|
||||
|
||||
BlazeComponent.extendComponent({
|
||||
visibilityCheck() {
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
return this.currentData() === currentBoard.permission;
|
||||
},
|
||||
|
||||
selectBoardVisibility() {
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
const visibility = this.currentData();
|
||||
currentBoard.setVisibility(visibility);
|
||||
Popup.close();
|
||||
},
|
||||
|
||||
events() {
|
||||
return [{
|
||||
'click .js-select-visibility': this.selectBoardVisibility,
|
||||
}];
|
||||
},
|
||||
}).register('boardChangeVisibilityPopup');
|
||||
|
||||
BlazeComponent.extendComponent({
|
||||
watchLevel() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const currentBoard = Boards.findOne(Session.get('currentBoard'));
|
||||
return currentBoard.getWatchLevel(Meteor.userId());
|
||||
},
|
||||
|
||||
watchCheck() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return this === currentBoard.getWatchLevel(Meteor.userId());
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeWatchPopup.events({
|
||||
'click .js-select-watch'() {
|
||||
// `this` is the data context of the clicked row - the string from the
|
||||
// enclosing {{#with "watching"}}. Blaze can hand that back as a boxed
|
||||
// String, so read it through String() rather than refusing anything that is
|
||||
// not typeof 'string'.
|
||||
const level = this === null || this === undefined ? '' : String(this);
|
||||
if (!level) return;
|
||||
Meteor.call(
|
||||
'watch',
|
||||
'board',
|
||||
Session.get('currentBoard'),
|
||||
level,
|
||||
(err, ret) => {
|
||||
if (!err && ret) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
// AN ERROR MUST NOT BE SILENT. It was: the callback closed the popup on
|
||||
// success and did nothing at all otherwise, so a refusal looked exactly
|
||||
// like a broken button - "Silent does not respond. If we try to change
|
||||
// it does not change. Nothing happens." (email, 2026-08-13). The server
|
||||
// refuses for two reasons an admin can act on: the watch feature is
|
||||
// turned off in the Admin Panel, and the board is not visible to this
|
||||
// user.
|
||||
const reason = err && (err.error || err.reason || err.message);
|
||||
const message = reason === 'error-watch-disabled'
|
||||
? TAPi18n.__('error-watch-disabled')
|
||||
: TAPi18n.__('error-board-notAMember');
|
||||
// eslint-disable-next-line no-alert
|
||||
if (typeof window !== 'undefined' && window.alert) window.alert(message);
|
||||
if (process.env.DEBUG === 'true') console.error('watch failed:', err);
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
// BlazeComponent.extendComponent was removed - this code is unused.
|
||||
// Original listsortPopup component:
|
||||
// {
|
||||
onCreated() {
|
||||
//this.sortBy = new ReactiveVar();
|
||||
////this.sortDirection = new ReactiveVar();
|
||||
//this.setSortBy();
|
||||
this.downClass = DOWNCLS;
|
||||
this.upClass = UPCLS;
|
||||
},
|
||||
allowedSortValues() {
|
||||
const types = [];
|
||||
const pushed = {};
|
||||
ReactiveCache.getCurrentUser()
|
||||
.getListSortTypes()
|
||||
.forEach(type => {
|
||||
const key = type.replace(/^-/, '');
|
||||
if (pushed[key] === undefined) {
|
||||
types.push({
|
||||
name: key,
|
||||
label: `list-label-${key}`,
|
||||
shortLabel: `list-label-short-${key}`,
|
||||
});
|
||||
pushed[key] = 1;
|
||||
}
|
||||
});
|
||||
return types;
|
||||
},
|
||||
Direction() {
|
||||
return ReactiveCache.getCurrentUser().getListSortByDirection() === -1
|
||||
? this.downClass
|
||||
: this.upClass;
|
||||
},
|
||||
sortby() {
|
||||
return ReactiveCache.getCurrentUser().getListSortBy();
|
||||
},
|
||||
|
||||
setSortBy(type = null) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (type === null) {
|
||||
type = user._getListSortBy();
|
||||
} else {
|
||||
let value = '';
|
||||
if (type.map) {
|
||||
// is an array
|
||||
value = (type[1] === -1 ? '-' : '') + type[0];
|
||||
}
|
||||
Meteor.call('setListSortBy', value);
|
||||
}
|
||||
//this.sortBy.set(type[0]);
|
||||
//this.sortDirection.set(type[1]);
|
||||
return this.currentData() === this.watchLevel();
|
||||
},
|
||||
|
||||
events() {
|
||||
return [
|
||||
{
|
||||
'click .js-sort-by'(evt) {
|
||||
evt.preventDefault();
|
||||
const target = evt.target;
|
||||
const sortby = target.getAttribute('name');
|
||||
const down = !!target.querySelector(`.${this.upClass}`);
|
||||
const direction = down ? -1 : 1;
|
||||
this.setSortBy([sortby, direction]);
|
||||
if (Utils.isMiniScreen) {
|
||||
Popup.back();
|
||||
}
|
||||
},
|
||||
return [{
|
||||
'click .js-select-watch'() {
|
||||
const level = this.currentData();
|
||||
Meteor.call('watch', 'board', Session.get('currentBoard'), level, (err, ret) => {
|
||||
if (!err && ret) Popup.close();
|
||||
});
|
||||
},
|
||||
];
|
||||
}];
|
||||
},
|
||||
// }.register('listsortPopup');
|
||||
*/
|
||||
|
||||
Template.cardsSortPopup.events({
|
||||
'click .js-sort-due'() {
|
||||
const sortBy = {
|
||||
dueAt: 1,
|
||||
};
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('due-date'));
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-sort-title'() {
|
||||
const sortBy = {
|
||||
title: 1,
|
||||
};
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('title'));
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-sort-created-asc'() {
|
||||
const sortBy = {
|
||||
createdAt: 1,
|
||||
};
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('date-created-newest-first'));
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-sort-created-desc'() {
|
||||
const sortBy = {
|
||||
createdAt: -1,
|
||||
};
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('date-created-oldest-first'));
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// The board's view menu is its own template in the FIRST header bar now, so its
|
||||
// handler is here rather than on the second bar that used to draw it.
|
||||
// docs/Features/Page/Header.md
|
||||
Template.boardViewMenu.events({
|
||||
'click .js-toggle-board-view': Popup.open('boardChangeView'),
|
||||
});
|
||||
|
||||
Template.boardViewMenu.helpers({
|
||||
boardView() {
|
||||
return Utils.boardView();
|
||||
},
|
||||
// The tooltip: the name of the view that is ON. The button is icon only, so
|
||||
// this is the only place the name appears. docs/Features/Page/Header.md
|
||||
boardViewName() {
|
||||
const names = {
|
||||
'board-view-swimlanes': 'swimlanes',
|
||||
'board-view-lists': 'lists',
|
||||
'board-view-cal': 'calendar',
|
||||
'board-view-gantt': 'gantt',
|
||||
'board-view-table': 'board-view-table',
|
||||
'board-view-stats': 'board-view-stats',
|
||||
'board-view-time': 'board-view-time',
|
||||
'board-view-dashboard': 'board-view-dashboard',
|
||||
'board-view-burndown': 'board-view-burndown',
|
||||
'board-view-burnup': 'board-view-burnup',
|
||||
'board-view-cumulative-flow': 'board-view-cumulative-flow',
|
||||
'board-view-control-chart': 'board-view-control-chart',
|
||||
'board-view-cycle-time': 'board-view-cycle-time',
|
||||
'board-view-flow-efficiency': 'board-view-flow-efficiency',
|
||||
'board-view-lead-time': 'board-view-lead-time',
|
||||
'board-view-throughput-histogram': 'board-view-throughput-histogram',
|
||||
'board-view-wip-run': 'board-view-wip-run',
|
||||
};
|
||||
return TAPi18n.__(names[Utils.boardView()] || 'board-view');
|
||||
},
|
||||
});
|
||||
|
||||
// The board is renamed by clicking its NAME in the first header bar, which is
|
||||
// handled where that bar is: client/components/main/header.js. The pencil that
|
||||
// used to be here is gone with the template it lived in.
|
||||
}).register('boardChangeWatchPopup');
|
||||
@@ -0,0 +1,22 @@
|
||||
.integration-form
|
||||
padding: 5px
|
||||
border-bottom: 1px solid #ccc
|
||||
|
||||
.flex
|
||||
display: -webkit-box
|
||||
display: -moz-box
|
||||
display: -webkit-flex
|
||||
display: -moz-flex
|
||||
display: -ms-flexbox
|
||||
display: flex
|
||||
|
||||
.option
|
||||
@extends .flex
|
||||
-webkit-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
margin-top: 5px;
|
||||
padding: 5px;
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Pure helpers for building the option labels shown in board / list /
|
||||
* swimlane selectors (e.g. the move-card and copy-card popups).
|
||||
*
|
||||
* Regression #6135 ("numbering boards, list, swim caused some issues"):
|
||||
* a previous change prepended a sequential number to every option label
|
||||
* (e.g. "3. My Board"). That broke two things:
|
||||
*
|
||||
* 1. Boards whose name itself starts with a digit became hard to read.
|
||||
* 2. First-letter keyboard navigation in the native <select> dropdown
|
||||
* stopped working, because every option started with a number instead
|
||||
* of the board name.
|
||||
*
|
||||
* The fix: numbering is OPTIONAL and OFF by default. By default the label is
|
||||
* exactly the entity name, so its first character is the name's first
|
||||
* character and first-letter navigation works again. When numbering is
|
||||
* explicitly enabled, the number is appended as a trailing visual suffix so
|
||||
* the label still STARTS with the name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the label for a selectable board/list/swimlane option.
|
||||
*
|
||||
* @param {string} name The entity (board/list/swimlane) name/title.
|
||||
* @param {number} index Zero-based position in the list.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.numbered=false] When true, append the 1-based
|
||||
* number as a trailing suffix. When false (default), return the bare
|
||||
* name so first-letter keyboard navigation keeps working.
|
||||
* @returns {string} The option label.
|
||||
*/
|
||||
export function boardOptionLabel(name, index, { numbered = false } = {}) {
|
||||
const label = name == null ? '' : String(name);
|
||||
if (!numbered) {
|
||||
return label;
|
||||
}
|
||||
const position = Number(index);
|
||||
const number = Number.isFinite(position) ? position + 1 : index;
|
||||
return `${label} (${number})`;
|
||||
}
|
||||
|
||||
export default boardOptionLabel;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,557 +1,49 @@
|
||||
template(name="boardList")
|
||||
//- `sidebar-open` moves the boards LEFT while the right sidebar is open, so
|
||||
every board icon stays visible between the left menu and the panel instead
|
||||
of hiding under it. Desktop only - on a phone the sidebar is full width and
|
||||
there is nothing to move aside. boardsList.css
|
||||
.wrapper.all-boards-wrapper(class="{{#if isSidebarOpen}}sidebar-open{{/if}}")
|
||||
+allBoardsSidebar
|
||||
//- The folded state is marked on the GRID, not on the menu inside it: a
|
||||
hidden grid ITEM still leaves its track behind, so the boards would keep
|
||||
a 260px-wide nothing beside them. The grid drops to one column and hides
|
||||
the menu in the same rule. The Admin Panel marks its menu instead, because
|
||||
a flex row with a hidden item simply closes up.
|
||||
docs/Features/Page/Left-Menu.md
|
||||
.boards-layout(class="{{#if isLeftMenuCollapsed}}collapsed{{/if}}")
|
||||
// Left menu
|
||||
.boards-left-menu
|
||||
//- The caret that folds it away - the Admin Panel's own, so the two
|
||||
menus cannot end up with two different carets, and the same control a
|
||||
list has on a board. Folded, the menu is not drawn at all and the
|
||||
caret that brings it back rides on the pane title below.
|
||||
docs/Features/Page/Left-Menu.md
|
||||
+leftMenuCollapse
|
||||
ul.menu
|
||||
//- ONE row, rendered four times, rather than four copies of the same
|
||||
eight lines. Their ORDER depends on the user - Starred first if
|
||||
anything is starred, Remaining first if not - and four copies could
|
||||
not be reordered without moving markup about.
|
||||
menuSections() asks models/lib/allBoardsUrls.js for the order.
|
||||
.wrapper
|
||||
ul.board-list.clearfix
|
||||
li.js-add-board
|
||||
a.board-list-item.label {{_ 'add-board'}}
|
||||
each boards
|
||||
li(class="{{#if isStarred}}starred{{/if}}" class=colorClass)
|
||||
if isInvited
|
||||
.board-list-item
|
||||
span.details
|
||||
span.board-list-item-name= title
|
||||
i.fa.js-star-board(
|
||||
class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
|
||||
title="{{_ 'star-board-title'}}")
|
||||
p.board-list-item-desc {{_ 'just-invited'}}
|
||||
button.js-accept-invite.primary {{_ 'accept'}}
|
||||
button.js-decline-invite {{_ 'decline'}}
|
||||
else
|
||||
a.js-open-board.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}")
|
||||
span.details
|
||||
span.board-list-item-name
|
||||
+viewer
|
||||
= title
|
||||
i.fa.js-star-board(
|
||||
class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
|
||||
title="{{_ 'star-board-title'}}")
|
||||
p.board-list-item-desc
|
||||
+viewer
|
||||
= description
|
||||
if hasSpentTimeCards
|
||||
i.fa.js-has-spenttime-cards(
|
||||
class="fa-circle{{#if hasOvertimeCards}} has-overtime-card-active{{else}} no-overtime-card-active{{/if}}"
|
||||
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
|
||||
i.fa.js-clone-board(
|
||||
class="fa-clone"
|
||||
title="{{_ 'duplicate-board'}}")
|
||||
i.fa.js-archive-board(
|
||||
class="fa-archive"
|
||||
title="{{_ 'archive-board'}}")
|
||||
|
||||
"Archive", not "Boards in Archive": one word, like its neighbours,
|
||||
and the menu already says what it lists. `archives` is an existing
|
||||
key, translated in the same 135 languages, and the one already used
|
||||
for the archive as a PLACE.
|
||||
each menuSections
|
||||
li(class="menu-item {{#if isSelectedMenu type}}active{{/if}}")
|
||||
a.js-select-menu(class="{{extraClass}}" data-type="{{type}}")
|
||||
span.menu-label
|
||||
span.emoji-icon
|
||||
i.fa(class="{{icon}}")
|
||||
| {{_ labelKey}}
|
||||
span.menu-count {{sectionCount type}}
|
||||
//- The share targets belong to the TEMPLATES row, so they are drawn
|
||||
inside the loop directly under it - not after the whole list,
|
||||
where they would follow the Archive and read as its sub-list.
|
||||
if $eq type 'templates'
|
||||
if isSelectedMenu 'templates'
|
||||
if shareTargets.length
|
||||
li.menu-section-title {{_ 'share-template-with'}}
|
||||
each shareTargets
|
||||
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
|
||||
span.menu-label
|
||||
span.emoji-icon
|
||||
i.fa.fa-share-alt
|
||||
| {{name}}
|
||||
//- Rules above and below the Workspaces section. The menu is three
|
||||
kinds of thing in one column - the three board lists, the workspaces
|
||||
tree, and the archive - and without them the tree ran into its
|
||||
neighbours as if it were more of the same list.
|
||||
hr.boards-menu-divider
|
||||
.workspaces-header
|
||||
span
|
||||
span.emoji-icon
|
||||
i.fa.fa-folder-open
|
||||
| {{_ 'allboards.workspaces'}}
|
||||
a.js-add-workspace(title="{{_ 'allboards.add-workspace'}}") +
|
||||
// Workspaces tree
|
||||
+workspaceTree(nodes=workspacesTree selectedWorkspaceId=selectedWorkspaceId)
|
||||
hr.boards-menu-divider
|
||||
|
||||
// Existing filter by orgs/teams (kept)
|
||||
ul.AllBoardTeamsOrgs
|
||||
li.AllBoardTeams
|
||||
if userHasTeams
|
||||
select.js-AllBoardTeams#jsAllBoardTeams("multiple")
|
||||
option(value="-1") {{_ 'teams'}} :
|
||||
each teamsDatas
|
||||
option(value="{{teamId}}") {{_ teamDisplayName}}
|
||||
|
||||
li.AllBoardOrgs
|
||||
if userHasOrgs
|
||||
select.js-AllBoardOrgs#jsAllBoardOrgs("multiple")
|
||||
option(value="-1") {{_ 'organizations'}} :
|
||||
each orgsDatas
|
||||
option(value="{{orgId}}") {{orgDisplayName}}
|
||||
|
||||
li.AllBoardBtns
|
||||
div.AllBoardButtonsContainer
|
||||
if userHasOrgsOrTeams
|
||||
span.emoji-icon
|
||||
i.fa.fa-search
|
||||
input#filterBtn(type="button" value="{{_ 'filter'}}")
|
||||
button#resetBtn.filter-reset-btn
|
||||
span.reset-icon
|
||||
span.emoji-icon
|
||||
i.fa.fa-times-thin
|
||||
span {{_ 'filter-clear'}}
|
||||
|
||||
//- The grip that changes the menu's width, the Admin Panel's own. It is a
|
||||
child of the GRID and not of the menu: it is positioned absolutely, so it
|
||||
is no grid item and adds no track, and it does not scroll away with a
|
||||
long list of workspaces the way a handle inside the menu would.
|
||||
docs/Features/Page/Left-Menu.md
|
||||
+leftMenuResize
|
||||
|
||||
// Right boards grid
|
||||
//- There is NO bar above the boards, and no second header bar either.
|
||||
Every control of this page - Sort, Search, Multi-Selection, the view
|
||||
menu and the actions on a selection - is in the right sidebar, and the
|
||||
page is named by the FIRST header bar. docs/Features/Page/All-Boards.md
|
||||
.boards-right-grid
|
||||
//- The heading of the pane: which list of boards this is. It sits above
|
||||
the view branch, so it is drawn ONCE whichever view is on - the board
|
||||
icons and the Table are two ways of showing the same section, not two
|
||||
sections.
|
||||
The Admin Panel's own `paneTitle`, so the two pages have one heading
|
||||
at one size and colour. docs/Features/Page/All-Boards.md
|
||||
+paneTitle(allBoardsPaneTitle)
|
||||
if showsBoardSelectionControls
|
||||
.board-multiselection-controls
|
||||
button.primary.js-board-select-all(type="button") {{_ 'select-all'}}
|
||||
button.primary.js-board-select-none(type="button") {{_ 'select-none'}}
|
||||
//- The Archive is a SECTION of this page, drawn here beside the left
|
||||
menu, not a page of its own that replaces it - and drawn with the same
|
||||
board ICONS every other section uses. It had a table of its own, which
|
||||
meant the one place you go to bring a board back was the one place
|
||||
multi-selection, dragging and the tile layout did not work.
|
||||
docs/Features/Page/Archive.md
|
||||
if isAllBoardsView 'table'
|
||||
//- The create action is supplied to tablePage with its other controls,
|
||||
rather than occupying an otherwise empty row of its own. Archive and
|
||||
Home deliberately expose neither action.
|
||||
+tablePage(tablePageData)
|
||||
else
|
||||
if boardsPaginationActive
|
||||
.boards-pagination
|
||||
button.js-boards-prev-page(class="{{#if hasBoardsPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.boards-page-info {{boardsCurrentPage}} / {{boardsTotalPages}}
|
||||
button.js-boards-next-page(class="{{#if hasBoardsNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
|
||||
//- The Remove bar, drawn only while a board from Home is actually
|
||||
being dragged - the launcher's, which appears at the top of the
|
||||
screen when an icon is picked up and is not there the rest of the
|
||||
time. Dropping a board here takes it off Home; the board itself
|
||||
stays where it lives, the way an app stays in the drawer.
|
||||
docs/Features/Board/Home.md
|
||||
if showsHomeRemoveTarget
|
||||
li.home-remove-target.js-home-remove
|
||||
i.fa.fa-trash
|
||||
span {{_ 'home-board-remove'}}
|
||||
//- Home is a mark on ONE board rather than a place boards are
|
||||
kept, so an empty Home says what to drag instead of showing a
|
||||
blank pane.
|
||||
if isSelectedMenu 'home'
|
||||
unless boards.length
|
||||
li.board-list-item-empty {{_ 'home-board-empty'}}
|
||||
//- No "Add Board" in the Archive - a board cannot be created
|
||||
already archived - nor in Home, where a new board would not be
|
||||
the board that opens after login: the tile would promise
|
||||
something it cannot do. ONE helper rather than two nested
|
||||
conditions, because nesting one moved the tile's own children out
|
||||
from under it.
|
||||
if showsAddBoardTile
|
||||
li.js-add-board
|
||||
if isSelectedMenu 'templates'
|
||||
a.board-list-item.label.is-template-container(title="{{_ 'add-template-container'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-template-container'}}
|
||||
else
|
||||
a.board-list-item.label(title="{{_ 'add-board'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-board'}}
|
||||
//- The BOOKMARKS, in the Starred section beside the starred
|
||||
boards - because Starred is the list of places you keep, and a
|
||||
bookmark is one of them. A tile rather than a row, so it sits in
|
||||
the same grid as the boards; a white border, so it is legible as
|
||||
a different KIND of thing without being a different shape; and
|
||||
the theme's own colour behind it, so it belongs to this WeKan.
|
||||
It carries the title AND the URL: the title says where it goes
|
||||
and the URL says what it is, which is what a bookmark bar shows.
|
||||
Dragging one past another reorders them, and that order is the
|
||||
order of the header dropdown - the two are views of one array.
|
||||
docs/Features/Board/Starred.md
|
||||
if isSelectedMenu 'starred'
|
||||
each starredPages
|
||||
li.js-bookmark.nodragscroll.board-list-item-bookmark(data-url="{{url}}", draggable="true")
|
||||
a.board-list-item(href="{{url}}", title="{{title}}")
|
||||
span.bookmark-title
|
||||
i.fa.fa-star
|
||||
| {{title}}
|
||||
span.bookmark-url {{url}}
|
||||
//- A SIBLING of the link, not a child of it: an `a` inside an
|
||||
`a` is invalid HTML, and browsers repair it by closing the
|
||||
outer one early - which would leave the URL line outside the
|
||||
link that is supposed to carry it.
|
||||
a.js-unstar-bookmark.bookmark-unstar(href="#", title="{{_ 'click-to-unstar-page'}}")
|
||||
i.fa.fa-times
|
||||
each boards
|
||||
li.js-board.nodragscroll(class="{{_id}} {{#if isStarred}}starred{{/if}} {{colorClass}} {{#if isInvited}}is-invited{{/if}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}", draggable="true")
|
||||
if isInvited
|
||||
.board-list-item
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
span.details
|
||||
span.board-list-item-name= title
|
||||
span.js-star-board(
|
||||
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
|
||||
title="{{_ 'star-board-title'}}")
|
||||
span.emoji-icon
|
||||
i.fa(class="{{#if isStarred}}fa-star{{else}}fa-star-o{{/if}}")
|
||||
p.board-list-item-desc {{_ 'just-invited'}}
|
||||
button.js-accept-invite.primary {{_ 'accept'}}
|
||||
button.js-decline-invite {{_ 'decline'}}
|
||||
else
|
||||
if $eq type "template-container"
|
||||
.template-container.board-list-item
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
//- The drag handle obeys the same "Show desktop drag handles"
|
||||
setting as swimlanes, lists and cards. With handles ON it is
|
||||
the only place a board drag may start, so the rest of the
|
||||
tile is free to dragscroll; with handles OFF there is no
|
||||
handle and the whole tile is the drag source.
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
|
||||
span.details
|
||||
span.board-list-item-name(title="{{_ 'template-container'}}")
|
||||
+viewer
|
||||
= title
|
||||
p.board-list-item-desc
|
||||
+viewer
|
||||
= description
|
||||
//- When you are looking at the Archive, when it was
|
||||
archived - that is what you sort an archive by, and
|
||||
what tells one old board from another.
|
||||
client/components/boards/boardsList.js
|
||||
if isSelectedMenu 'archive'
|
||||
p.board-list-item-archived-at
|
||||
| {{_ 'archived-at'}}
|
||||
= ' '
|
||||
= archivedAtText
|
||||
if hasSpentTimeCards
|
||||
span.js-has-spenttime-cards(
|
||||
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
|
||||
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-clock-o
|
||||
span.js-star-board(
|
||||
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
|
||||
title="{{_ 'star-board-title'}}")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
else
|
||||
//- #5157: reuse the board's existing backgroundImageURL as the
|
||||
//- tile background on the board list page.
|
||||
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
//- #2220: the Home board (opened after login) is badged with a
|
||||
home icon at the top centre of its tile.
|
||||
if isDefaultBoard
|
||||
span.board-home-badge(title="{{_ 'home-board-badge'}}")
|
||||
i.fa.fa-home
|
||||
//- The drag handle obeys the same "Show desktop drag handles"
|
||||
setting as swimlanes, lists and cards. With handles ON it is
|
||||
the only place a board drag may start, so the rest of the
|
||||
tile is free to dragscroll; with handles OFF there is no
|
||||
handle and the whole tile is the drag source.
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
|
||||
span.details
|
||||
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
|
||||
+viewer
|
||||
= title
|
||||
//- #5174 / #4825: the per-board allows* flags are
|
||||
//- resolved server-side into the one-shot tile data
|
||||
//- (the raw fields are not even published to this
|
||||
//- page), so the show* helpers below gate the whole
|
||||
//- block consistently for every board.
|
||||
unless currentSetting.hideBoardMemberList
|
||||
if showBoardMemberList _id
|
||||
.minicard-members
|
||||
each member in boardMembers _id
|
||||
a.name
|
||||
+userAvatar(userId=member noRemove=true)
|
||||
unless currentSetting.hideCardCounterList
|
||||
if showCardCounterList _id
|
||||
.minicard-lists.flex.flex-wrap
|
||||
each list in boardLists _id
|
||||
.item
|
||||
| {{ list }}
|
||||
p.board-list-item-desc
|
||||
+viewer
|
||||
= description
|
||||
//- When you are looking at the Archive, when it was
|
||||
archived - that is what you sort an archive by, and
|
||||
what tells one old board from another.
|
||||
client/components/boards/boardsList.js
|
||||
if isSelectedMenu 'archive'
|
||||
p.board-list-item-archived-at
|
||||
| {{_ 'archived-at'}}
|
||||
= ' '
|
||||
= archivedAtText
|
||||
if hasSpentTimeCards
|
||||
span.js-has-spenttime-cards(
|
||||
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
|
||||
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-clock-o
|
||||
a.js-star-board(
|
||||
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
|
||||
title="{{_ 'star-board-title'}}")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
//- #2220 Home-board toggle is disabled: the icon and the
|
||||
//- set-default-board action are intentionally not rendered.
|
||||
|
||||
template(name="boardsSortPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-boards-sort(data-sort="custom" class="{{#if isBoardsSort 'custom'}}active{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
| {{_ 'sort-boards-custom'}}
|
||||
li
|
||||
a.js-boards-sort(data-sort="title-asc" class="{{#if isBoardsSort 'title-asc'}}active{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-asc
|
||||
| {{_ 'sort-boards-title-asc'}}
|
||||
li
|
||||
a.js-boards-sort(data-sort="title-desc" class="{{#if isBoardsSort 'title-desc'}}active{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-desc
|
||||
| {{_ 'sort-boards-title-desc'}}
|
||||
|
||||
//- One row of the All Boards Table view. A rowTemplate, because the Edit cell is
|
||||
//- a control and the row carries its board's colours - neither of which a
|
||||
//- text-cell column spec can express. Cells must match the header, in order:
|
||||
//- Edit, Board title, Board description.
|
||||
template(name="allBoardsRow")
|
||||
tr.all-boards-row(class="{{colorClass}}" data-board-id="{{_id}}")
|
||||
td.all-boards-edit
|
||||
a.js-edit-board-title-row(title="{{_ 'edit'}}")
|
||||
i.fa.fa-pencil
|
||||
td.all-boards-title
|
||||
a.js-open-board-row(href="{{pathFor 'board' id=_id slug=slug}}")
|
||||
+viewer
|
||||
= title
|
||||
td.all-boards-desc
|
||||
+viewer
|
||||
= description
|
||||
|
||||
template(name="allBoardsViewPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-all-boards-view-lists
|
||||
i.fa.fa-trello
|
||||
| {{_ 'lists'}}
|
||||
if isAllBoardsView 'lists'
|
||||
i.fa.fa-check
|
||||
li
|
||||
a.js-all-boards-view-table
|
||||
i.fa.fa-table
|
||||
| {{_ 'board-view-table'}}
|
||||
if isAllBoardsView 'table'
|
||||
i.fa.fa-check
|
||||
//.board-header-btns.right
|
||||
//
|
||||
template(name="boardListHeaderBar")
|
||||
h1 {{_ 'my-boards'}}
|
||||
.board-header-btns.right
|
||||
a.board-header-btn.js-open-archived-board
|
||||
//
|
||||
i.fa.fa-archive
|
||||
//
|
||||
span {{_ 'archives'}}
|
||||
//
|
||||
a.board-header-btn(href="{{pathFor 'board' id=templatesBoardId slug=templatesBoardSlug}}")
|
||||
//
|
||||
i.fa.fa-clone
|
||||
//
|
||||
span {{_ 'templates'}}
|
||||
|
||||
// Recursive template for workspaces tree
|
||||
template(name="workspaceTree")
|
||||
if nodes
|
||||
ul.workspace-tree.js-workspace-tree
|
||||
//- `../selectedWorkspaceId`, not the bare name. Inside `each` the data
|
||||
context is the NODE, and this is a template argument rather than a
|
||||
helper - so a bare lookup found nothing on the node, `$eq` compared an
|
||||
id with undefined, and no workspace ever got the `active` class. The
|
||||
theme highlight was styled for a class that was never applied.
|
||||
each nodes
|
||||
//- Which part of the row starts a reorder follows the "Show desktop drag
|
||||
handles" toggle in the first header bar - the same setting swimlanes,
|
||||
lists, cards and the board tiles obey:
|
||||
handles ON - the ✥ handle, and only the handle;
|
||||
handles OFF - the workspace's icon AND its name, which is the whole
|
||||
`a.js-select-space`.
|
||||
The anchor rather than the icon alone: a 16px glyph is a target that
|
||||
has to be aimed at, and the name beside it is the part of the row a
|
||||
reader is actually pointing at. Clicking it still opens the workspace -
|
||||
a click and a drag are different gestures on the same element, exactly
|
||||
as they are on a board tile with handles off.
|
||||
NOT the whole row either: the row also holds the ⋯ menu and the board
|
||||
count, and a drag started on those is a drag of something else.
|
||||
`dragstart` bubbles, so the handler stays on the row in both cases.
|
||||
docs/Features/Page/All-Boards.md
|
||||
li.workspace-node(class="{{#if $eq id ../selectedWorkspaceId}}active{{/if}}" data-workspace-id="{{id}}")
|
||||
.workspace-node-content
|
||||
//- The caret that folds this workspace's own workspaces away, at
|
||||
the START of the row - before the drag handle, so the carets of a
|
||||
tree line up in one column whatever the handles toggle says. It is
|
||||
the control a list has on a board and the one the left menu itself
|
||||
has, down to the two words in its tooltip.
|
||||
A workspace with no workspaces under it has nothing to fold, so it
|
||||
gets a spacer of the same width instead: a row that shifted
|
||||
sideways when it gained a child would be a moving target.
|
||||
docs/Features/Page/Workspaces.md
|
||||
if workspaceHasChildren
|
||||
a.workspace-collapse-indicator.js-collapse-workspace(
|
||||
title="{{workspaceCollapseLabel}}"
|
||||
aria-label="{{workspaceCollapseLabel}}"
|
||||
aria-expanded="{{#if isWorkspaceCollapsed}}false{{else}}true{{/if}}"
|
||||
role="button"
|
||||
tabindex="0")
|
||||
if isWorkspaceCollapsed
|
||||
i.fa.fa-caret-right
|
||||
else
|
||||
i.fa.fa-caret-down
|
||||
else
|
||||
span.workspace-collapse-spacer
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
span.workspace-drag-handle.nodragscroll(draggable="true")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
//- `nodragscroll` for the same reason the handle carries it: the
|
||||
page-level dragscroll owns a mousedown it is not opted out of, and
|
||||
a drag that never begins is what "reordering does not work" looks
|
||||
like.
|
||||
a.js-select-workspace.js-select-space.nodragscroll(
|
||||
data-id="{{id}}"
|
||||
draggable="{{#if isTouchScreenOrShowDesktopDragHandles}}false{{else}}true{{/if}}")
|
||||
span.workspace-icon
|
||||
if icon
|
||||
span.workspace-icon-value(style="{{#if iconColor}}color: {{iconColor}};{{/if}}")
|
||||
+viewer
|
||||
= icon
|
||||
else
|
||||
span.emoji-icon(style="{{#if iconColor}}color: {{iconColor}};{{/if}}")
|
||||
i.fa.fa-folder
|
||||
span.workspace-name= name
|
||||
a.js-open-workspace-menu(data-id="{{id}}" title="{{_ 'allboards.workspace-menu'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-bars
|
||||
span.workspace-count {{workspaceCount id}}
|
||||
//- ONE condition, not `if children` wrapped in an `unless collapsed`:
|
||||
`..` counts block levels, and a second block between the `each` and
|
||||
this inclusion is another level for `../selectedWorkspaceId` to
|
||||
climb - which is how the highlight was lost once already.
|
||||
docs/Features/Page/Workspaces.md
|
||||
if workspaceShowsChildren
|
||||
+workspaceTree(nodes=children selectedWorkspaceId=../selectedWorkspaceId)
|
||||
|
||||
//- #6524: creating a workspace used window.prompt(), which a browser may refuse
|
||||
to show at all - iOS Safari lets the user suppress further prompts for a page,
|
||||
and then the "+" beside Workspaces did nothing visible. This is a normal popup
|
||||
with a real input, like every other create flow in WeKan.
|
||||
template(name="addWorkspacePopup")
|
||||
form.js-add-workspace-form
|
||||
label
|
||||
| {{_ 'allboards.add-workspace-prompt'}}
|
||||
input.js-new-workspace-name(type="text" autocomplete="off" autofocus required)
|
||||
input.primary.wide(type="submit" value="{{_ 'allboards.add-workspace'}}")
|
||||
|
||||
template(name="workspaceActionsPopup")
|
||||
form.workspace-actions-form.js-workspace-actions-form(data-id="{{id}}")
|
||||
label
|
||||
| {{_ 'allboards.edit-workspace-name'}}
|
||||
input.js-workspace-name(type="text" value="{{workspaceName}}" required)
|
||||
label
|
||||
| {{_ 'allboards.edit-workspace-icon'}}
|
||||
input.js-workspace-icon(type="text" value="{{workspaceIcon}}")
|
||||
input.primary.wide(type="submit" value="{{_ 'save'}}")
|
||||
hr
|
||||
form.workspace-subspace-form.js-workspace-subspace-form(data-id="{{id}}")
|
||||
label
|
||||
| {{_ 'allboards.add-subworkspace-prompt'}}
|
||||
input.js-subworkspace-name(type="text")
|
||||
button.primary.wide(type="submit") {{_ 'allboards.add-subworkspace'}}
|
||||
hr
|
||||
.workspace-delete-block
|
||||
span.js-workspace-delete-error(style="color: #c00; display: block; margin: 4px 0;")
|
||||
button.js-delete-workspace.negate.wide(type="button" data-id="{{id}}") {{_ 'delete'}}
|
||||
|
||||
//- The All Boards view menu, in the FIRST top header bar, beside the board
|
||||
//- view menu's place - a view menu says what you are looking AT, so both of
|
||||
//- them sit next to the page's name rather than in a bar or a panel.
|
||||
//-
|
||||
//- It names the CURRENT view, not itself: the board header says "Swimlanes" or
|
||||
//- "Lists" rather than "Board View", and this matches it.
|
||||
//- docs/Features/Page/Header.md
|
||||
//- The page's controls, as icons, in the first top header bar - left of the
|
||||
//- bell, exactly where a board's own controls are. They were rows inside the
|
||||
//- right sidebar's home view, which meant two clicks and a panel covering the
|
||||
//- boards to reach the thing you came for; and the sidebar's home view was the
|
||||
//- ONLY reason All Boards offered a hamburger, so it does not any more. Search
|
||||
//- and Multi-Selection still open the sidebar, straight into their own view.
|
||||
//-
|
||||
//- Each names itself beside its icon where the bar has room, and the one rule
|
||||
//- in header.css drops every label below 1100px, leaving the tooltips.
|
||||
//- docs/Features/Page/All-Boards.md
|
||||
template(name="allBoardsHeaderButtons")
|
||||
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
|
||||
i.fa.fa-sort
|
||||
span.board-header-btn-label {{_ 'sort-boards'}}
|
||||
a.board-header-btn.js-all-boards-sidebar-search(title="{{_ 'search-boards'}}")
|
||||
i.fa.fa-search
|
||||
span.board-header-btn-label {{_ 'search-boards'}}
|
||||
if canMultiSelectBoards
|
||||
//- Says whether it is ON, and offers the way OFF - the same pair the
|
||||
board's own Multi-Selection has. It looked identical whether or not a
|
||||
selection was running, so the one control that changes what a click on a
|
||||
board does gave no sign it had changed it, and the only way out was to
|
||||
find the row inside the sidebar. docs/Features/Page/Multi-Selection.md
|
||||
a.board-header-btn.js-all-boards-sidebar-multiselection(
|
||||
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
span.board-header-btn-label {{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
|
||||
if BoardMultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'multi-selection-off'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
template(name="allBoardsViewMenu")
|
||||
//- The name is visible beside the icon, the same as the board's view menu:
|
||||
a tooltip is the one place a name cannot be read without hovering.
|
||||
a.board-header-btn.js-open-all-boards-view(title="{{#if isAllBoardsView 'table'}}{{_ 'board-view-table'}}{{else}}{{_ 'lists'}}{{/if}}")
|
||||
i.fa.fa-caret-down
|
||||
if isAllBoardsView 'table'
|
||||
i.fa.fa-table
|
||||
span.board-header-btn-label {{_ 'board-view-table'}}
|
||||
else
|
||||
i.fa.fa-trello
|
||||
span.board-header-btn-label {{_ 'lists'}}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,198 @@
|
||||
@import 'nib'
|
||||
|
||||
$spaceBetweenTiles = 16px
|
||||
|
||||
.board-list
|
||||
margin: 0 ($spaceBetweenTiles/2)
|
||||
|
||||
li
|
||||
float: left
|
||||
width: 25%
|
||||
box-sizing: border-box
|
||||
position: relative
|
||||
|
||||
&.starred
|
||||
.fa-star,
|
||||
.fa-star-o
|
||||
opacity: 1
|
||||
|
||||
.board-list-item
|
||||
overflow: hidden;
|
||||
background-color: #999
|
||||
color: #f6f6f6
|
||||
height: 90px
|
||||
font-size: 16px
|
||||
line-height: 22px
|
||||
border-radius: 3px
|
||||
display: block
|
||||
font-weight: 700
|
||||
min-height: 18px
|
||||
padding: 8px
|
||||
margin: ($spaceBetweenTiles/2)
|
||||
position: relative
|
||||
text-decoration: none
|
||||
|
||||
&.tile
|
||||
background-size: auto
|
||||
background-repeat: repeat
|
||||
|
||||
.board-list-item-sub-name
|
||||
color: rgba(255, 255, 255, .5)
|
||||
display: block
|
||||
font-size: 14px
|
||||
font-weight: 400
|
||||
line-height: 22px
|
||||
|
||||
.board-list-item-desc
|
||||
color: #fff
|
||||
display: block
|
||||
font-size: 14px
|
||||
font-weight: 400
|
||||
line-height: 18px
|
||||
|
||||
.js-add-board
|
||||
text-align:center
|
||||
|
||||
.label
|
||||
font-weight: normal
|
||||
line-height:90px
|
||||
|
||||
:hover
|
||||
background-color:#939393
|
||||
|
||||
.fa-star,
|
||||
.fa-star-o
|
||||
bottom: 0
|
||||
font-size: 14px
|
||||
height: 18px
|
||||
line-height: 18px
|
||||
opacity: 0
|
||||
padding: 9px 9px
|
||||
position: absolute
|
||||
right: 0
|
||||
top: 0
|
||||
transition-duration: .15s
|
||||
transition-property: color, font-size, background
|
||||
|
||||
.fa-circle
|
||||
bottom: 0;
|
||||
font-size: 10px;
|
||||
height: 10px;
|
||||
line-height: 10px;
|
||||
padding: 9px 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transition-duration: .15s
|
||||
transition-property: color, font-size, background
|
||||
|
||||
.has-overtime-card-active
|
||||
color: #eb4646 !important
|
||||
|
||||
.no-overtime-card-active
|
||||
color: #3cb500 !important
|
||||
|
||||
.is-star-active
|
||||
color: white
|
||||
|
||||
.fa-clone
|
||||
position: absolute;
|
||||
bottom: 0
|
||||
font-size: 14px
|
||||
height: 18px
|
||||
line-height: 18px
|
||||
opacity: 0
|
||||
right: 0
|
||||
padding: 9px 9px
|
||||
transition-duration: .15s
|
||||
transition-property: color, font-size, background
|
||||
|
||||
.fa-archive
|
||||
position: absolute;
|
||||
bottom: 0
|
||||
font-size: 14px
|
||||
height: 18px
|
||||
line-height: 18px
|
||||
opacity: 0
|
||||
left: 0
|
||||
padding: 9px 9px
|
||||
transition-duration: .15s
|
||||
transition-property: color, font-size, background
|
||||
|
||||
li:hover a
|
||||
&:hover
|
||||
.fa-star,
|
||||
.fa-clone,
|
||||
.fa-archive,
|
||||
.fa-star-o
|
||||
color: white
|
||||
|
||||
.fa-star,
|
||||
.fa-clone,
|
||||
.fa-archive,
|
||||
.fa-star-o
|
||||
color: white
|
||||
opacity: .75
|
||||
|
||||
&:hover
|
||||
font-size: 18px
|
||||
opacity: 1
|
||||
|
||||
&.is-star-active
|
||||
opacity: 1
|
||||
|
||||
.board-backgrounds-list
|
||||
|
||||
.board-background-select
|
||||
box-sizing: border-box
|
||||
display: block
|
||||
float: left
|
||||
width: 50%
|
||||
padding-top: 12px
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
&:nth-child(-n + 2)
|
||||
padding-top: 0
|
||||
|
||||
&:nth-child(2n)
|
||||
padding-left: 6px
|
||||
|
||||
&:nth-child(2n+1)
|
||||
padding-right: 6px
|
||||
|
||||
.background-box
|
||||
color: white
|
||||
border-radius: 3px
|
||||
background-size: cover
|
||||
display: block
|
||||
height: 74px
|
||||
position: relative
|
||||
width: 100%
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
i.fa-check
|
||||
font-size: 25px
|
||||
color: white
|
||||
|
||||
@media screen and (max-width: 800px)
|
||||
.board-list
|
||||
height: 100%
|
||||
overflow: scroll
|
||||
|
||||
li
|
||||
width: 33.3%
|
||||
|
||||
.board-list-item
|
||||
overflow: hidden
|
||||
|
||||
.board-list-item-sub-name
|
||||
position: relative
|
||||
top: -100px
|
||||
left: -100px
|
||||
|
||||
@media screen and (max-width: 360px)
|
||||
li
|
||||
width: 50%
|
||||
@@ -1,68 +0,0 @@
|
||||
/* #6686: Today/Previous/Next/Day/Week/Month are one group on the RIGHT of
|
||||
the title now (boardBody.js's headerToolbar: left: 'title', right:
|
||||
'<all the buttons>') - forced to stay on one row, with the title
|
||||
vertically centered against it, rather than relying on FullCalendar's
|
||||
own default toolbar CSS, which wrapped the title onto its own row above
|
||||
the buttons (WeKan's global `h2 { margin: 0 0 7px }` gives the toolbar
|
||||
title, an <h2>, a bottom margin that a bare flex row does not clear the
|
||||
way this explicit centering does). */
|
||||
.calendar-view .fc-header-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.calendar-view .fc-toolbar-chunk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calendar-view .fc-toolbar-title {
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.calendar-view .fc {
|
||||
--fc-button-text-color: #333;
|
||||
--fc-button-bg-color: #f5f5f5;
|
||||
--fc-button-border-color: rgba(0, 0, 0, 0.2);
|
||||
--fc-button-hover-bg-color: #e6e6e6;
|
||||
--fc-button-hover-border-color: rgba(0, 0, 0, 0.25);
|
||||
--fc-button-active-bg-color: #d9d9d9;
|
||||
--fc-button-active-border-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.calendar-view .fc .fc-button-primary {
|
||||
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
background-image: linear-gradient(to bottom, #fff 0%, #e6e6e6 100%);
|
||||
}
|
||||
|
||||
.calendar-view .fc .fc-button-primary:focus,
|
||||
.calendar-view .fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
.calendar-view .fc .fc-button-primary:not(:disabled):active {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15),
|
||||
0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.calendar-create-close {
|
||||
min-height: auto !important;
|
||||
min-width: auto !important;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: #666 !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.calendar-create-close:hover,
|
||||
.calendar-create-close:focus {
|
||||
background: transparent !important;
|
||||
color: #111 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//- Board views listed in the "Board View" switcher menu (boardHeader.jade)
|
||||
that do not have a real implementation yet. Each is a plain grey page - the
|
||||
same .stats-view/.stats-view-content/.stats-view-title shell statsView.jade
|
||||
uses - with its title matching the menu entry that opens it, and one shared
|
||||
"not implemented yet" note. Replace a template here with a real one (and
|
||||
drop it from this file) as each view gets built.
|
||||
|
||||
template(name="dashboardView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-dashboard'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="burndownView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-burndown'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="burnupView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-burnup'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="cumulativeFlowView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-cumulative-flow'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="controlChartView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-control-chart'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="cycleTimeView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-cycle-time'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="flowEfficiencyView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-flow-efficiency'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="leadTimeView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-lead-time'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="throughputHistogramView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-throughput-histogram'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
|
||||
template(name="wipRunView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{_ 'board-view-wip-run'}}
|
||||
p.stats-view-placeholder-note {{_ 'board-view-not-yet-implemented'}}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Event handlers for the not-yet-implemented board views in
|
||||
// chartPlaceholderViews.jade - see that file for what each one is. Same
|
||||
// stopPropagation as statsView.js/timeView.js: the board canvas's drag-to-
|
||||
// scroll otherwise swallows the pointer/touch events native text selection
|
||||
// needs on the placeholder's `.stats-view`.
|
||||
const PLACEHOLDER_VIEW_TEMPLATES = [
|
||||
'dashboardView',
|
||||
'burndownView',
|
||||
'burnupView',
|
||||
'cumulativeFlowView',
|
||||
'controlChartView',
|
||||
'cycleTimeView',
|
||||
'flowEfficiencyView',
|
||||
'leadTimeView',
|
||||
'throughputHistogramView',
|
||||
'wipRunView',
|
||||
];
|
||||
|
||||
PLACEHOLDER_VIEW_TEMPLATES.forEach(name => {
|
||||
Template[name].events({
|
||||
'mousedown .stats-view'(event) {
|
||||
event.stopPropagation();
|
||||
},
|
||||
'touchstart .stats-view'(event) {
|
||||
event.stopPropagation();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
.dependency-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* #3392: the overlay SVG stays click-through, but each line is clickable (to
|
||||
open the edit/delete popup) even though its container is pointer-events:none. */
|
||||
.dependency-line {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dependency-temp-line {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* #3392: drag-to-connect handle on the minicard (no mode — cards stay
|
||||
clickable). Shown on hover; drag it onto another card to create a link. */
|
||||
.minicard .dependency-connect-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: -7px;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #eb144c;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
cursor: crosshair;
|
||||
opacity: 0;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.minicard:hover .dependency-connect-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dependency-line-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dependency-line-popup .card-dependency-icons li a.is-selected {
|
||||
border-color: #eb144c;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.dependency-line-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #eb144c;
|
||||
}
|
||||
|
||||
/* #3392: card detail "Dependencies" rows */
|
||||
.card-details-dependencies-list .card-dependency {
|
||||
display: flex;
|
||||
/* The title WRAPS now, so the row can be two or three lines tall. Centring
|
||||
the icon and the controls against that block would float them halfway down
|
||||
it; they belong beside the first line. */
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .card-dependency-link {
|
||||
flex: 1 1 auto;
|
||||
/* A card's title is a sentence, and the card details pane is narrow. It used
|
||||
to be one line with an ellipsis, so everything past the width of the pane -
|
||||
which is most of a real title, sitting right of the coloured icon - could
|
||||
not be read at all. There is no reason to hide it: the row can be tall.
|
||||
`min-width: 0` is what makes that work. A flex item's default is
|
||||
`min-width: auto`, which refuses to shrink below its content, so the text
|
||||
overflowed the row instead of wrapping inside it - and no amount of
|
||||
`white-space: normal` alone would have fixed that. */
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
/* A title can be a URL or an id with no spaces in it; break inside the word
|
||||
rather than let one long token push the controls off the row. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-type {
|
||||
flex: 0 0 auto;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-color {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-dependencies-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-dependency-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.card-dependency-icons li a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.minicard .badge.minicard-dependencies .badge-icon {
|
||||
font-weight: bold;
|
||||
}
|
||||
Loaded 100 of 4259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user