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')
+ }
+}