1
0

chore: move frontend files

This commit is contained in:
kolaente
2024-02-07 14:56:56 +01:00
parent 447641c222
commit fc4676315d
606 changed files with 0 additions and 0 deletions

View File

@ -0,0 +1,462 @@
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
import type {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
import AbstractModel from '@/models/abstractModel'
import type {IAbstract} from '@/modelTypes/IAbstract'
import type {Right} from '@/constants/rights'
interface Paths {
create : string
get : string
getAll : string
update : string
delete : string
reset?: string
}
function convertObject(o: Record<string, unknown>) {
if (o instanceof Date) {
return o.toISOString()
}
return o
}
function prepareParams(params: Record<string, unknown | unknown[]>) {
if (typeof params !== 'object') {
return params
}
for (const p in params) {
if (Array.isArray(params[p])) {
params[p] = params[p].map(convertObject)
continue
}
params[p] = convertObject(params[p])
}
return objectToSnakeCase(params)
}
export default abstract class AbstractService<Model extends IAbstract = IAbstract> {
/////////////////////////////
// Initial variable definitions
///////////////////////////
http
loading = false
uploadProgress = 0
paths: Paths = {
create: '',
get: '',
getAll: '',
update: '',
delete: '',
}
// This contains the total number of pages and the number of results for the current page
totalPages = 0
resultCount = 0
/////////////
// Service init
///////////
/**
* The abstract constructor.
* @param [paths] An object with all paths.
*/
constructor(paths : Partial<Paths> = {}) {
this.http = AuthenticatedHTTPFactory()
// Set the interceptors to process every request
this.http.interceptors.request.use((config) => {
switch (config.method) {
case 'post':
if (this.useUpdateInterceptor()) {
config.data = this.beforeUpdate(config.data)
config.data = objectToSnakeCase(config.data)
}
break
case 'put':
if (this.useCreateInterceptor()) {
config.data = this.beforeCreate(config.data)
config.data = objectToSnakeCase(config.data)
}
break
case 'delete':
if (this.useDeleteInterceptor()) {
config.data = this.beforeDelete(config.data)
config.data = objectToSnakeCase(config.data)
}
break
}
return config
})
Object.assign(this.paths, paths)
}
/**
* Whether or not to use the create interceptor which processes a request payload into json
*/
useCreateInterceptor(): boolean {
return true
}
/**
* Whether or not to use the update interceptor which processes a request payload into json
*/
useUpdateInterceptor(): boolean {
return true
}
/**
* Whether or not to use the delete interceptor which processes a request payload into json
*/
useDeleteInterceptor(): boolean {
return true
}
/////////////////
// Helper functions
///////////////
/**
* Returns an object with all route parameters and their values.
* @example
* getRouteReplacements(
* '/tasks/{taskId}/assignees/{userId}',
* { taskId: 7, userId: 2 },
* )
* // { "{taskId}": 7, "{userId}": 2 }
*/
getRouteReplacements(route : string, parameters : Record<string, unknown> = {}) {
const replace$$1: Record<string, unknown> = {}
let pattern = this.getRouteParameterPattern()
pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g')
for (let parameter; (parameter = pattern.exec(route)) !== null;) {
replace$$1[parameter[0]] = parameters[parameter[1]]
}
return replace$$1
}
/**
* Holds the replacement pattern for url paths, can be overwritten by implementations.
*/
getRouteParameterPattern(): RegExp {
return /{([^}]+)}/
}
/**
* Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters.
* @example
* getReplacedRoute('/projects/{projectId}/tasks', { projectId: 3 })
* === '/projects/{projectId}/tasks'
*/
getReplacedRoute(path : string, pathparams : Record<string, unknown>) : string {
const replacements = this.getRouteReplacements(path, pathparams)
return Object.entries(replacements).reduce(
(result, [parameter, value]) => result.replace(parameter, value as string),
path,
)
}
/**
* setLoading is a method which sets the loading variable to true, after a timeout of 100ms.
* It has the timeout to prevent the loading indicator from showing for only a blink of an eye in the
* case the api returns a response in < 100ms.
* But because the timeout is created using setTimeout, it will still trigger even if the request is
* already finished, so we return a method to call in that case.
*/
setLoading() {
const timeout = setTimeout(() => {
this.loading = true
}, 100)
return () => {
clearTimeout(timeout)
this.loading = false
}
}
//////////////////
// Default factories
// It is possible to specify a factory for each type of request.
// This makes it possible to have different models returned from different routes.
// Specific factories for each request are completly optional, if these are not specified, the defautl factory is used.
////////////////
/**
* The modelFactory returns a model from an object.
* This one here is the default one, usually the service definitions for a model will override this.
*/
modelFactory(data : Partial<Model>) {
return data as Model
}
/**
* This is the model factory for get requests.
*/
modelGetFactory(data : Partial<Model>) {
return this.modelFactory(data)
}
/**
* This is the model factory for get all requests.
*/
modelGetAllFactory(data : Partial<Model>) {
return this.modelFactory(data)
}
/**
* This is the model factory for create requests.
*/
modelCreateFactory(data : Partial<Model>) {
return this.modelFactory(data)
}
/**
* This is the model factory for update requests.
*/
modelUpdateFactory(data : Partial<Model>) {
return this.modelFactory(data)
}
//////////////
// Preprocessors
////////////
/**
* Default preprocessor for get requests
*/
beforeGet(model : Model) {
return model
}
/**
* Default preprocessor for create requests
*/
beforeCreate(model : Model) {
return model
}
/**
* Default preprocessor for update requests
*/
beforeUpdate(model : Model) {
return model
}
/**
* Default preprocessor for delete requests
*/
beforeDelete(model : Model) {
return model
}
///////////////
// Global actions
/////////////
/**
* Performs a get request to the url specified before.
* @param model The model to use. The request path is built using the values from the model.
* @param params Optional query parameters
*/
get(model : Model, params = {}) {
if (this.paths.get === '') {
throw new Error('This model is not able to get data.')
}
return this.getM(this.paths.get, model, params)
}
/**
* This is a more abstract implementation which only does a get request.
* Services which need more flexibility can use this.
*/
async getM(url : string, model : Model = new AbstractModel({}), params: Record<string, unknown> = {}) {
const cancel = this.setLoading()
model = this.beforeGet(model)
const finalUrl = this.getReplacedRoute(url, model)
try {
const response = await this.http.get(finalUrl, {params: prepareParams(params)})
const result = this.modelGetFactory(response.data)
result.maxRight = Number(response.headers['x-max-right']) as Right
return result
} finally {
cancel()
}
}
async getBlobUrl(url : string, method : Method = 'GET', data = {}) {
const response = await this.http({
url,
method,
responseType: 'blob',
data,
})
return window.URL.createObjectURL(new Blob([response.data]))
}
/**
* Performs a get request to the url specified before.
* The difference between this and get() is this one is used to get a bunch of data (an array), not just a single object.
* @param model The model to use. The request path is built using the values from the model.
* @param params Optional query parameters
* @param page The page to get
*/
async getAll(model : Model = new AbstractModel({}), params = {}, page = 1): Promise<Model[]> {
if (this.paths.getAll === '') {
throw new Error('This model is not able to get data.')
}
params.page = page
const cancel = this.setLoading()
model = this.beforeGet(model)
const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
try {
const response = await this.http.get(finalUrl, {params: prepareParams(params)})
this.resultCount = Number(response.headers['x-pagination-result-count'])
this.totalPages = Number(response.headers['x-pagination-total-pages'])
if (!Array.isArray(response.data)) {
return []
}
return response.data.map(entry => this.modelGetAllFactory(entry))
} finally {
cancel()
}
}
/**
* Performs a put request to the url specified before
* @returns {Promise<any | never>}
*/
async create(model : Model) {
if (this.paths.create === '') {
throw new Error('This model is not able to create data.')
}
const cancel = this.setLoading()
const finalUrl = this.getReplacedRoute(this.paths.create, model)
try {
const response = await this.http.put(finalUrl, model)
const result = this.modelCreateFactory(response.data)
if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight
}
return result
} finally {
cancel()
}
}
/**
* An abstract implementation to send post requests.
* Services can use this to implement functions to do post requests other than using the update method.
*/
async post(url : string, model : Model) {
const cancel = this.setLoading()
try {
const response = await this.http.post(url, model)
const result = this.modelUpdateFactory(response.data)
if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight
}
return result
} finally {
cancel()
}
}
/**
* Performs a post request to the update url
*/
update(model : Model) {
if (this.paths.update === '') {
throw new Error('This model is not able to update data.')
}
const finalUrl = this.getReplacedRoute(this.paths.update, model)
return this.post(finalUrl, model)
}
/**
* Performs a delete request to the update url
*/
async delete(model : Model) {
if (this.paths.delete === '') {
throw new Error('This model is not able to delete data.')
}
const cancel = this.setLoading()
const finalUrl = this.getReplacedRoute(this.paths.delete, model)
try {
const {data} = await this.http.delete(finalUrl, model)
return data
} finally {
cancel()
}
}
/**
* Uploads a file to a url.
* @param url
* @param file {IFile}
* @param fieldName The name of the field the file is uploaded to.
*/
uploadFile(url : string, file: File, fieldName : string) {
return this.uploadBlob(url, new Blob([file]), fieldName, file.name)
}
/**
* Uploads a blob to a url.
*/
uploadBlob(url : string, blob: Blob, fieldName: string, filename : string) {
const data = new FormData()
data.append(fieldName, blob, filename)
return this.uploadFormData(url, data)
}
/**
* Uploads a form data object.
*/
async uploadFormData(url : string, formData: FormData) {
const cancel = this.setLoading()
try {
const response = await this.http.put(
url,
formData,
{
headers: {
'Content-Type':
'multipart/form-data; boundary=' + formData._boundary,
},
// fix upload issue after upgrading to axios to 1.0.0
// see: https://github.com/axios/axios/issues/4885#issuecomment-1222419132
transformRequest: formData => formData,
onUploadProgress: ({progress}) => {
this.uploadProgress = progress? Math.round((progress * 100)) : 0
},
},
)
return this.modelCreateFactory(response.data)
} finally {
this.uploadProgress = 0
cancel()
}
}
}

