mirror of
https://github.com/wekan/wekan.git
synced 2026-01-23 01:36:09 +01:00
Per-User and Board-level data save fixes. Per-User is collapse, width, height. Per-Board is Swimlanes, Lists, Cards etc.
Thanks to xet7 ! Fixes #5997
This commit is contained in:
parent
2e0e1e56b5
commit
414b8dbf41
11 changed files with 2273 additions and 57 deletions
278
client/lib/localStorageValidator.js
Normal file
278
client/lib/localStorageValidator.js
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* LocalStorage Validation and Cleanup Utility
|
||||
*
|
||||
* Validates and cleans up per-user UI state stored in localStorage
|
||||
* for non-logged-in users (swimlane heights, list widths, collapse states)
|
||||
*/
|
||||
|
||||
// Maximum age for localStorage data (90 days)
|
||||
const MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Maximum number of boards to keep per storage key
|
||||
const MAX_BOARDS_PER_KEY = 50;
|
||||
|
||||
// Maximum number of items per board
|
||||
const MAX_ITEMS_PER_BOARD = 100;
|
||||
|
||||
/**
|
||||
* Validate that a value is a valid positive number
|
||||
*/
|
||||
function isValidNumber(value, min = 0, max = 10000) {
|
||||
if (typeof value !== 'number') return false;
|
||||
if (isNaN(value)) return false;
|
||||
if (!isFinite(value)) return false;
|
||||
if (value < min || value > max) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is a valid boolean
|
||||
*/
|
||||
function isValidBoolean(value) {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and clean swimlane heights data
|
||||
* Structure: { boardId: { swimlaneId: height, ... }, ... }
|
||||
*/
|
||||
function validateSwimlaneHeights(data) {
|
||||
if (!data || typeof data !== 'object') return {};
|
||||
|
||||
const cleaned = {};
|
||||
const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
|
||||
|
||||
for (const boardId of boardIds) {
|
||||
if (typeof boardId !== 'string' || boardId.length === 0) continue;
|
||||
|
||||
const boardData = data[boardId];
|
||||
if (!boardData || typeof boardData !== 'object') continue;
|
||||
|
||||
const swimlaneIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
|
||||
const cleanedBoard = {};
|
||||
|
||||
for (const swimlaneId of swimlaneIds) {
|
||||
if (typeof swimlaneId !== 'string' || swimlaneId.length === 0) continue;
|
||||
|
||||
const height = boardData[swimlaneId];
|
||||
// Valid swimlane heights: -1 (auto) or 50-2000 pixels
|
||||
if (isValidNumber(height, -1, 2000)) {
|
||||
cleanedBoard[swimlaneId] = height;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(cleanedBoard).length > 0) {
|
||||
cleaned[boardId] = cleanedBoard;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and clean list widths data
|
||||
* Structure: { boardId: { listId: width, ... }, ... }
|
||||
*/
|
||||
function validateListWidths(data) {
|
||||
if (!data || typeof data !== 'object') return {};
|
||||
|
||||
const cleaned = {};
|
||||
const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
|
||||
|
||||
for (const boardId of boardIds) {
|
||||
if (typeof boardId !== 'string' || boardId.length === 0) continue;
|
||||
|
||||
const boardData = data[boardId];
|
||||
if (!boardData || typeof boardData !== 'object') continue;
|
||||
|
||||
const listIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
|
||||
const cleanedBoard = {};
|
||||
|
||||
for (const listId of listIds) {
|
||||
if (typeof listId !== 'string' || listId.length === 0) continue;
|
||||
|
||||
const width = boardData[listId];
|
||||
// Valid list widths: 100-1000 pixels
|
||||
if (isValidNumber(width, 100, 1000)) {
|
||||
cleanedBoard[listId] = width;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(cleanedBoard).length > 0) {
|
||||
cleaned[boardId] = cleanedBoard;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and clean collapsed states data
|
||||
* Structure: { boardId: { itemId: boolean, ... }, ... }
|
||||
*/
|
||||
function validateCollapsedStates(data) {
|
||||
if (!data || typeof data !== 'object') return {};
|
||||
|
||||
const cleaned = {};
|
||||
const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
|
||||
|
||||
for (const boardId of boardIds) {
|
||||
if (typeof boardId !== 'string' || boardId.length === 0) continue;
|
||||
|
||||
const boardData = data[boardId];
|
||||
if (!boardData || typeof boardData !== 'object') continue;
|
||||
|
||||
const itemIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
|
||||
const cleanedBoard = {};
|
||||
|
||||
for (const itemId of itemIds) {
|
||||
if (typeof itemId !== 'string' || itemId.length === 0) continue;
|
||||
|
||||
const collapsed = boardData[itemId];
|
||||
if (isValidBoolean(collapsed)) {
|
||||
cleanedBoard[itemId] = collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(cleanedBoard).length > 0) {
|
||||
cleaned[boardId] = cleanedBoard;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and clean a single localStorage key
|
||||
*/
|
||||
function validateAndCleanKey(key, validator) {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return;
|
||||
|
||||
const data = JSON.parse(stored);
|
||||
const cleaned = validator(data);
|
||||
|
||||
// Only write back if data changed
|
||||
const cleanedStr = JSON.stringify(cleaned);
|
||||
if (cleanedStr !== stored) {
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
localStorage.setItem(key, cleanedStr);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Error validating localStorage key ${key}:`, e);
|
||||
// Remove corrupted data
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (removeError) {
|
||||
console.error(`Failed to remove corrupted localStorage key ${key}:`, removeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and clean all Wekan localStorage data
|
||||
* Called on app startup and periodically
|
||||
*/
|
||||
export function validateAndCleanLocalStorage() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Validate swimlane heights
|
||||
validateAndCleanKey('wekan-swimlane-heights', validateSwimlaneHeights);
|
||||
|
||||
// Validate list widths
|
||||
validateAndCleanKey('wekan-list-widths', validateListWidths);
|
||||
|
||||
// Validate list constraints
|
||||
validateAndCleanKey('wekan-list-constraints', validateListWidths);
|
||||
|
||||
// Validate collapsed lists
|
||||
validateAndCleanKey('wekan-collapsed-lists', validateCollapsedStates);
|
||||
|
||||
// Validate collapsed swimlanes
|
||||
validateAndCleanKey('wekan-collapsed-swimlanes', validateCollapsedStates);
|
||||
|
||||
// Record last cleanup time
|
||||
localStorage.setItem('wekan-last-cleanup', Date.now().toString());
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error during localStorage validation:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cleanup is needed (once per day)
|
||||
*/
|
||||
export function shouldRunCleanup() {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const lastCleanup = localStorage.getItem('wekan-last-cleanup');
|
||||
if (!lastCleanup) return true;
|
||||
|
||||
const lastCleanupTime = parseInt(lastCleanup, 10);
|
||||
if (isNaN(lastCleanupTime)) return true;
|
||||
|
||||
const timeSince = Date.now() - lastCleanupTime;
|
||||
// Run cleanup once per day
|
||||
return timeSince > 24 * 60 * 60 * 1000;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated data from localStorage
|
||||
*/
|
||||
export function getValidatedLocalStorageData(key, validator) {
|
||||
if (typeof localStorage === 'undefined') return {};
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return {};
|
||||
|
||||
const data = JSON.parse(stored);
|
||||
return validator(data);
|
||||
} catch (e) {
|
||||
console.warn(`Error reading localStorage key ${key}:`, e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set validated data to localStorage
|
||||
*/
|
||||
export function setValidatedLocalStorageData(key, data, validator) {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const validated = validator(data);
|
||||
localStorage.setItem(key, JSON.stringify(validated));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Error writing localStorage key ${key}:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Export validators for use by other modules
|
||||
export const validators = {
|
||||
swimlaneHeights: validateSwimlaneHeights,
|
||||
listWidths: validateListWidths,
|
||||
collapsedStates: validateCollapsedStates,
|
||||
isValidNumber,
|
||||
isValidBoolean,
|
||||
};
|
||||
|
||||
// Auto-cleanup on module load if needed
|
||||
if (Meteor.isClient) {
|
||||
Meteor.startup(() => {
|
||||
if (shouldRunCleanup()) {
|
||||
validateAndCleanLocalStorage();
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue