Lauri Ojansivu 1 week ago
parent
commit
6592102e8f

+ 1 - 1
CHANGELOG.md

@@ -19,7 +19,7 @@ Fixing other platforms In Progress.
 
 
 [Upgrade WeKan](https://wekan.fi/upgrade/)
 [Upgrade WeKan](https://wekan.fi/upgrade/)
 
 
-# Upcoming WeKan ® release
+# v8.02 2025-10-14 WeKan ® release
 
 
 This release adds the following new features:
 This release adds the following new features:
 
 

+ 3 - 3
Dockerfile

@@ -249,9 +249,9 @@ cd /home/wekan/app
 # Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc.
 # Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc.
 #rm -rf /home/wekan/app_build/bundle/programs/web.browser.legacy
 #rm -rf /home/wekan/app_build/bundle/programs/web.browser.legacy
 #mv /home/wekan/app_build/bundle /build
 #mv /home/wekan/app_build/bundle /build
-wget "https://github.com/wekan/wekan/releases/download/v8.01/wekan-8.01-amd64.zip"
-unzip wekan-8.01-amd64.zip
-rm wekan-8.01-amd64.zip
+wget "https://github.com/wekan/wekan/releases/download/v8.02/wekan-8.02-amd64.zip"
+unzip wekan-8.02-amd64.zip
+rm wekan-8.02-amd64.zip
 mv /home/wekan/app/bundle /build
 mv /home/wekan/app/bundle /build
 
 
 # Put back the original tar
 # Put back the original tar

+ 1 - 1
Stackerfile.yml

@@ -1,5 +1,5 @@
 appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
 appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
-appVersion: "v8.01.0"
+appVersion: "v8.02.0"
 files:
 files:
   userUploads:
   userUploads:
     - README.md
     - README.md

+ 2 - 2
docs/Platforms/Propietary/Windows/Offline.md

@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
 
 
 Right click and download files 1-4:
 Right click and download files 1-4:
 
 
-1. [wekan-8.01-amd64-windows.zip](https://github.com/wekan/wekan/releases/download/v8.01/wekan-8.01-amd64-windows.zip)
+1. [wekan-8.02-amd64-windows.zip](https://github.com/wekan/wekan/releases/download/v8.02/wekan-8.02-amd64-windows.zip)
 
 
 2. [node.exe](https://nodejs.org/dist/latest-v14.x/win-x64/node.exe)
 2. [node.exe](https://nodejs.org/dist/latest-v14.x/win-x64/node.exe)
 
 
@@ -22,7 +22,7 @@ Right click and download files 1-4:
 
 
 6. Double click `mongodb-windows-x86_64-7.0.25-signed.msi` . In installer, uncheck downloading MongoDB compass.
 6. Double click `mongodb-windows-x86_64-7.0.25-signed.msi` . In installer, uncheck downloading MongoDB compass.
 
 
-7. Unzip `wekan-8.01-amd64-windows.zip` , inside it is directory `bundle`, to it copy other files:
+7. Unzip `wekan-8.02-amd64-windows.zip` , inside it is directory `bundle`, to it copy other files:
 
 
 ```
 ```
 bundle (directory)
 bundle (directory)

+ 406 - 0
openapi/generate_openapi.js

@@ -0,0 +1,406 @@
+#!/usr/bin/env node
+/*
+  Node.js port of openapi/generate_openapi.py (minimal, Node 14 compatible).
+  Parses models to produce an OpenAPI 2.0 YAML on stdout.
+*/
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const esprima = require('esprima');
+
+function cleanupJsdocs(jsdoc) {
+  const lines = jsdoc.value.split('\n')
+    .map(s => s.replace(/^\s*/, ''))
+    .map(s => s.replace(/^\*/, ''));
+  while (lines.length && !lines[0].trim()) lines.shift();
+  while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
+  return lines;
+}
+
+function loadReturnTypeJsdocJson(data) {
+  let s = data;
+  const repl = [
+    [/\n/g, ' '],
+    [/([\{\s,])(\w+)(:)/g, '$1"$2"$3'],
+    [/(:)\s*([^:\},\]]+)\s*([\},\]])/g, '$1"$2"$3'],
+    [/([\[])\s*([^\{].+)\s*(\])/g, '$1"$2"$3'],
+    [/^\s*([^\[{].+)\s*/, '"$1"']
+  ];
+  for (const [r, rep] of repl) s = s.replace(r, rep);
+  try { return JSON.parse(s); } catch { return data; }
+}
+
+class Context {
+  constructor(filePath) {
+    this.path = filePath;
+    this._txt = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
+    const data = this._txt.join('\n');
+    this.program = esprima.parseModule(data, { comment: true, loc: true, range: true });
+  }
+  textAt(begin, end) { return this._txt.slice(begin - 1, end).join('\n'); }
+}
+
+function parseFile(filePath) {
+  try { return new Context(filePath); } catch { return undefined; }
+}
+
+function getReqBodyElems(node, acc) {
+  if (!node) return '';
+  switch (node.type) {
+    case 'FunctionExpression':
+    case 'ArrowFunctionExpression': return getReqBodyElems(node.body, acc);
+    case 'BlockStatement': node.body.forEach(s => getReqBodyElems(s, acc)); return '';
+    case 'TryStatement': return getReqBodyElems(node.block, acc);
+    case 'ExpressionStatement': return getReqBodyElems(node.expression, acc);
+    case 'MemberExpression': {
+      const left = getReqBodyElems(node.object, acc);
+      const right = node.property && node.property.name;
+      if (left === 'req.body' && right && !acc.includes(right)) acc.push(right);
+      return `${left}.${right}`;
+    }
+    case 'VariableDeclaration': node.declarations.forEach(s => getReqBodyElems(s, acc)); return '';
+    case 'VariableDeclarator': return getReqBodyElems(node.init, acc);
+    case 'Property': return getReqBodyElems(node.value, acc);
+    case 'ObjectExpression': node.properties.forEach(s => getReqBodyElems(s, acc)); return '';
+    case 'CallExpression': node.arguments.forEach(s => getReqBodyElems(s, acc)); return '';
+    case 'ArrayExpression': node.elements.forEach(s => getReqBodyElems(s, acc)); return '';
+    case 'IfStatement': getReqBodyElems(node.test, acc); if (node.consequent) getReqBodyElems(node.consequent, acc); if (node.alternate) getReqBodyElems(node.alternate, acc); return '';
+    case 'LogicalExpression':
+    case 'BinaryExpression':
+    case 'AssignmentExpression': getReqBodyElems(node.left, acc); getReqBodyElems(node.right, acc); return '';
+    case 'ChainExpression': return getReqBodyElems(node.expression, acc);
+    case 'ReturnStatement':
+    case 'UnaryExpression': if (node.argument) return getReqBodyElems(node.argument, acc); return '';
+    case 'Identifier': return node.name;
+    default: return '';
+  }
+}
+
+class EntryPoint {
+  constructor(schema, [method, pathLit, body]) {
+    this.schema = schema;
+    this.method = method; this._path = pathLit; this.body = body;
+    this._rawDoc = null; this._doc = {}; this._jsdoc = null;
+    this.path = (this._path.value || '').replace(/\/$/, '');
+    this.method_name = (this.method.value || '').toLowerCase();
+    this.body_params = [];
+    if (['post','put'].includes(this.method_name)) getReqBodyElems(this.body, this.body_params);
+    let url = this.path.replace(/:([^\/]*)Id/g, '{$1}').replace(/:([^\/]*)/g, '{$1}');
+    this.url = url;
+    const tokens = url.split('/');
+    const reduced = [];
+    for (let i = 0; i < tokens.length; i++) {
+      const t = tokens[i];
+      if (t === 'api') continue;
+      if (i < tokens.length - 1 && tokens[i+1].startsWith('{')) continue;
+      reduced.push(t.replace(/[{}]/g, ''));
+    }
+    this.reduced_function_name = reduced.join('_');
+    schema.used = true;
+  }
+  set doc(doc) { this._rawDoc = doc; this._jsdoc = cleanupJsdocs(doc); this._doc = this.parseDoc(); }
+  get doc() { return this._doc; }
+  parseDoc() {
+    if (!this._jsdoc) return {};
+    const result = {};
+    let currentTag = 'description';
+    let current = '';
+    const store = (tag, data) => {
+      if (data == null) return; const s = (data + '').replace(/\s+$/,''); if (!s) return;
+      if (tag === 'param') {
+        result.params = result.params || {};
+        let nameDesc = s.trim();
+        let paramType = null;
+        let name = nameDesc; let desc = '';
+        const mType = nameDesc.match(/^\{([^}]+)\}\s*(.*)$/);
+        if (mType) { paramType = mType[1]; nameDesc = mType[2]; }
+        const sp = nameDesc.split(/\s+/, 2); name = sp[0]; desc = sp[1] || '';
+        const optional = /^\[.*\]$/.test(name); if (optional) name = name.slice(1,-1);
+        result.params[name] = [paramType, optional, desc];
+        if (name.endsWith('Id')) { const base = name.slice(0,-2); if (!result.params[base]) result.params[base] = [paramType, optional, desc]; }
+        return;
+      }
+      if (tag === 'tag') { (result.tag = result.tag || []).push(s); return; }
+      if (tag === 'return_type') { result[tag] = loadReturnTypeJsdocJson(s); return; }
+      result[tag] = s;
+    };
+    for (const lineRaw of this._jsdoc) {
+      let line = lineRaw;
+      if (/^@/.test(line.trim())) {
+        const parts = line.trim().split(/\s+/, 2);
+        const tag = parts[0]; const rest = parts[1] || '';
+        if (['@operation','@summary','@description','@param','@return_type','@tag'].includes(tag)) {
+          store(currentTag, current);
+          currentTag = tag.slice(1); current = ''; line = rest;
+        }
+      }
+      current += line + '\n';
+    }
+    store(currentTag, current);
+    return result;
+  }
+  get summary() { return this._doc.summary ? this._doc.summary.replace(/\n/g,' ') : null; }
+  docParam(name) { const p = (this._doc.params||{})[name]; return p ? p : [null,null,null]; }
+  operationId() { return this._doc.operation || `${this.method_name}_${this.reduced_function_name}`; }
+  description() { return this._doc.description || null; }
+  returns() { return this._doc.return_type || null; }
+  tags() { const tags = []; if (this.schema.fields) tags.push(this.schema.name); if (this._doc.tag) tags.push(...this._doc.tag); return tags; }
+}
+
+class SchemaProperty {
+  constructor(statement, schema, context) {
+    this.schema = schema;
+    this.statement = statement;
+    this.name = (statement.key.name || statement.key.value);
+    this.type = 'object'; this.blackbox = false; this.required = true; this.elements = [];
+    (statement.value.properties || []).forEach(p => {
+      try {
+        const key = p.key && (p.key.name || p.key.value);
+        if (key === 'type') {
+          if (p.value.type === 'Identifier') this.type = (p.value.name || '').toLowerCase();
+          else if (p.value.type === 'ArrayExpression') { this.type = 'array'; this.elements = (p.value.elements||[]).map(e => (e.name||'object').toLowerCase()); }
+        } else if (key === 'blackbox') { this.blackbox = true; }
+        else if (key === 'optional' && p.value && p.value.value) { this.required = false; }
+      } catch(e) { /* ignore minor parse errors */ }
+    });
+    this._doc = null; this._raw_doc = null;
+  }
+  set doc(jsdoc){ this._raw_doc = jsdoc; this._doc = cleanupJsdocs(jsdoc); }
+  get doc(){ return this._doc; }
+}
+
+class Schemas {
+  constructor(context, statement, jsdocs, name) {
+    this.name = name || null; this._data = statement; this.fields = null; this.used = false;
+    if (statement) {
+      if (!this.name) this.name = statement.expression.callee.object.name;
+      const content = statement.expression.arguments[0].arguments[0];
+      this.fields = (content.properties || []).map(p => new SchemaProperty(p, this, context));
+    }
+    this._doc = null; this._raw_doc = null;
+    if (jsdocs) this.processJsdocs(jsdocs);
+  }
+  get doc(){ return this._doc ? this._doc.join(' ') : null; }
+  set doc(jsdoc){ this._raw_doc = jsdoc; this._doc = cleanupJsdocs(jsdoc); }
+  processJsdocs(jsdocs){
+    if (!this._data) return;
+    const start = this._data.loc.start.line, end = this._data.loc.end.line;
+    for (const doc of jsdocs){ if (doc.loc.end.line + 1 === start) { this.doc = doc; break; } }
+    const inRange = jsdocs.filter(doc => doc.loc.start.line >= start && doc.loc.end.line <= end);
+    for (const f of (this.fields||[]))
+      for (let i=0;i<inRange.length;i++){ const doc=inRange[i]; if (f.statement && f.statement.loc && f.statement.loc.start && f.statement.loc.start.line === (f.statement.key && f.statement.key.loc && f.statement.key.loc.start.line)) { f.doc = doc; inRange.splice(i,1); break; } }
+  }
+}
+
+function parseSchemas(schemasDir){
+  const schemas = {}; const entryPoints = [];
+  const walk = dir => {
+    const items = fs.readdirSync(dir, { withFileTypes: true }).sort((a,b)=>a.name.localeCompare(b.name));
+    for (const it of items){
+      const p = path.join(dir, it.name);
+      if (it.isDirectory()) walk(p);
+      else if (it.isFile()){
+        const context = parseFile(p); if (!context) continue; const program = context.program;
+        let currentSchema = null;
+        const jsdocs = (program.comments||[]).filter(c => c.type === 'Block' && c.value.startsWith('*\n'));
+        for (const statement of program.body){
+          try {
+            if (statement.type === 'ExpressionStatement' && statement.expression && statement.expression.callee && statement.expression.callee.property && statement.expression.callee.property.name === 'attachSchema' && statement.expression.arguments[0] && statement.expression.arguments[0].type === 'NewExpression' && statement.expression.arguments[0].callee.name === 'SimpleSchema'){
+              const schema = new Schemas(context, statement, jsdocs);
+              currentSchema = schema.name; schemas[currentSchema] = schema;
+            } else if (statement.type === 'IfStatement' && statement.test && statement.test.type === 'MemberExpression' && statement.test.object && statement.test.object.name === 'Meteor' && statement.test.property && statement.test.property.name === 'isServer'){
+              const conseq = statement.consequent && statement.consequent.body || [];
+              const data = conseq.filter(s => s.type === 'ExpressionStatement' && s.expression && s.expression.type === 'CallExpression' && s.expression.callee && s.expression.callee.object && s.expression.callee.object.name === 'JsonRoutes').map(s => s.expression.arguments);
+              if (data.length){
+                if (!currentSchema){ currentSchema = path.basename(p); schemas[currentSchema] = new Schemas(context, null, null, currentSchema); }
+                const eps = data.map(d => new EntryPoint(schemas[currentSchema], d)); entryPoints.push(...eps);
+                let endOfPrev = -1;
+                for (const ep of eps){
+                  const op = ep.method; const prior = jsdocs.filter(j => j.loc.end.line + 1 <= op.loc.start.line && j.loc.start.line > endOfPrev);
+                  if (prior.length) ep.doc = prior[prior.length - 1];
+                  endOfPrev = op.loc.end.line;
+                }
+              }
+            }
+          } catch(e){ /* ignore parse hiccups per file */ }
+        }
+      }
+    }
+  };
+  walk(schemasDir);
+  return { schemas, entryPoints };
+}
+
+function printOpenapiReturn(obj, indent){
+  const pad = ' '.repeat(indent);
+  if (Array.isArray(obj)){
+    console.log(`${pad}type: array`); console.log(`${pad}items:`); printOpenapiReturn(obj[0], indent+2); return;
+  }
+  if (obj && typeof obj === 'object'){
+    console.log(`${pad}type: object`); console.log(`${pad}properties:`);
+    for (const k of Object.keys(obj)){ console.log(`${pad}  ${k}:`); printOpenapiReturn(obj[k], indent+4); }
+    return;
+  }
+  if (typeof obj === 'string') console.log(`${pad}type: ${obj}`);
+}
+
+function generateOpenapi(schemas, entryPoints, version){
+  console.log(`swagger: '2.0'
+info:
+  title: Wekan REST API
+  version: ${version}
+  description: |
+    The REST API allows you to control and extend Wekan with ease.
+schemes:
+  - http
+securityDefinitions:
+  UserSecurity:
+    type: apiKey
+    in: header
+    name: Authorization
+paths:
+  /users/login:
+    post:
+      operationId: login
+      summary: Login with REST API
+      consumes:
+        - application/json
+        - application/x-www-form-urlencoded
+      tags:
+        - Login
+      parameters:
+        - name: loginRequest
+          in: body
+          required: true
+          description: Login credentials
+          schema:
+            type: object
+            required:
+              - username
+              - password
+            properties:
+              username:
+                description: |
+                  Your username
+                type: string
+              password:
+                description: |
+                  Your password
+                type: string
+                format: password
+      responses:
+        200:
+          description: |-
+            Successful authentication
+          schema:
+            type: object
+            required:
+              - id
+              - token
+              - tokenExpires
+            properties:
+              id:
+                type: string
+                description: User ID
+              token:
+                type: string
+                description: |
+                  Authentication token
+              tokenExpires:
+                type: string
+                format: date-time
+                description: |
+                  Token expiration date
+        400:
+          description: |
+            Error in authentication
+          schema:
+            type: object
+            properties:
+              error:
+                type: string
+              reason:
+                type: string
+        default:
+          description: |
+            Error in authentication`);
+
+  const methods = {};
+  for (const ep of entryPoints){ (methods[ep.path] = methods[ep.path] || []).push(ep); }
+  const sorted = Object.keys(methods).sort();
+  for (const pth of sorted){
+    console.log(`  ${methods[pth][0].url}:`);
+    for (const ep of methods[pth]){
+      const parameters = pth.split('/').filter(t => t.startsWith(':')).map(t => t.endsWith('Id') ? t.slice(1,-2) : t.slice(1));
+      console.log(`    ${ep.method_name}:`);
+      console.log(`      operationId: ${ep.operationId()}`);
+      const sum = ep.summary(); if (sum) console.log(`      summary: ${sum}`);
+      const desc = ep.description(); if (desc){ console.log(`      description: |`); desc.split('\n').forEach(l => console.log(`        ${l.trim() ? l : ''}`)); }
+      const tags = ep.tags(); if (tags.length){ console.log('      tags:'); tags.forEach(t => console.log(`        - ${t}`)); }
+      if (['post','put'].includes(ep.method_name)) console.log(`      consumes:\n        - multipart/form-data\n        - application/json`);
+      if (parameters.length || ['post','put'].includes(ep.method_name)) console.log('      parameters:');
+      if (['post','put'].includes(ep.method_name)){
+        for (const f of ep.body_params){
+          console.log(`        - name: ${f}\n          in: formData`);
+          const [ptype, optional, pdesc] = ep.docParam(f);
+          if (pdesc) console.log(`          description: |\n            ${pdesc}`); else console.log(`          description: the ${f} value`);
+          console.log(`          type: ${ptype || 'string'}`);
+          console.log(`          ${optional ? 'required: false' : 'required: true'}`);
+        }
+      }
+      for (const p of parameters){
+        console.log(`        - name: ${p}\n          in: path`);
+        const [ptype, optional, pdesc] = ep.docParam(p);
+        if (pdesc) console.log(`          description: |\n            ${pdesc}`); else console.log(`          description: the ${p} value`);
+        console.log(`          type: ${ptype || 'string'}`);
+        console.log(`          ${optional ? 'required: false' : 'required: true'}`);
+      }
+      console.log(`      produces:\n        - application/json\n      security:\n          - UserSecurity: []\n      responses:\n        '200':\n          description: |-\n            200 response`);
+      const ret = ep.returns();
+      if (ret){ console.log('          schema:'); printOpenapiReturn(ret, 12); }
+    }
+  }
+  console.log('definitions:');
+  for (const schema of Object.values(schemas)){
+    if (!schema.used || !schema.fields) continue;
+    console.log(`  ${schema.name}:`);
+    console.log('    type: object');
+    if (schema.doc) console.log(`    description: ${schema.doc}`);
+    console.log('    properties:');
+    const props = schema.fields.filter(f => !f.name.includes('.'));
+    const req = [];
+    for (const prop of props){
+      const name = prop.name; console.log(`      ${name}:`);
+      if (prop.doc){ console.log('        description: |'); prop.doc.forEach(l => console.log(`          ${l.trim() ? l : ''}`)); }
+      let ptype = prop.type; if (ptype === 'enum' || ptype === 'date') ptype = 'string';
+      if (ptype !== 'object') console.log(`        type: ${ptype}`);
+      if (prop.type === 'array'){
+        console.log('        items:');
+        for (const el of prop.elements){ if (el === 'object') console.log(`          $ref: "#/definitions/${schema.name + name.charAt(0).toUpperCase() + name.slice(1)}"`); else console.log(`          type: ${el}`); }
+      } else if (prop.type === 'object'){
+        if (prop.blackbox) console.log('        type: object');
+        else console.log(`        $ref: "#/definitions/${schema.name + name.charAt(0).toUpperCase() + name.slice(1)}"`);
+      }
+      if (!prop.name.includes('.') && !prop.required) console.log('        x-nullable: true');
+      if (prop.required) req.push(name);
+    }
+    if (req.length){ console.log('    required:'); req.forEach(f => console.log(`      - ${f}`)); }
+  }
+}
+
+function main(){
+  const argv = process.argv.slice(2);
+  let version = 'git-master';
+  let dir = path.resolve(__dirname, '../models');
+  for (let i = 0; i < argv.length; i++){
+    if (argv[i] === '--release' && argv[i+1]) { version = argv[i+1]; i++; continue; }
+    if (!argv[i].startsWith('--')) { dir = path.resolve(argv[i]); }
+  }
+  const { schemas, entryPoints } = parseSchemas(dir);
+  generateOpenapi(schemas, entryPoints, version);
+}
+
+if (require.main === module) main();
+
+

+ 1 - 1
package-lock.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "wekan",
   "name": "wekan",
-  "version": "v8.01.0",
+  "version": "v8.02.0",
   "lockfileVersion": 1,
   "lockfileVersion": 1,
   "requires": true,
   "requires": true,
   "dependencies": {
   "dependencies": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "wekan",
   "name": "wekan",
-  "version": "v8.01.0",
+  "version": "v8.02.0",
   "description": "Open-Source kanban",
   "description": "Open-Source kanban",
   "private": true,
   "private": true,
   "repository": {
   "repository": {

+ 327 - 3
public/api/wekan.yml

@@ -1,7 +1,325 @@
+{
+    type: "ConditionalExpression",
+    test: {
+        type: "LogicalExpression",
+        operator: "&&",
+        left: {
+            type: "Identifier",
+            name: "TAPi18n",
+            loc: {start: {line: 2217,column: 15},end: {line: 2217,column: 22}}
+        },
+        right: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "Identifier",
+                name: "TAPi18n",
+                loc: {start: {line: 2217,column: 26},end: {line: 2217,column: 33}}
+            },
+            property: {
+                type: "Identifier",
+                name: "i18n",
+                loc: {start: {line: 2217,column: 34},end: {line: 2217,column: 38}}
+            },
+            loc: {start: {line: 2217,column: 26},end: {line: 2217,column: 38}}
+        },
+        loc: {start: {line: 2217,column: 15},end: {line: 2217,column: 38}}
+    },
+    consequent: {
+        type: "CallExpression",
+        callee: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "Identifier",
+                name: "TAPi18n",
+                loc: {start: {line: 2217,column: 41},end: {line: 2217,column: 48}}
+            },
+            property: {
+                type: "Identifier",
+                name: "__",
+                loc: {start: {line: 2217,column: 49},end: {line: 2217,column: 51}}
+            },
+            loc: {start: {line: 2217,column: 41},end: {line: 2217,column: 51}}
+        },
+        arguments: [
+            {
+                type: "Literal",
+                value: "default",
+                raw: "'default'",
+                loc: {start: {line: 2217,column: 52},end: {line: 2217,column: 61}}
+            }
+        ],
+        loc: {start: {line: 2217,column: 41},end: {line: 2217,column: 62}}
+    },
+    alternate: {
+        type: "Literal",
+        value: "Default",
+        raw: "'Default'",
+        loc: {start: {line: 2217,column: 65},end: {line: 2217,column: 74}}
+    },
+    loc: {start: {line: 2217,column: 15},end: {line: 2217,column: 74}}
+}
+{
+    type: "ConditionalExpression",
+    test: {
+        type: "BinaryExpression",
+        operator: ">",
+        left: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "MemberExpression",
+                computed: False,
+                object: {
+                    type: "MemberExpression",
+                    computed: False,
+                    object: {
+                        type: "Identifier",
+                        name: "req",
+                        loc: {start: {line: 3587,column: 25},end: {line: 3587,column: 28}}
+                    },
+                    property: {
+                        type: "Identifier",
+                        name: "body",
+                        loc: {start: {line: 3587,column: 29},end: {line: 3587,column: 33}}
+                    },
+                    loc: {start: {line: 3587,column: 25},end: {line: 3587,column: 33}}
+                },
+                property: {
+                    type: "Identifier",
+                    name: "title",
+                    loc: {start: {line: 3587,column: 34},end: {line: 3587,column: 39}}
+                },
+                loc: {start: {line: 3587,column: 25},end: {line: 3587,column: 39}}
+            },
+            property: {
+                type: "Identifier",
+                name: "length",
+                loc: {start: {line: 3587,column: 40},end: {line: 3587,column: 46}}
+            },
+            loc: {start: {line: 3587,column: 25},end: {line: 3587,column: 46}}
+        },
+        right: {
+            type: "Literal",
+            value: 1000,
+            raw: "1000",
+            loc: {start: {line: 3587,column: 49},end: {line: 3587,column: 53}}
+        },
+        loc: {start: {line: 3587,column: 25},end: {line: 3587,column: 53}}
+    },
+    consequent: {
+        type: "CallExpression",
+        callee: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "MemberExpression",
+                computed: False,
+                object: {
+                    type: "MemberExpression",
+                    computed: False,
+                    object: {
+                        type: "Identifier",
+                        name: "req",
+                        loc: {start: {line: 3587,column: 56},end: {line: 3587,column: 59}}
+                    },
+                    property: {
+                        type: "Identifier",
+                        name: "body",
+                        loc: {start: {line: 3587,column: 60},end: {line: 3587,column: 64}}
+                    },
+                    loc: {start: {line: 3587,column: 56},end: {line: 3587,column: 64}}
+                },
+                property: {
+                    type: "Identifier",
+                    name: "title",
+                    loc: {start: {line: 3587,column: 65},end: {line: 3587,column: 70}}
+                },
+                loc: {start: {line: 3587,column: 56},end: {line: 3587,column: 70}}
+            },
+            property: {
+                type: "Identifier",
+                name: "substring",
+                loc: {start: {line: 3587,column: 71},end: {line: 3587,column: 80}}
+            },
+            loc: {start: {line: 3587,column: 56},end: {line: 3587,column: 80}}
+        },
+        arguments: [
+            {
+                type: "Literal",
+                value: 0,
+                raw: "0",
+                loc: {start: {line: 3587,column: 81},end: {line: 3587,column: 82}}
+            },
+            {
+                type: "Literal",
+                value: 1000,
+                raw: "1000",
+                loc: {start: {line: 3587,column: 84},end: {line: 3587,column: 88}}
+            }
+        ],
+        loc: {start: {line: 3587,column: 56},end: {line: 3587,column: 89}}
+    },
+    alternate: {
+        type: "MemberExpression",
+        computed: False,
+        object: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "Identifier",
+                name: "req",
+                loc: {start: {line: 3587,column: 92},end: {line: 3587,column: 95}}
+            },
+            property: {
+                type: "Identifier",
+                name: "body",
+                loc: {start: {line: 3587,column: 96},end: {line: 3587,column: 100}}
+            },
+            loc: {start: {line: 3587,column: 92},end: {line: 3587,column: 100}}
+        },
+        property: {
+            type: "Identifier",
+            name: "title",
+            loc: {start: {line: 3587,column: 101},end: {line: 3587,column: 106}}
+        },
+        loc: {start: {line: 3587,column: 92},end: {line: 3587,column: 106}}
+    },
+    loc: {start: {line: 3587,column: 25},end: {line: 3587,column: 106}}
+}
+{
+    type: "ConditionalExpression",
+    test: {
+        type: "BinaryExpression",
+        operator: ">",
+        left: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "MemberExpression",
+                computed: False,
+                object: {
+                    type: "MemberExpression",
+                    computed: False,
+                    object: {
+                        type: "Identifier",
+                        name: "req",
+                        loc: {start: {line: 655,column: 25},end: {line: 655,column: 28}}
+                    },
+                    property: {
+                        type: "Identifier",
+                        name: "body",
+                        loc: {start: {line: 655,column: 29},end: {line: 655,column: 33}}
+                    },
+                    loc: {start: {line: 655,column: 25},end: {line: 655,column: 33}}
+                },
+                property: {
+                    type: "Identifier",
+                    name: "title",
+                    loc: {start: {line: 655,column: 34},end: {line: 655,column: 39}}
+                },
+                loc: {start: {line: 655,column: 25},end: {line: 655,column: 39}}
+            },
+            property: {
+                type: "Identifier",
+                name: "length",
+                loc: {start: {line: 655,column: 40},end: {line: 655,column: 46}}
+            },
+            loc: {start: {line: 655,column: 25},end: {line: 655,column: 46}}
+        },
+        right: {
+            type: "Literal",
+            value: 1000,
+            raw: "1000",
+            loc: {start: {line: 655,column: 49},end: {line: 655,column: 53}}
+        },
+        loc: {start: {line: 655,column: 25},end: {line: 655,column: 53}}
+    },
+    consequent: {
+        type: "CallExpression",
+        callee: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "MemberExpression",
+                computed: False,
+                object: {
+                    type: "MemberExpression",
+                    computed: False,
+                    object: {
+                        type: "Identifier",
+                        name: "req",
+                        loc: {start: {line: 655,column: 56},end: {line: 655,column: 59}}
+                    },
+                    property: {
+                        type: "Identifier",
+                        name: "body",
+                        loc: {start: {line: 655,column: 60},end: {line: 655,column: 64}}
+                    },
+                    loc: {start: {line: 655,column: 56},end: {line: 655,column: 64}}
+                },
+                property: {
+                    type: "Identifier",
+                    name: "title",
+                    loc: {start: {line: 655,column: 65},end: {line: 655,column: 70}}
+                },
+                loc: {start: {line: 655,column: 56},end: {line: 655,column: 70}}
+            },
+            property: {
+                type: "Identifier",
+                name: "substring",
+                loc: {start: {line: 655,column: 71},end: {line: 655,column: 80}}
+            },
+            loc: {start: {line: 655,column: 56},end: {line: 655,column: 80}}
+        },
+        arguments: [
+            {
+                type: "Literal",
+                value: 0,
+                raw: "0",
+                loc: {start: {line: 655,column: 81},end: {line: 655,column: 82}}
+            },
+            {
+                type: "Literal",
+                value: 1000,
+                raw: "1000",
+                loc: {start: {line: 655,column: 84},end: {line: 655,column: 88}}
+            }
+        ],
+        loc: {start: {line: 655,column: 56},end: {line: 655,column: 89}}
+    },
+    alternate: {
+        type: "MemberExpression",
+        computed: False,
+        object: {
+            type: "MemberExpression",
+            computed: False,
+            object: {
+                type: "Identifier",
+                name: "req",
+                loc: {start: {line: 655,column: 92},end: {line: 655,column: 95}}
+            },
+            property: {
+                type: "Identifier",
+                name: "body",
+                loc: {start: {line: 655,column: 96},end: {line: 655,column: 100}}
+            },
+            loc: {start: {line: 655,column: 92},end: {line: 655,column: 100}}
+        },
+        property: {
+            type: "Identifier",
+            name: "title",
+            loc: {start: {line: 655,column: 101},end: {line: 655,column: 106}}
+        },
+        loc: {start: {line: 655,column: 92},end: {line: 655,column: 106}}
+    },
+    loc: {start: {line: 655,column: 25},end: {line: 655,column: 106}}
+}
 swagger: '2.0'
 swagger: '2.0'
 info:
 info:
   title: Wekan REST API
   title: Wekan REST API