View File

@ -0,0 +1,15 @@
import AbstractService from './abstractService'
export default class AccountDeleteService extends AbstractService {
request(password: string) {
return this.post('/user/deletion/request', {password})
}
confirm(token: string) {
return this.post('/user/deletion/confirm', {token})
}
cancel(password: string) {
return this.post('/user/deletion/cancel', {password})
}
}

View File

@ -0,0 +1,36 @@
import AbstractService from '@/services/abstractService'
import type {IApiToken} from '@/modelTypes/IApiToken'
import ApiTokenModel from '@/models/apiTokenModel'
export default class ApiTokenService extends AbstractService<IApiToken> {
constructor() {
super({
create: '/tokens',
getAll: '/tokens',
delete: '/tokens/{id}',
})
}
processModel(model: IApiToken) {
return {
...model,
expiresAt: new Date(model.expiresAt).toISOString(),
created: new Date(model.created).toISOString(),
}
}
modelFactory(data: Partial<IApiToken>) {
return new ApiTokenModel(data)
}
async getAvailableRoutes() {
const cancel = this.setLoading()
try {
const response = await this.http.get('/routes')
return response.data
} finally {
cancel()
}
}
}

View File

@ -0,0 +1,66 @@
import AbstractService from './abstractService'
import AttachmentModel from '../models/attachment'
import type { IAttachment } from '@/modelTypes/IAttachment'
import {downloadBlob} from '@/helpers/downloadBlob'
export default class AttachmentService extends AbstractService<IAttachment> {
constructor() {
super({
create: '/tasks/{taskId}/attachments',
getAll: '/tasks/{taskId}/attachments',
delete: '/tasks/{taskId}/attachments/{id}',
})
}
processModel(model: IAttachment) {
return {
...model,
created: new Date(model.created).toISOString(),
}
}
useCreateInterceptor() {
return false
}
modelFactory(data: Partial<IAttachment>) {
return new AttachmentModel(data)
}
modelCreateFactory(data) {
// Success contains the uploaded attachments
data.success = (data.success === null ? [] : data.success).map(a => {
return this.modelFactory(a)
})
return data
}
getBlobUrl(model: IAttachment) {
return AbstractService.prototype.getBlobUrl.call(this, '/tasks/' + model.taskId + '/attachments/' + model.id)
}
async download(model: IAttachment) {
const url = await this.getBlobUrl(model)
return downloadBlob(url, model.file.name)
}
/**
* Uploads a file to the server
* @param files
* @returns {Promise<any|never>}
*/
create(model: IAttachment, files: File[] | FileList) {
const data = new FormData()
for (let i = 0; i < files.length; i++) {
// TODO: Validation of file size
data.append('files', new Blob([files[i]]), files[i].name)
}
return this.uploadFormData(
this.getReplacedRoute(this.paths.create, model),
data,
)
}
}

