Added migration for libraryTags

This commit is contained in:
Stefan Zermatten
2023-04-17 15:28:06 +02:00
parent 90235a5bc6
commit d643886a7f
9 changed files with 321 additions and 107 deletions

View File

@@ -59,7 +59,7 @@ let LibraryNodeSchema = new SimpleSchema({
},
libraryTags: {
type: Array,
defaultValue: [],
optional: true,
maxCount: STORAGE_LIMITS.tagCount,
},
'libraryTags.$': {
@@ -72,12 +72,14 @@ let LibraryNodeSchema = new SimpleSchema({
optional: true,
max: STORAGE_LIMITS.variableName,
},
/* TODO: Disabled for now until image upload is working
// Image to display when filling the slot
slotFillImage: {
type: String,
optional: true,
max: STORAGE_LIMITS.url,
},
*/
// Fill more than one quantity in a slot, like feats and ability score
// improvements, filtered out of UI if there isn't space in quantityExpected
slotQuantityFilled: {

View File

@@ -1,14 +1,19 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
// Folders organize a character sheet into a tree, particularly to group things
// like 'race' and 'background'
let FolderSchema = new createPropertySchema({
let FolderSchema = createPropertySchema({
name: {
type: String,
max: STORAGE_LIMITS.name,
optional: true,
},
description: {
type: 'inlineCalculationFieldToCompute',
optional: true,
},
groupStats: {
type: Boolean,
optional: true,
@@ -33,6 +38,19 @@ let FolderSchema = new createPropertySchema({
},
});
const ComputedOnlyFolderSchema = new createPropertySchema({});
const ComputedOnlyFolderSchema = createPropertySchema({
summary: {
type: 'computedOnlyInlineCalculationField',
optional: true,
},
description: {
type: 'computedOnlyInlineCalculationField',
optional: true,
},
});
export { FolderSchema, ComputedOnlyFolderSchema };
const ComputedFolderSchema = new SimpleSchema()
.extend(FolderSchema)
.extend(ComputedOnlyFolderSchema);
export { FolderSchema, ComputedFolderSchema, ComputedOnlyFolderSchema };

View File

@@ -13,7 +13,7 @@ import { ComputedDamageSchema } from '/imports/api/properties/Damages.js';
import { DamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js';
import { ComputedEffectSchema } from '/imports/api/properties/Effects.js';
import { ComputedFeatureSchema } from '/imports/api/properties/Features.js';
import { FolderSchema } from '/imports/api/properties/Folders.js';
import { ComputedFolderSchema } from '/imports/api/properties/Folders.js';
import { ComputedItemSchema } from '/imports/api/properties/Items.js';
import { ComputedNoteSchema } from '/imports/api/properties/Notes.js';
import { ComputedPointBuySchema } from '/imports/api/properties/PointBuys.js';
@@ -43,7 +43,7 @@ const propertySchemasIndex = {
damageMultiplier: DamageMultiplierSchema,
effect: ComputedEffectSchema,
feature: ComputedFeatureSchema,
folder: FolderSchema,
folder: ComputedFolderSchema,
note: ComputedNoteSchema,
pointBuy: ComputedPointBuySchema,
proficiency: ProficiencySchema,

View File

@@ -1,3 +1,3 @@
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
export default SCHEMA_VERSION;

View File

@@ -10,52 +10,52 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
Migrations.add({
version: 1,
name: 'Unifies calculated field schema',
up(){
up() {
migrate();
},
down(){
migrate({reversed: true});
down() {
migrate({ reversed: true });
},
});
function migrate({reversed} = {}){
function migrate({ reversed } = {}) {
console.log('migrating creature properties');
migrateCollection({collection: CreatureProperties, reversed});
migrateCollection({ collection: CreatureProperties, reversed });
console.log('migrating library nodes')
migrateCollection({collection: LibraryNodes, reversed});
migrateCollection({ collection: LibraryNodes, reversed });
}
function migrateCollection({collection, reversed}){
function migrateCollection({ collection, reversed }) {
const bulk = collection.rawCollection().initializeUnorderedBulkOp();
collection.find({}).forEach(prop => {
const newProp = migrateProperty({collection, reversed, prop});
const newProp = migrateProperty({ collection, reversed, prop });
bulk.find({ _id: prop._id }).replaceOne(newProp);
});
bulk.execute();
}
export function migrateProperty({collection, reversed, prop}){
export function migrateProperty({ collection, reversed, prop }) {
const transforms = [
...(transformsByPropType[prop.type] || []),
{from: 'dependencies'}
{ from: 'dependencies' }
];
let migratedProp = transformFields(prop, transforms, reversed);
const schema = collection.simpleSchema({type: migratedProp.type});
const schema = collection.simpleSchema({ type: migratedProp.type });
// Only clean if the schema version matches our destination version
if(!reversed && SCHEMA_VERSION === 1){
if (!reversed && SCHEMA_VERSION >= 1) {
try {
migratedProp = schema.clean(migratedProp);
schema.validate(migratedProp);
} catch(e){
if (e.details[0]?.type === 'maxString'){
} catch (e) {
if (e.details[0]?.type === 'maxString') {
console.log({
prop: prop,
details: e.details,
});
} else {
console.warn({prop, error: e});
console.warn({ prop, error: e });
}
}
}
@@ -74,31 +74,31 @@ const transformsByPropType = {
'action': actionTransforms,
'adjustment': [
...getComputedPropertyTransforms('amount'),
{from: 'target', to: 'target', up: simplifyTarget},
{ from: 'target', to: 'target', up: simplifyTarget },
],
'attack': [
...actionTransforms,
...getComputedPropertyTransforms('rollBonus', 'attackRoll'),
//change type to action
{from: 'type', to: 'type', up: () => 'action'},
{ from: 'type', to: 'type', up: () => 'action' },
],
'attribute': [
// from: baseValue must be first or else it will delete the field we need
{from: 'baseValue', to: 'baseValue.value', up: nanToNull},
{from: 'baseValueCalculation', to: 'baseValue.calculation', up: calculationUp, down: calculationDown},
{from: 'baseValueErrors', to: 'baseValue.errors', up: trimErrors},
{ from: 'baseValue', to: 'baseValue.value', up: nanToNull },
{ from: 'baseValueCalculation', to: 'baseValue.calculation', up: calculationUp, down: calculationDown },
{ from: 'baseValueErrors', to: 'baseValue.errors', up: trimErrors },
...getComputedPropertyTransforms('spellSlotLevel'),
...getInlineComputationTransforms('description'),
{from: 'value', to: 'total', up: nanToNull},
{from: 'currentValue', to: 'value', up: nanToNull},
{from: 'proficiency', to: 'proficiency', up: stripZero},
{ from: 'value', to: 'total', up: nanToNull },
{ from: 'currentValue', to: 'value', up: nanToNull },
{ from: 'proficiency', to: 'proficiency', up: stripZero },
],
'buff': [
...getComputedPropertyTransforms('duration'),
...getInlineComputationTransforms('description'),
{from: 'value', to: 'total', up: nanToNull},
{from: 'target', to: 'target', up: simplifyTarget},
{from: 'applied'},
{ from: 'value', to: 'total', up: nanToNull },
{ from: 'target', to: 'target', up: simplifyTarget },
{ from: 'applied' },
],
'classLevel': [
...getInlineComputationTransforms('description'),
@@ -108,20 +108,22 @@ const transformsByPropType = {
],
'damage': [
...getComputedPropertyTransforms('amount'),
{from: 'target', to: 'target', up: simplifyTarget},
{ from: 'target', to: 'target', up: simplifyTarget },
],
'effect': [
{from: 'calculation', to: 'amount.calculation'},
{from: 'result', to: 'amount.value', up: nanToNull},
{from: 'errors', to: 'amount.errors', up: trimErrors},
{from: 'name', to: 'name', up(val, src, doc){
if (src.operation === 'conditional'){
doc.text = val;
return;
} else {
return val;
{ from: 'calculation', to: 'amount.calculation' },
{ from: 'result', to: 'amount.value', up: nanToNull },
{ from: 'errors', to: 'amount.errors', up: trimErrors },
{
from: 'name', to: 'name', up(val, src, doc) {
if (src.operation === 'conditional') {
doc.text = val;
return;
} else {
return val;
}
}
}},
},
],
'feature': [
...getInlineComputationTransforms('summary'),
@@ -139,20 +141,20 @@ const transformsByPropType = {
],
'savingThrow': [
...getComputedPropertyTransforms('dc'),
{from: 'target', to: 'target', up: simplifyTarget},
{ from: 'target', to: 'target', up: simplifyTarget },
],
'skill': [
...getComputedPropertyTransforms('baseValue'),
...getInlineComputationTransforms('description'),
{from: 'value', to: 'value', up: nanToNull},
{from: 'passiveBonus', to: 'passiveBonus', up: nanToNull},
{from: 'proficiency', to: 'proficiency', up: stripZero},
{ from: 'value', to: 'value', up: nanToNull },
{ from: 'passiveBonus', to: 'passiveBonus', up: nanToNull },
{ from: 'proficiency', to: 'proficiency', up: stripZero },
],
'spell': [
...actionTransforms,
],
'proficiency': [
{from: 'value', to: 'value', up: stripZero},
{ from: 'value', to: 'value', up: stripZero },
],
'propertySlot': [
...getComputedPropertyTransforms('quantityExpected'),
@@ -166,70 +168,70 @@ const transformsByPropType = {
...getInlineComputationTransforms('description'),
],
'toggle': [
{from: 'condition', to: 'condition.calculation'},
{from: 'toggleResult', to: 'condition.value', up: nanToNull},
{from: 'errors', to: 'condition.errors', up: trimErrors},
{ from: 'condition', to: 'condition.calculation' },
{ from: 'toggleResult', to: 'condition.value', up: nanToNull },
{ from: 'errors', to: 'condition.errors', up: trimErrors },
],
};
function getComputedPropertyTransforms(key, toKey){
function getComputedPropertyTransforms(key, toKey) {
if (!toKey) toKey = key;
return [
{from: key, to: `${toKey}.calculation`, up: calculationUp, down: calculationDown},
{from: `${key}Result`, to: `${toKey}.value`, up: nanToNull},
{from: `${key}Errors`, to: `${toKey}.errors`, up: trimErrors},
{ from: key, to: `${toKey}.calculation`, up: calculationUp, down: calculationDown },
{ from: `${key}Result`, to: `${toKey}.value`, up: nanToNull },
{ from: `${key}Errors`, to: `${toKey}.errors`, up: trimErrors },
];
}
function getInlineComputationTransforms(key){
function getInlineComputationTransforms(key) {
return [
{from: key, to: `${key}.text`, up: calculationUp, down: calculationDown},
{from: `${key}Calculations`, to: `${key}.inlineCalculations`, up: calculationUp, down: calculationDown},
{from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value`},
{ from: key, to: `${key}.text`, up: calculationUp, down: calculationDown },
{ from: `${key}Calculations`, to: `${key}.inlineCalculations`, up: calculationUp, down: calculationDown },
{ from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value` },
];
}
export function calculationUp(val){
export function calculationUp(val) {
if (typeof val !== 'string') return val;
if (!val.replace) console.log({val, replace: val.replace});
if (!val.replace) console.log({ val, replace: val.replace });
return val.replace(/#(\w+).(\w+)Result/g, '#$1.$2')
.replace(/\.value/g, '.total')
.replace(/\.currentValue/g, '.value');
}
function calculationDown(val){
function calculationDown(val) {
if (typeof val !== 'string') return val;
return val.replace(/\.value/g, '.currentValue').replace(/\.total/g, '.value');
}
function nanToNull(val){
if (Number.isNaN(val)){
function nanToNull(val) {
if (Number.isNaN(val)) {
return null;
} else {
return val;
}
}
function stripZero(val){
if (val === 0){
function stripZero(val) {
if (val === 0) {
return undefined;
} else {
return val;
}
}
function simplifyTarget(val){
if (val === 'self'){
function simplifyTarget(val) {
if (val === 'self') {
return val;
} else {
return 'target';
}
}
function trimErrors(arr){
if(!arr) return arr;
function trimErrors(arr) {
if (!arr) return arr;
arr.forEach(e => {
if (e.message.length > STORAGE_LIMITS.errorMessage){
if (e.message.length > STORAGE_LIMITS.errorMessage) {
e.message = e.message.slice(0, STORAGE_LIMITS.errorMessage);
}
});

View File

@@ -31,7 +31,7 @@ const exampleAction = {
'ancestors': [{
'collection': 'creatures',
'id': 'X9rzFhsgFhodYfHmG'
}, ],
},],
'order': 315,
'summary': 'Curse a creature for 1 minute. The curse ends early if {warlock.level >14 ? "" : "the target dies, or"} you are incapacitated. \nGain the following benefits: \n- *Bonus to damage rolls against the cursed target of* **+{proficiencyBonus}**. \n- Any attack roll you make against the cursed target is a **critical hit on a roll of 19 or 20**. \n- If the cursed target dies, you **regain {warlock.level+charisma.modifier} hit points**. \n{warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."}',
'uses': '1',
@@ -45,21 +45,21 @@ const exampleAction = {
'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.'
}],
'summaryCalculations': [{
'calculation': 'warlock.level >14 ? "" : "the target dies, or"',
'result': 'the target dies, or'
},
{
'calculation': 'proficiencyBonus',
'result': '4'
},
{
'calculation': 'warlock.level+charisma.modifier',
'result': '15'
},
{
'calculation': 'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."',
'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.'
}
'calculation': 'warlock.level >14 ? "" : "the target dies, or"',
'result': 'the target dies, or'
},
{
'calculation': 'proficiencyBonus',
'result': '4'
},
{
'calculation': 'warlock.level+charisma.modifier',
'result': '15'
},
{
'calculation': 'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."',
'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.'
}
]
};
@@ -76,13 +76,13 @@ const exampleAttribute = {
'collection': 'creatureProperties'
},
ancestors: [{
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv'
},
{
'id': '8jSWKxvgQyKbunFtD',
'collection': 'creatureProperties'
}
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv'
},
{
'id': '8jSWKxvgQyKbunFtD',
'collection': 'creatureProperties'
}
],
order: 84,
value: 20,
@@ -110,13 +110,13 @@ const expectedMigratedAttribute = {
'collection': 'creatureProperties'
},
ancestors: [{
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv'
},
{
'id': '8jSWKxvgQyKbunFtD',
'collection': 'creatureProperties'
}
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv'
},
{
'id': '8jSWKxvgQyKbunFtD',
'collection': 'creatureProperties'
}
],
order: 84,
total: 20,
@@ -205,11 +205,10 @@ const expectedMigratedAttack = {
}],
'order': 56,
'usesUsed': 2,
libraryTags: [],
}
describe('migrateProperty', function() {
it('Migrates actions reversibly', function() {
describe('migrateProperty', function () {
it('Migrates actions reversibly', function () {
const action = {
...exampleAction
};
@@ -226,7 +225,7 @@ describe('migrateProperty', function() {
assert.deepEqual(action, exampleAction, 'action should not be bashed');
assert.deepEqual(exampleAction, reversedAction, 'operation should be reversible');
});
it('Migrates attributes as expected', function() {
it('Migrates attributes as expected', function () {
const attribute = {
...exampleAttribute
};
@@ -237,7 +236,7 @@ describe('migrateProperty', function() {
assert.deepEqual(newAttribute, expectedMigratedAttribute,
'Attribute should match the expected result');
});
it('Migrates attacks as expected', function() {
it('Migrates attacks as expected', function () {
const attribute = {
...exampleAttack
};

View File

@@ -0,0 +1,3 @@
export default function cleanAt2() {
return;
}

View File

@@ -0,0 +1,56 @@
import { Migrations } from 'meteor/percolate:migrations';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { union } from 'lodash';
// Git version 2.0-beta.33
// Database version 1
Migrations.add({
version: 2,
name: 'Separates creature property tags from library tags',
up() {
console.log('migrating up library nodes 1 -> 2');
const bulk = LibraryNodes.rawCollection().initializeUnorderedBulkOp();
LibraryNodes.find({}).forEach(prop => migratePropUp(bulk, prop));
bulk.execute();
},
down() {
console.log('migrating down library nodes 2 -> 1');
const bulk = LibraryNodes.rawCollection().initializeUnorderedBulkOp();
LibraryNodes.find({}).forEach(prop => migratePropDown(bulk, prop));
bulk.execute();
},
});
export function migratePropUp(bulk, prop) {
// If there are tags, copy them to libraryTags and set findable flags
if (Array.isArray(prop.tags) && prop.tags.length) {
bulk.find({ _id: prop._id }).updateOne({
$set: {
libraryTags: prop.tags,
fillSlots: true,
searchable: true,
},
});
}
}
export function migratePropDown(bulk, prop) {
const update = {
$unset: {
slotFillImage: 1,
slotFillerCondition: 1,
libraryTags: 1,
fillSlots: 1,
searchable: 1,
}
};
if (prop.libraryTags?.length) {
update.$set = {
tags: union(prop.libraryTags, prop.tags)
}
}
bulk.find({ _id: prop._id }).updateOne(update);
}

View File

@@ -0,0 +1,134 @@
import { migratePropUp, migratePropDown } from './dbv2.js';
import { assert } from 'chai';
const exampleAttack = {
'_id': 'vw23EnJwBRcXEJg7i',
'actionType': 'attack',
'target': 'singleTarget',
'tags': ['attack', 'magical', 'very cool'],
'resources': {
'itemsConsumed': [],
'attributesConsumed': []
},
'attackRoll': {
calculation: 'dexterity.modifier + proficiencyBonus + 2 - hp.total + hp.value',
},
'type': 'action',
'name': 'Claws',
'parent': {
'id': 'Jpx8q3WjM5SCoGBm8',
'collection': 'creatureProperties'
},
'ancestors': [{
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv'
}, {
'id': '3WS2xsSPAqB4eF9YH',
'collection': 'creatureProperties'
}, {
'id': 'rhYLEycvtHjcioaQL',
'collection': 'creatureProperties'
}, {
'id': 'Jpx8q3WjM5SCoGBm8',
'collection': 'creatureProperties'
}],
'order': 56,
'usesUsed': 2,
};
const expectedAttackUpdate = {
$set: {
'libraryTags': ['attack', 'magical', 'very cool'],
'fillSlots': true,
'searchable': true,
}
};
const emptyFolderExample = {
_id: 'DXPYsHKF6W8Hh3hZs',
type: 'folder',
name: 'Empty Folder',
'parent': {
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv',
},
'ancestors': [{
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv',
}],
};
const DownMergeExample = {
_id: 'DXPYsHKF6W8Hh3hZs',
type: 'feature',
name: 'Feature With Tags and library Tags',
'parent': {
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv',
},
'ancestors': [{
'collection': 'creatures',
'id': 'm9sdCvs6iDf7qRaGv',
}],
'libraryTags': ['tags', 'from', 'library'],
'tags': ['attack', 'magical', 'very cool'],
};
const expectedDownMergeUpdate = {
$unset: {
slotFillImage: 1,
slotFillerCondition: 1,
libraryTags: 1,
fillSlots: 1,
searchable: 1,
},
$set: {
tags: ['tags', 'from', 'library', 'attack', 'magical', 'very cool'],
}
};
describe('dbv2 Migrate library nodes', function () {
it('Migrates attacks up', function () {
const bulk = stubBulk();
migratePropUp(bulk, exampleAttack);
const { query, update } = bulk.result();
assert.deepEqual(query, { _id: 'vw23EnJwBRcXEJg7i' }, 'The query should match the id of the given prop');
assert.deepEqual(update, expectedAttackUpdate, 'The update should match the expected update');
});
it('Migrates props without tags up', function () {
const bulk = stubBulk();
migratePropUp(bulk, emptyFolderExample);
const { query, update, timesFind, timesUpdate } = bulk.result();
assert.isUndefined(query, 'There should be no query on a prop with no tags');
assert.equal(timesFind, 0, 'Find should be called zero times on a prop with no tags');
assert.isUndefined(update, 'There should be no update on a prop with no tags');
assert.equal(timesUpdate, 0, 'Update should be called zero times on a prop with no tags');
});
it('Merges tags when down migrating', function () {
const bulk = stubBulk();
migratePropDown(bulk, DownMergeExample);
const { query, update } = bulk.result();
assert.deepEqual(query, { _id: 'DXPYsHKF6W8Hh3hZs' }, 'The query should match the id of the given prop');
assert.deepEqual(update, expectedDownMergeUpdate, 'The update should match the expected update');
});
});
// Create a stub for bulk udateOne operations that accepts a single op
function stubBulk() {
let query, update, timesFind = 0, timesUpdate = 0;
return {
find(inputQuery) {
query = inputQuery;
timesFind += 1;
return {
updateOne(inputUpdate) {
update = inputUpdate;
timesUpdate += 1;
}
}
},
result() {
return { query, update, timesFind, timesUpdate }
}
}
}