-  version: v8.01
+  version: v8.02
   description: |
   description: |
     The REST API allows you to control and extend Wekan with ease.
     The REST API allows you to control and extend Wekan with ease.
 
 
@@ -2853,6 +3171,11 @@ definitions:
            The default board ID assigned to subtasks.
            The default board ID assigned to subtasks.
         type: string
         type: string
         x-nullable: true
         x-nullable: true
+      migrationVersion:
+        description: |
+           The migration version of the board structure.
+           New boards are created with the latest version and don't need migration.
+        type: number
       subtasksDefaultListId:
       subtasksDefaultListId:
         description: |
         description: |
            The default List ID assigned to subtasks.
            The default List ID assigned to subtasks.
@@ -3041,6 +3364,7 @@ definitions:
       - color
       - color
       - allowsCardCounterList
       - allowsCardCounterList
       - allowsBoardMemberList
       - allowsBoardMemberList
+      - migrationVersion
       - allowsSubtasks
       - allowsSubtasks
       - allowsAttachments
       - allowsAttachments
       - allowsChecklists
       - allowsChecklists
@@ -3821,8 +4145,9 @@ definitions:
         type: string
         type: string
       swimlaneId:
       swimlaneId:
         description: |
         description: |
-           the swimlane associated to this list. Required for per-swimlane list titles
+           the swimlane associated to this list. Optional for backward compatibility
         type: string
         type: string