View File

@ -0,0 +1,30 @@
import AbstractService from './abstractService'
import AvatarModel from '@/models/avatar'
import type { IAvatar } from '@/modelTypes/IAvatar'
export default class AvatarService extends AbstractService<IAvatar> {
constructor() {
super({
get: '/user/settings/avatar',
update: '/user/settings/avatar',
create: '/user/settings/avatar/upload',
})
}
modelFactory(data: Partial<IAvatar>) {
return new AvatarModel(data)
}
useCreateInterceptor() {
return false
}
create(blob) {
return this.uploadBlob(
this.paths.create,
blob,
'avatar',
'avatar.jpg', // This fails without a file name
)
}
}

View File

@ -0,0 +1,30 @@
import AbstractService from './abstractService'
import BackgroundImageModel from '../models/backgroundImage'
import ProjectModel from '@/models/project'
import type { IBackgroundImage } from '@/modelTypes/IBackgroundImage'
export default class BackgroundUnsplashService extends AbstractService<IBackgroundImage> {
constructor() {
super({
getAll: '/backgrounds/unsplash/search',
update: '/projects/{projectId}/backgrounds/unsplash',
})
}
modelFactory(data: Partial<IBackgroundImage>) {
return new BackgroundImageModel(data)
}
modelUpdateFactory(data) {
return new ProjectModel(data)
}
async thumb(model) {
const response = await this.http({
url: `/backgrounds/unsplash/images/${model.id}/thumb`,
method: 'GET',
responseType: 'blob',
})
return window.URL.createObjectURL(new Blob([response.data]))
}
}

