diff --git a/components/admin/Collections.vue b/components/admin/Collections.vue index da19f814..5d6e976d 100644 --- a/components/admin/Collections.vue +++ b/components/admin/Collections.vue @@ -28,7 +28,7 @@ v-container v-btn(color='primary' text @click='newCollection') {{ $t('admin.new_collection') }} - v-dialog(v-model='dialog' width='900' destroy-on-close :fullscreen='$vuetify.breakpoint.xsOnly') + v-dialog(v-model='dialog' max-width='8000' width='800' destroy-on-close :fullscreen='$vuetify.breakpoint.xsOnly') v-card v-card-title {{ $t('admin.edit_collection') }} v-card-text @@ -45,7 +45,7 @@ v-container h3(v-else class='text-h5' v-text='collection.name') v-row - v-col(cols=4) + v-col(cols=6) //- @input.native='searchActors' //- @focus='searchActors' v-autocomplete(v-model='filterActors' @@ -55,6 +55,7 @@ v-container :disabled="!collection.id" placeholder='Local' return-object + hide-details item-value='ap_id' item-text='ap_id' :delimiters="[',', ';']" @@ -70,23 +71,7 @@ v-container v-chip(v-bind="attrs" close :close-icon='mdiCloseCircle' @click:close='parent.selectItem(item)' :input-value="selected" label small) @{{ item?.object?.preferredUsername }}@{{ item?.instanceDomain }} - v-col(cols=4) - v-autocomplete(v-model='filterTags' - cache-items - :prepend-inner-icon="mdiTagMultiple" - chips small-chips multiple deletable-chips hide-no-data hide-selected persistent-hint - :disabled="!collection.id" - placeholder='All' - @input.native='searchTags' - @focus='searchTags' - :delimiters="[',', ';']" - :items="tags" - :label="$t('common.tags')") - template(v-slot:selection="{ item, on, attrs, selected, parent }") - v-chip(v-bind="attrs" close :close-icon='mdiCloseCircle' @click:close='parent.selectItem(item)' - :input-value="selected" label small) {{ item }} - - v-col(cols=4) + v-col(cols=6) v-autocomplete(v-model='filterPlaces' cache-items :prepend-inner-icon="mdiMapMarker" @@ -94,6 +79,7 @@ v-container auto-select-first clearable return-object + hide-details item-text='name' :disabled="!collection.id" @input.native="searchPlaces" @@ -105,12 +91,32 @@ v-container v-chip(v-bind="attrs" close :close-icon='mdiCloseCircle' @click:close='parent.selectItem(item)' :input-value="selected" label small) {{ item.name }} + v-col(cols=6) + v-autocomplete(v-model='filterTags' + cache-items + :prepend-inner-icon="mdiTagMultiple" + chips small-chips multiple deletable-chips hide-no-data hide-selected persistent-hint + :disabled="!collection.id" + hide-details + placeholder='All' + @input.native='searchTags' + @focus='searchTags' + :delimiters="[',', ';']" + :items="tags" + :label="$t('common.tags')") + template(v-slot:selection="{ item, on, attrs, selected, parent }") + v-chip(v-bind="attrs" close :close-icon='mdiCloseCircle' @click:close='parent.selectItem(item)' + :input-value="selected" label small) {{ item }} + + + //- template(v-slot:item="{ item, attrs, on }") //- v-list-item(v-bind='attrs' v-on='on') //- v-list-item-content(two-line) //- v-list-item-title(v-text='item.name') //- v-list-item-subtitle(v-text='item.address') - + v-col(cols=3) + v-switch(inset v-model='negateFilter' label='Not') v-data-table( :headers='filterHeaders' @@ -121,6 +127,8 @@ v-container template(v-slot:item.actions='{ item }') v-btn(@click='removeFilter(item)' color='error' icon) v-icon(v-text='mdiDeleteForever') + template(v-slot:item.negate='{ item }') + v-icon(v-text='item.negate ? mdiNotEqualVariant : mdiEqualBox' :color='item.negate ? "warning" : "success"') template(v-slot:item.tags='{ item }') v-chip.ma-1(small label v-for='tag in item.tags' v-text='tag' :key='tag') template(v-slot:item.places='{ item }') @@ -131,8 +139,8 @@ v-container v-card-actions v-spacer - v-btn(color='primary' outlined :loading='loading' text @click='addFilter' :disabled='loading || !filterActors.length && !filterPlaces.length && !filterTags.length') add - v-btn(@click='dialog = false' outlined color='warning' :disabled="loading || filterActors.length || filterPlaces.length || filterTags.length") {{ $t('common.close') }} + v-btn(color='primary' outlined :loading='loading' text @click='addFilter' :disabled='loading || filterActors.length<1 && filterPlaces.length<1 && filterTags.length<1') add + v-btn(@click='dialog = false' outlined color='warning' :disabled="loading || filterActors.length>0 || filterPlaces.length>0 || filterTags.length>0") {{ $t('common.close') }} v-card-text v-data-table( @@ -160,12 +168,14 @@ import debounce from 'lodash/debounce' import isEqual from 'lodash/isEqual' import sortBy from 'lodash/sortBy' -import { mdiPencil, mdiChevronLeft, mdiChevronRight, mdiMagnify, mdiPlus, mdiTagMultiple, mdiMapMarker, mdiDeleteForever, mdiCloseCircle, mdiChevronDown, mdiWeb } from '@mdi/js' +import { mdiPencil, mdiChevronLeft, mdiChevronRight, mdiMagnify, mdiPlus, mdiTagMultiple, mdiMapMarker, mdiDeleteForever, + mdiCloseCircle, mdiChevronDown, mdiWeb, mdiInformation, mdiNotEqualVariant, mdiEqualBox } from '@mdi/js' export default { data({ $store }) { return { - mdiPencil, mdiChevronRight, mdiChevronLeft, mdiMagnify, mdiPlus, mdiTagMultiple, mdiMapMarker, mdiDeleteForever, mdiCloseCircle, mdiChevronDown, mdiWeb, + mdiPencil, mdiChevronRight, mdiChevronLeft, mdiMagnify, mdiPlus, mdiTagMultiple, mdiMapMarker, mdiDeleteForever, + mdiCloseCircle, mdiChevronDown, mdiWeb, mdiInformation, mdiEqualBox, mdiNotEqualVariant, loading: false, dialog: false, valid: false, @@ -173,6 +183,7 @@ export default { filterTags: [], filterPlaces: [], filterActors: [], + negateFilter: false, actors: [], tags: [], places: [], @@ -187,6 +198,7 @@ export default { { value: 'actions', text: this.$t('common.actions'), align: 'right', width: 150, sortable: false } ], filterHeaders: [ + { value: 'negate', text: '', width: 20, sortable: false }, { value: 'actors', text: this.$t('common.actors') }, { value: 'tags', text: this.$t('common.tags') }, { value: 'places', text: this.$t('common.places') }, @@ -235,7 +247,7 @@ export default { const tags = this.filterTags const places = this.filterPlaces.map(p => ({ id: p.id, name: p.name })) const actors = this.filterActors.map(a => ({ ap_id: a.ap_id, name: a.object?.preferredUsername ?? a.object?.username, domain: a.instanceDomain })) - const filter = { collectionId: this.collection.id, tags, places, actors } + const filter = { collectionId: this.collection.id, tags, places, actors, negate: this.negateFilter } // tags and places are JSON field and there's no way to use them inside a unique constrain const alreadyExists = this.filters.find(f => @@ -244,7 +256,10 @@ export default { isEqual(sortBy(f.actors), sortBy(filter.actors)) ) - if (alreadyExists) return + if (alreadyExists) { + this.$root.$message('Already exists', { color: 'warning' }) + return + } const ret = await this.$axios.$post('/filter', filter ) this.$fetch() @@ -252,6 +267,7 @@ export default { this.filterTags = [] this.filterPlaces = [] this.filterActors = [] + this.negateFilter = false this.loading = false }, async editCollection(collection) { diff --git a/server/api/controller/collection.js b/server/api/controller/collection.js index f61321f5..47a22796 100644 --- a/server/api/controller/collection.js +++ b/server/api/controller/collection.js @@ -45,10 +45,10 @@ const collectionController = { async getEvents (req, res) { const settings = res.locals.settings const exportController = require('./export') - const format = req.params.format || 'json' + const format = req.params?.format ?? 'json' const name = req.params.name - const limit = req.query.max - const start = req.query.start_at || DateTime.local().toUnixInteger() + const limit = req.query?.max ?? 10 + const start = req.query?.start_at ?? DateTime.local().toUnixInteger() const reverse = queryParamToBool(req.query.reverse) const older = queryParamToBool(req.query.older) const show_recurrent = settings.allow_recurrent_event && queryParamToBool(req.query.show_recurrent, settings.recurrent_event_visible) @@ -78,15 +78,17 @@ const collectionController = { async _getEvents ({ name, start, end, show_recurrent=false, - limit, include_description=false, + limit=10, include_description=false, older, reverse }) { + // get the collection from specified name const collection = await Collection.findOne({ where: { name } }) if (!collection) { log.warn(`[COLLECTION] "%s" not found`, name) return [] } + // and all related filters const filters = await Filter.findAll({ where: { collectionId: collection.id } }) // collection is empty if there are no filters @@ -118,32 +120,40 @@ const collectionController = { } const replacements = [] - const ors = [] - + const conditions = [] + const negatedConditions = [] + // collections are a set of filters to match filters.forEach(f => { - let conditions = [] + let tmpConditions = [] if (f.tags && f.tags.length) { const tags = Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=event.id AND ${Col('tagTag')} in (?)`)) replacements.push(f.tags) - conditions.push(tags) + tmpConditions.push(tags) } if (f.places && f.places.length) { - conditions.push({ placeId: f.places.map(p => p.id) }) + tmpConditions.push({ placeId: f.places.map(p => p.id) }) } if (f.actors && f.actors.length) { - conditions.push({ apUserApId: f.actors.map(a => a.ap_id)}) + tmpConditions.push({ apUserApId: f.actors.map(a => a.ap_id)}) } - if (!conditions.length) return - ors.push(conditions.length === 1 ? conditions[0] : { [Op.and]: conditions }) + if (!tmpConditions.length) return + if (f.negate) { + negatedConditions.push(tmpConditions.length === 1 ? tmpConditions[0] : { [Op.and]: tmpConditions }) + } else { + conditions.push(tmpConditions.length === 1 ? tmpConditions[0] : { [Op.and]: tmpConditions }) + } }) - where[Op.and] = { [Op.or]: ors } + where[Op.and] = { + ...(negatedConditions.length > 0 && { [Op.not]: negatedConditions}), + ...(conditions.length > 0 && { [Op.or]: conditions }) + } const events = await Event.findAll({ where, @@ -213,14 +223,10 @@ const collectionController = { }, async addFilter (req, res) { - const { collectionId, tags, places, actors } = req.body + const { collectionId, tags, places, actors, negate } = req.body try { - // if (actors?.length) { - // const actors_to_follow = await APUser.findAll({ where: { ap_id: { [Op.in]: actors.map(a => a.ap_id) }} }) - // await Promise.all(actors_to_follow.map(followActor)) - // } - const filter = await Filter.create({ collectionId, tags, places, actors }) + const filter = await Filter.create({ collectionId, tags, places, actors, negate }) return res.json(filter) } catch (e) { log.error(String(e)) diff --git a/server/api/models/filter.js b/server/api/models/filter.js index 117d2aa7..5a4a0a2c 100644 --- a/server/api/models/filter.js +++ b/server/api/models/filter.js @@ -6,6 +6,9 @@ module.exports = (sequelize, DataTypes) => primaryKey: true, autoIncrement: true, }, + negate: { + type: DataTypes.BOOLEAN + }, tags: { type: DataTypes.JSON, }, diff --git a/server/migrations/20240517093747-filter_collection_negate.js b/server/migrations/20240517093747-filter_collection_negate.js new file mode 100644 index 00000000..33ee6dbc --- /dev/null +++ b/server/migrations/20240517093747-filter_collection_negate.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + return queryInterface.addColumn('filters', 'negate', { type: Sequelize.BOOLEAN }) + }, + + async down (queryInterface, Sequelize) { + return queryInterface.removeColumn('filters', 'negate') + } +}