+        x-nullable: true
       createdAt:
       createdAt:
         description: |
         description: |
            creation date
            creation date
@@ -3887,7 +4212,6 @@ definitions:
       - title
       - title
       - archived
       - archived
       - boardId
       - boardId
-      - swimlaneId
       - createdAt
       - createdAt
       - modifiedAt
       - modifiedAt
       - type
       - type

+ 41 - 25
releases/rebuild-docs.sh

@@ -1,4 +1,6 @@
-# Extract the OpenAPI specification.
+#!/usr/bin/env bash
+# Build API documentation using Node.js tooling only (Node 14.x compatible).
+set -euo pipefail
 
 
 # 1) Check that there is only one parameter
 # 1) Check that there is only one parameter
 #    of Wekan version number:
 #    of Wekan version number:
@@ -10,26 +12,7 @@ if [ $# -ne 1 ]
     exit 1
     exit 1
 fi
 fi
 
 
-# 2) If esprima-python directory does not exist,
-#   install dependencies.
-
-if [ ! -d ~/python/esprima-python ]; then
-  sudo apt-get -y install python3-pip python3-swagger-spec-validator python3-wheel python3-setuptools
-  # Install older version of api2html that works with Node.js 14
-  sudo npm install -g api2html@0.3.0 || sudo npm install -g swagger-ui-watcher
-  (mkdir -p ~/python && cd ~/python && git clone --depth 1 -b master https://github.com/Kronuz/esprima-python)
-  (cd ~/python/esprima-python && git fetch origin pull/20/head:delete_fix && git checkout delete_fix &&  sudo python3 setup.py install --record files.txt)
-  #(cd ~/python/esprima-python && git fetch origin pull/20/head:delete_fix && git checkout delete_fix && sudo pip3 install .)
-  # temporary fix until https://github.com/Kronuz/esprima-python/pull/20 gets merged
-  # a) Generating docs works on Kubuntu 21.10 with this,
-  #    but generating Sandstorm WeKan package does not work
-  #    https://github.com/wekan/wekan/issues/4280
-  #    https://github.com/sandstorm-io/sandstorm/issues/3600
-  #      sudo pip3 install .
-  # b) Generating docs Works on Linux Mint with this,
-  #    and also generating Sandstorm WeKan package works:
-  #      sudo python3 setup.py install --record files.txt
-fi
+# 2) No Python dependencies; use npm/npx exclusively
 
 
 # 2) Go to Wekan repo directory
 # 2) Go to Wekan repo directory
 cd ~/repos/wekan
 cd ~/repos/wekan
@@ -39,10 +22,43 @@ if [ ! -d public/api ]; then
   mkdir -p public/api
   mkdir -p public/api
 fi
 fi
 
 
-# 4) Generate docs with api2html or fallback to swagger-ui-watcher
-python3 ./openapi/generate_openapi.py --release v$1 > ./public/api/wekan.yml
-if ! api2html -c ./public/logo-header.png -o ./public/api/wekan.html ./public/api/wekan.yml; then
-  swagger-ui-watcher ./public/api/wekan.yml -p 8080
+# 4) Locate or generate an OpenAPI spec (YAML or JSON)
+SPEC_YML="./public/api/wekan.yml"
+SPEC_JSON="./public/openapi.json"
+SPEC_ALT_YML="./public/openapi.yml"
+
+if [ -s "$SPEC_YML" ]; then
+  SPEC="$SPEC_YML"
+elif [ -s "$SPEC_JSON" ]; then
+  SPEC="$SPEC_JSON"
+elif [ -s "$SPEC_ALT_YML" ]; then
+  SPEC="$SPEC_ALT_YML"
+else
+  echo "No existing OpenAPI spec found. Generating from models with Node..."
+  mkdir -p ./public/api
+  node ./openapi/generate_openapi.js --release v$1 ./models > "$SPEC_YML"
+  SPEC="$SPEC_YML"
+fi
+chmod 644 "$SPEC" 2>/dev/null || true
+
+# Build static HTML docs (no global installs)
+# 1) Prefer Redocly CLI
+if npx --yes @redocly/cli@latest build-docs "$SPEC" -o ./public/api/wekan.html; then
+  :
+else
+  # 2) Fallback to redoc-cli
+  if npx --yes redoc-cli@latest bundle "$SPEC" -o ./public/api/wekan.html; then
+    :
+  else
+    # 3) Fallback to api2html
+    if npx --yes api2html@0.3.0 -c ./public/logo-header.png -o ./public/api/wekan.html "$SPEC"; then
+      :
+    else
+      echo "All HTML generators failed. You can preview locally with:" >&2
+      echo "  npx --yes @redocly/cli@latest preview-docs $SPEC" >&2
+      exit 1
+    fi
+  fi
 fi
 fi
 
 
 # Copy docs to bundle
 # Copy docs to bundle

+ 2 - 2
sandstorm-pkgdef.capnp

@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
     appTitle = (defaultText = "Wekan"),
     appTitle = (defaultText = "Wekan"),
     # The name of the app as it is displayed to the user.
     # The name of the app as it is displayed to the user.
 
 
-    appVersion = 801,
+    appVersion = 802,
     # Increment this for every release.
     # Increment this for every release.
 
 
-    appMarketingVersion = (defaultText = "8.01.0~2025-10-11"),
+    appMarketingVersion = (defaultText = "8.02.0~2025-10-14"),
     # Human-readable presentation of the app version.
     # Human-readable presentation of the app version.
 
 
     minUpgradableAppVersion = 0,
     minUpgradableAppVersion = 0,

+ 4 - 4
snapcraft.yaml

@@ -1,5 +1,5 @@
 name: wekan
 name: wekan
-version: '8.01'
+version: '8.02'
 base: core24
 base: core24
 summary: Open Source kanban
 summary: Open Source kanban
 description: |
 description: |
@@ -203,9 +203,9 @@ parts:
             # Cleanup
             # Cleanup
             mkdir .build
             mkdir .build
             cd .build
             cd .build
-            wget https://github.com/wekan/wekan/releases/download/v8.01/wekan-8.01-amd64.zip
-            unzip wekan-8.01-amd64.zip
-            rm wekan-8.01-amd64.zip
+            wget https://github.com/wekan/wekan/releases/download/v8.02/wekan-8.02-amd64.zip
+            unzip wekan-8.02-amd64.zip
+            rm wekan-8.02-amd64.zip
             cd ..
             cd ..
             ##cd .build/bundle
             ##cd .build/bundle
             ##find . -type d -name '*-garbage*' | xargs rm -rf
             ##find . -type d -name '*-garbage*' | xargs rm -rf