View File

@ -0,0 +1,32 @@
import AbstractService from './abstractService'
import ProjectModel from '@/models/project'
import type { IProject } from '@/modelTypes/IProject'
import type { IFile } from '@/modelTypes/IFile'
export default class BackgroundUploadService extends AbstractService {
constructor() {
super({
create: '/projects/{projectId}/backgrounds/upload',
})
}
useCreateInterceptor() {
return false
}
modelCreateFactory(data: Partial<IProject>) {
return new ProjectModel(data)
}
/**
* Uploads a file to the server
*/
create(projectId: IProject['id'], file: IFile) {
return this.uploadFile(
this.getReplacedRoute(this.paths.create, {projectId}),
file,
'background',
)
}
}

View File

@ -0,0 +1,25 @@
import AbstractService from './abstractService'
import BucketModel from '../models/bucket'
import TaskService from '@/services/task'
import type { IBucket } from '@/modelTypes/IBucket'
export default class BucketService extends AbstractService<IBucket> {
constructor() {
super({
getAll: '/projects/{projectId}/buckets',
create: '/projects/{projectId}/buckets',
update: '/projects/{projectId}/buckets/{id}',
delete: '/projects/{projectId}/buckets/{id}',
})
}
modelFactory(data: Partial<IBucket>) {
return new BucketModel(data)
}
beforeUpdate(model) {
const taskService = new TaskService()
model.tasks = model.tasks?.map(t => taskService.processModel(t))
return model
}
}

View File

@ -0,0 +1,17 @@
import CaldavTokenModel from '@/models/caldavToken'
import type {ICaldavToken} from '@/modelTypes/ICaldavToken'
import AbstractService from './abstractService'
export default class CaldavTokenService extends AbstractService<ICaldavToken> {
constructor() {
super({
getAll: '/user/settings/token/caldav',
create: '/user/settings/token/caldav',
delete: '/user/settings/token/caldav/{id}',
})
}
modelFactory(data) {
return new CaldavTokenModel(data)
}
}

View File

@ -0,0 +1,20 @@
import AbstractService from './abstractService'
import {downloadBlob} from '../helpers/downloadBlob'
const DOWNLOAD_NAME = 'vikunja-export.zip'
export default class DataExportService extends AbstractService {
request(password: string) {
return this.post('/user/export/request', {password})
}
async download(password: string) {
const clear = this.setLoading()
try {
const url = await this.getBlobUrl('/user/export/download', 'POST', {password})
downloadBlob(url, DOWNLOAD_NAME)
} finally {
clear()
}
}
}

View File

@ -0,0 +1,9 @@
import AbstractService from './abstractService'
export default class EmailUpdateService extends AbstractService {
constructor() {
super({
update: '/user/settings/email',
})
}
}

View File

@ -0,0 +1,35 @@
import AbstractService from './abstractService'
import LabelModel from '@/models/label'
import type {ILabel} from '@/modelTypes/ILabel'
import {colorFromHex} from '@/helpers/color/colorFromHex'
export default class LabelService extends AbstractService<ILabel> {
constructor() {
super({
create: '/labels',
getAll: '/labels',
get: '/labels/{id}',
update: '/labels/{id}',
delete: '/labels/{id}',
})
}
processModel(label) {
label.created = new Date(label.created).toISOString()
label.updated = new Date(label.updated).toISOString()
label.hexColor = colorFromHex(label.hexColor)
return label
}
modelFactory(data) {
return new LabelModel(data)
}
beforeUpdate(label) {
return this.processModel(label)
}
beforeCreate(label) {
return this.processModel(label)
}
}

View File

@ -0,0 +1,17 @@
import AbstractService from './abstractService'
import LabelTask from '@/models/labelTask'
import type {ILabelTask} from '@/modelTypes/ILabelTask'
export default class LabelTaskService extends AbstractService<ILabelTask> {
constructor() {
super({
create: '/tasks/{taskId}/labels',
getAll: '/tasks/{taskId}/labels',
delete: '/tasks/{taskId}/labels/{labelId}',
})
}
modelFactory(data) {
return new LabelTask(data)
}
}

View File

@ -0,0 +1,18 @@
import AbstractService from './abstractService'
import LinkShareModel from '@/models/linkShare'
import type {ILinkShare} from '@/modelTypes/ILinkShare'
export default class LinkShareService extends AbstractService<ILinkShare> {
constructor() {
super({
getAll: '/projects/{projectId}/shares',
get: '/projects/{projectId}/shares/{id}',
create: '/projects/{projectId}/shares',
delete: '/projects/{projectId}/shares/{id}',
})
}
modelFactory(data) {
return new LinkShareModel(data)
}
}

View File

@ -0,0 +1,28 @@
import AbstractService from '../abstractService'
export type MigrationConfig = { code: string }
// This service builds on top of the abstract service and basically just hides away method names.
// It enables migration services to be created with minimal overhead and even better method names.
export default class AbstractMigrationService extends AbstractService<MigrationConfig> {
serviceUrlKey = ''
constructor(serviceUrlKey: string) {
super({
update: '/migration/' + serviceUrlKey + '/migrate',
})
this.serviceUrlKey = serviceUrlKey
}
getAuthUrl() {
return this.getM('/migration/' + this.serviceUrlKey + '/auth')
}
getStatus() {
return this.getM('/migration/' + this.serviceUrlKey + '/status')
}
migrate(data: MigrationConfig) {
return this.update(data)
}
}

View File

@ -0,0 +1,30 @@
import AbstractService from '../abstractService'
// This service builds on top of the abstract service and basically just hides away method names.
// It enables migration services to be created with minimal overhead and even better method names.
export default class AbstractMigrationFileService extends AbstractService {
serviceUrlKey = ''
constructor(serviceUrlKey: string) {
super({
create: '/migration/' + serviceUrlKey + '/migrate',
})
this.serviceUrlKey = serviceUrlKey
}
getStatus() {
return this.getM('/migration/' + this.serviceUrlKey + '/status')
}
useCreateInterceptor() {
return false
}
migrate(file: File) {
return this.uploadFile(
this.paths.create,
file,
'import',
)
}
}

View File

@ -0,0 +1,30 @@
import AbstractService from '@/services/abstractService'
import NotificationModel from '@/models/notification'
import type {INotification} from '@/modelTypes/INotification'
export default class NotificationService extends AbstractService<INotification> {
constructor() {
super({
getAll: '/notifications',
update: '/notifications/{id}',
})
}
modelFactory(data) {
return new NotificationModel(data)
}
beforeUpdate(model) {
if (!model) {
return model
}
model.created = new Date(model.created).toISOString()
model.readAt = new Date(model.readAt).toISOString()
return model
}
async markAllRead() {
return this.post('/notifications', false)
}
}

View File

@ -0,0 +1,38 @@
import AbstractService from './abstractService'
import PasswordResetModel from '@/models/passwordReset'
import type {IPasswordReset} from '@/modelTypes/IPasswordReset'
export default class PasswordResetService extends AbstractService<IPasswordReset> {
constructor() {
super({})
this.paths = {
reset: '/user/password/reset',
requestReset: '/user/password/token',
}
}
modelFactory(data) {
return new PasswordResetModel(data)
}
async resetPassword(model) {
const cancel = this.setLoading()
try {
const response = await this.http.post(this.paths.reset, model)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
async requestResetPassword(model) {
const cancel = this.setLoading()
try {
const response = await this.http.post(this.paths.requestReset, model)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
}

View File

@ -0,0 +1,10 @@
import AbstractService from './abstractService'
import type {IPasswordUpdate} from '@/modelTypes/IPasswordUpdate'
export default class PasswordUpdateService extends AbstractService<IPasswordUpdate> {
constructor() {
super({
update: '/user/password',
})
}
}

View File

@ -0,0 +1,65 @@
import AbstractService from './abstractService'
import ProjectModel from '@/models/project'
import type {IProject} from '@/modelTypes/IProject'
import TaskService from './task'
import {colorFromHex} from '@/helpers/color/colorFromHex'
export default class ProjectService extends AbstractService<IProject> {
constructor() {
super({
create: '/projects',
get: '/projects/{id}',
getAll: '/projects',
update: '/projects/{id}',
delete: '/projects/{id}',
})
}
modelFactory(data) {
return new ProjectModel(data)
}
beforeUpdate(model) {
if(typeof model.tasks !== 'undefined') {
const taskService = new TaskService()
model.tasks = model.tasks.map(task => {
return taskService.beforeUpdate(task)
})
}
if(typeof model.hexColor !== 'undefined') {
model.hexColor = colorFromHex(model.hexColor)
}
return model
}
beforeCreate(project) {
project.hexColor = colorFromHex(project.hexColor)
return project
}
async background(project: Pick<IProject, 'id' | 'backgroundInformation'>) {
if (project.backgroundInformation === null) {
return ''
}
const response = await this.http({
url: `/projects/${project.id}/background`,
method: 'GET',
responseType: 'blob',
})
return window.URL.createObjectURL(new Blob([response.data]))
}
async removeBackground(project: Pick<IProject, 'id'>) {
const cancel = this.setLoading()
try {
const response = await this.http.delete(`/projects/${project.id}/background`, project)
return response.data
} finally {
cancel()
}
}
}

View File

@ -0,0 +1,21 @@
import AbstractService from './abstractService'
import projectDuplicateModel from '@/models/projectDuplicateModel'
import type {IProjectDuplicate} from '@/modelTypes/IProjectDuplicate'
export default class ProjectDuplicateService extends AbstractService<IProjectDuplicate> {
constructor() {
super({
create: '/projects/{projectId}/duplicate',
})
}
beforeCreate(model) {
model.project = null
return model
}
modelFactory(data) {
return new projectDuplicateModel(data)
}
}

View File

@ -0,0 +1,14 @@
import AbstractService from './abstractService'
import UserModel from '../models/user'
export default class ProjectUserService extends AbstractService {
constructor() {
super({
getAll: '/projects/{projectId}/projectusers',
})
}
modelFactory(data) {
return new UserModel(data)
}
}

View File

@ -0,0 +1,172 @@
import {computed, ref, shallowReactive, unref, watch} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import type {MaybeRef} from '@vueuse/core'
import {useDebounceFn} from '@vueuse/core'
import type {IProject} from '@/modelTypes/IProject'
import type {ISavedFilter} from '@/modelTypes/ISavedFilter'
import AbstractService from '@/services/abstractService'
import SavedFilterModel from '@/models/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {objectToSnakeCase, objectToCamelCase} from '@/helpers/case'
import {success} from '@/message'
import ProjectModel from '@/models/project'
/**
* Calculates the corresponding project id to this saved filter.
* This function matches the one in the api.
*/
function getProjectId(savedFilter: ISavedFilter) {
let projectId = savedFilter.id * -1 - 1
if (projectId > 0) {
projectId = 0
}
return projectId
}
export function getSavedFilterIdFromProjectId(projectId: IProject['id']) {
let filterId = projectId * -1 - 1
// FilterIds from projectIds are always positive
if (filterId < 0) {
filterId = 0
}
return filterId
}
export function isSavedFilter(project: IProject) {
return getSavedFilterIdFromProjectId(project?.id) > 0
}
export default class SavedFilterService extends AbstractService<ISavedFilter> {
constructor() {
super({
get: '/filters/{id}',
create: '/filters',
update: '/filters/{id}',
delete: '/filters/{id}',
})
}
modelFactory(data) {
return new SavedFilterModel(data)
}
processModel(model) {
// Make filters from this.filters camelCase and set them to the model property:
// That's easier than making the whole filter component configurable since that still needs to provide
// the filter values in snake_sćase for url parameters.
model.filters = objectToCamelCase(model.filters)
// Make sure all filterValues are passes as strings. This is a requirement of the api.
model.filters.filterValue = model.filters.filterValue.map(v => String(v))
return model
}
beforeUpdate(model) {
return this.processModel(model)
}
beforeCreate(model) {
return this.processModel(model)
}
}
export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
const router = useRouter()
const {t} = useI18n({useScope:'global'})
const projectStore = useProjectStore()
const filterService = shallowReactive(new SavedFilterService())
const filter = ref<ISavedFilter>(new SavedFilterModel())
const filters = computed({
get: () => filter.value.filters,
set(value) {
filter.value.filters = value
},
})
// load SavedFilter
watch(() => unref(projectId), async (watchedProjectId) => {
if (watchedProjectId === undefined) {
return
}
// We assume the projectId in the route is the pseudoproject
const savedFilterId = getSavedFilterIdFromProjectId(watchedProjectId)
filter.value = new SavedFilterModel({id: savedFilterId})
const response = await filterService.get(filter.value)
response.filters = objectToSnakeCase(response.filters)
filter.value = response
await validateTitleField()
}, {immediate: true})
async function createFilter() {
filter.value = await filterService.create(filter.value)
await projectStore.loadProjects()
router.push({name: 'project.index', params: {projectId: getProjectId(filter.value)}})
}
async function saveFilter() {
const response = await filterService.update(filter.value)
await projectStore.loadProjects()
success({message: t('filters.edit.success')})
response.filters = objectToSnakeCase(response.filters)
filter.value = response
await useBaseStore().setCurrentProject(new ProjectModel({
id: getProjectId(filter.value),
title: filter.value.title,
}))
router.back()
}
async function deleteFilter() {
await filterService.delete(filter.value)
await projectStore.loadProjects()
success({message: t('filters.delete.success')})
router.push({name: 'projects.index'})
}
const titleValid = ref(true)
const validateTitleField = useDebounceFn(() => {
titleValid.value = filter.value.title !== ''
}, 100)
async function createFilterWithValidation() {
if (!titleValid.value) {
return
}
return createFilter()
}
async function saveFilterWithValidation() {
if (!titleValid.value) {
return
}
return saveFilter()
}
return {
createFilter,
createFilterWithValidation,
saveFilter,
saveFilterWithValidation,
deleteFilter,
filter,
filters,
filterService,
titleValid,
validateTitleField,
}
}

View File

@ -0,0 +1,16 @@
import AbstractService from '@/services/abstractService'
import SubscriptionModel from '@/models/subscription'
import type {ISubscription} from '@/modelTypes/ISubscription'
export default class SubscriptionService extends AbstractService<ISubscription> {
constructor() {
super({
create: '/subscriptions/{entity}/{entityId}',
delete: '/subscriptions/{entity}/{entityId}',
})
}
modelFactory(data) {
return new SubscriptionModel(data)
}
}

View File

@ -0,0 +1,114 @@
import AbstractService from './abstractService'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import AttachmentService from './attachment'
import LabelService from './label'
import {colorFromHex} from '@/helpers/color/colorFromHex'
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK} from '@/constants/date'
const parseDate = date => {
if (date) {
return new Date(date).toISOString()
}
return null
}
export default class TaskService extends AbstractService<ITask> {
constructor() {
super({
create: '/projects/{projectId}/tasks',
getAll: '/tasks/all',
get: '/tasks/{id}',
update: '/tasks/{id}',
delete: '/tasks/{id}',
})
}
modelFactory(data) {
return new TaskModel(data)
}
beforeUpdate(model) {
return this.processModel(model)
}
beforeCreate(model) {
return this.processModel(model)
}
processModel(updatedModel) {
const model = { ...updatedModel }
model.title = model.title?.trim()
// Ensure that projectId is an int
model.projectId = Number(model.projectId)
// Convert dates into an iso string
model.dueDate = parseDate(model.dueDate)
model.startDate = parseDate(model.startDate)
model.endDate = parseDate(model.endDate)
model.doneAt = parseDate(model.doneAt)
model.created = new Date(model.created).toISOString()
model.updated = new Date(model.updated).toISOString()
model.reminderDates = null
// remove all nulls, these would create empty reminders
for (const index in model.reminders) {
if (model.reminders[index] === null) {
model.reminders.splice(index, 1)
}
}
// Make normal timestamps from js dates
if (model.reminders.length > 0) {
model.reminders.forEach(r => {
r.reminder = new Date(r.reminder).toISOString()
})
}
// Make the repeating amount to seconds
let repeatAfterSeconds = 0
if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) {
switch (model.repeatAfter.type) {
case 'hours':
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_HOUR
break
case 'days':
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_DAY
break
case 'weeks':
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_WEEK
break
}
}
model.repeatAfter = repeatAfterSeconds
model.hexColor = colorFromHex(model.hexColor)
// Do the same for all related tasks
Object.keys(model.relatedTasks).forEach(relationKind => {
model.relatedTasks[relationKind] = model.relatedTasks[relationKind].map(t => {
return this.processModel(t)
})
})
// Process all attachments to preven parsing errors
if (model.attachments.length > 0) {
const attachmentService = new AttachmentService()
model.attachments.map(a => {
return attachmentService.processModel(a)
})
}
// Preprocess all labels
if (model.labels.length > 0) {
const labelService = new LabelService()
model.labels = model.labels.map(l => labelService.processModel(l))
}
return model as ITask
}
}

View File

@ -0,0 +1,16 @@
import AbstractService from './abstractService'
import TaskAssigneeModel from '@/models/taskAssignee'
import type {ITaskAssignee} from '@/modelTypes/ITaskAssignee'
export default class TaskAssigneeService extends AbstractService<ITaskAssignee> {
constructor() {
super({
create: '/tasks/{taskId}/assignees',
delete: '/tasks/{taskId}/assignees/{userId}',
})
}
modelFactory(data) {
return new TaskAssigneeModel(data)
}
}

View File

@ -0,0 +1,27 @@
import AbstractService from '@/services/abstractService'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
// FIXME: unite with other filter params types
export interface GetAllTasksParams {
sort_by: ('start_date' | 'done' | 'id')[],
order_by: ('asc' | 'asc' | 'desc')[],
filter_by: 'start_date'[],
filter_comparator: ('greater_equals' | 'less_equals')[],
filter_value: [string, string] // [dateFrom, dateTo],
filter_concat: 'and',
filter_include_nulls: boolean,
}
export default class TaskCollectionService extends AbstractService<ITask> {
constructor() {
super({
getAll: '/projects/{projectId}/tasks',
})
}
modelFactory(data) {
return new TaskModel(data)
}
}

View File

@ -0,0 +1,19 @@
import AbstractService from './abstractService'
import TaskCommentModel from '@/models/taskComment'
import type {ITaskComment} from '@/modelTypes/ITaskComment'
export default class TaskCommentService extends AbstractService<ITaskComment> {
constructor() {
super({
create: '/tasks/{taskId}/comments',
getAll: '/tasks/{taskId}/comments',
get: '/tasks/{taskId}/comments/{id}',
update: '/tasks/{taskId}/comments/{id}',
delete: '/tasks/{taskId}/comments/{id}',
})
}
modelFactory(data) {
return new TaskCommentModel(data)
}
}

View File

@ -0,0 +1,16 @@
import AbstractService from './abstractService'
import TaskRelationModel from '@/models/taskRelation'
import type {ITaskRelation} from '@/modelTypes/ITaskRelation'
export default class TaskRelationService extends AbstractService<ITaskRelation> {
constructor() {
super({
create: '/tasks/{taskId}/relations',
delete: '/tasks/{taskId}/relations/{relationKind}/{otherTaskId}',
})
}
modelFactory(data) {
return new TaskRelationModel(data)
}
}

View File

@ -0,0 +1,19 @@
import AbstractService from './abstractService'
import TeamModel from '@/models/team'
import type {ITeam} from '@/modelTypes/ITeam'
export default class TeamService extends AbstractService<ITeam> {
constructor() {
super({
create: '/teams',
get: '/teams/{id}',
getAll: '/teams',
update: '/teams/{id}',
delete: '/teams/{id}',
})
}
modelFactory(data) {
return new TeamModel(data)
}
}

View File

@ -0,0 +1,23 @@
import AbstractService from './abstractService'
import TeamMemberModel from '@/models/teamMember'
import type {ITeamMember} from '@/modelTypes/ITeamMember'
export default class TeamMemberService extends AbstractService<ITeamMember> {
constructor() {
super({
create: '/teams/{teamId}/members',
delete: '/teams/{teamId}/members/{username}',
update: '/teams/{teamId}/members/{username}/admin',
})
}
modelFactory(data) {
return new TeamMemberModel(data)
}
beforeCreate(model) {
model.userId = model.id // The api wants to get the user id as user_Id
model.admin = model.admin === null ? false : model.admin
return model
}
}

View File

@ -0,0 +1,23 @@
import AbstractService from './abstractService'
import TeamProjectModel from '@/models/teamProject'
import type {ITeamProject} from '@/modelTypes/ITeamProject'
import TeamModel from '@/models/team'
export default class TeamProjectService extends AbstractService<ITeamProject> {
constructor() {
super({
create: '/projects/{projectId}/teams',
getAll: '/projects/{projectId}/teams',
update: '/projects/{projectId}/teams/{teamId}',
delete: '/projects/{projectId}/teams/{teamId}',
})
}
modelFactory(data) {
return new TeamProjectModel(data)
}
modelGetAllFactory(data) {
return new TeamModel(data)
}
}

View File

@ -0,0 +1,38 @@
import AbstractService from './abstractService'
import TotpModel from '@/models/totp'
import type {ITotp} from '@/modelTypes/ITotp'
export default class TotpService extends AbstractService<ITotp> {
urlPrefix = '/user/settings/totp'
constructor() {
super({})
this.paths.get = this.urlPrefix
}
modelFactory(data) {
return new TotpModel(data)
}
enroll() {
return this.post(`${this.urlPrefix}/enroll`, {})
}
enable(model) {
return this.post(`${this.urlPrefix}/enable`, model)
}
disable(model) {
return this.post(`${this.urlPrefix}/disable`, model)
}
async qrcode() {
const response = await this.http({
url: `${this.urlPrefix}/qrcode`,
method: 'GET',
responseType: 'blob',
})
return new Blob([response.data])
}
}

View File

@ -0,0 +1,15 @@
import AbstractService from './abstractService'
import UserModel from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
export default class UserService extends AbstractService<IUser> {
constructor() {
super({
getAll: '/users',
})
}
modelFactory(data) {
return new UserModel(data)
}
}

View File

@ -0,0 +1,23 @@
import AbstractService from './abstractService'
import UserProjectModel from '@/models/userProject'
import type {IUserProject} from '@/modelTypes/IUserProject'
import UserModel from '@/models/user'
export default class UserProjectService extends AbstractService<IUserProject> {
constructor() {
super({
create: '/projects/{projectId}/users',
getAll: '/projects/{projectId}/users',
update: '/projects/{projectId}/users/{userId}',
delete: '/projects/{projectId}/users/{userId}',
})
}
modelFactory(data) {
return new UserProjectModel(data)
}
modelGetAllFactory(data) {
return new UserModel(data)
}
}

View File

@ -0,0 +1,10 @@
import type {IUserSettings} from '@/modelTypes/IUserSettings'
import AbstractService from './abstractService'
export default class UserSettingsService extends AbstractService<IUserSettings> {
constructor() {
super({
update: '/user/settings/general',
})
}
}

View File

@ -0,0 +1,29 @@
import AbstractService from '@/services/abstractService'
import type {IWebhook} from '@/modelTypes/IWebhook'
import WebhookModel from '@/models/webhook'
export default class WebhookService extends AbstractService<IWebhook> {
constructor() {
super({
getAll: '/projects/{projectId}/webhooks',
create: '/projects/{projectId}/webhooks',
update: '/projects/{projectId}/webhooks/{id}',
delete: '/projects/{projectId}/webhooks/{id}',
})
}
modelFactory(data) {
return new WebhookModel(data)
}
async getAvailableEvents(): Promise<string[]> {
const cancel = this.setLoading()
try {
const response = await this.http.get('/webhooks/events')
return response.data
} finally {
cancel()
}
}
}