Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18be84b45 | ||
|
|
199dcd382e | ||
|
|
c6f82783ba | ||
|
|
9b3f0029a4 | ||
|
|
6e46be36c8 | ||
|
|
87f5af4caa | ||
|
|
dbe35252bc | ||
|
|
de8aa48b83 | ||
|
|
43977a1927 | ||
|
|
a9cabad529 | ||
|
|
fdc326094a | ||
|
|
9a9398b386 | ||
|
|
dc0e5eb465 | ||
|
|
79218be5c4 | ||
|
|
8d9dd99012 | ||
|
|
9f376c43b5 | ||
|
|
824164852c | ||
|
|
8ea93ec73d | ||
|
|
28f58b3694 | ||
|
|
52cd70dfa3 | ||
|
|
19683fae74 | ||
|
|
432462f967 | ||
|
|
9e40431d3a | ||
|
|
4576e75a67 | ||
|
|
11826e2a21 | ||
|
|
b023487c3e | ||
|
|
f4e6aa1bd2 | ||
|
|
939471cb3b | ||
|
|
88587b1ccb | ||
|
|
a08125e283 | ||
|
|
40d944b7d7 | ||
|
|
b7a3bb892d | ||
|
|
08c415ea0e | ||
|
|
6d662cf339 | ||
|
|
9d32e27c98 | ||
|
|
52625c8138 | ||
|
|
1b9f496832 | ||
|
|
3527c07e94 | ||
|
|
b345826566 | ||
|
|
6d3d76982a | ||
|
|
59427c4de3 | ||
|
|
7787ec1265 | ||
|
|
8daac4cdd1 | ||
|
|
adbee23e12 | ||
|
|
38232a73dc | ||
|
|
0d21a711e7 | ||
|
|
f5d538ba48 | ||
|
|
f713678328 | ||
|
|
d34df7c314 |
@@ -93,8 +93,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# Bundle audit
|
||||
- run:
|
||||
|
||||
@@ -103,4 +103,5 @@ CLAUDE.local.md
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
sw.js
|
||||
Procfile.worktree
|
||||
|
||||
@@ -268,6 +268,7 @@ group :development, :test do
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'skooma'
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+13
-2
@@ -191,7 +191,7 @@ GEM
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
@@ -473,6 +473,12 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
json_skooma (0.2.5)
|
||||
bigdecimal
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri-idna (~> 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
@@ -736,7 +742,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.22.2)
|
||||
redis-client (0.26.4)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
@@ -910,6 +916,9 @@ GEM
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
skooma (0.3.7)
|
||||
json_skooma (~> 0.2.5)
|
||||
zeitwerk (~> 2.6)
|
||||
slack-ruby-client (2.7.0)
|
||||
faraday (>= 2.0.1)
|
||||
faraday-mashify
|
||||
@@ -970,6 +979,7 @@ GEM
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.1.1)
|
||||
uri-idna (0.3.1)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -1143,6 +1153,7 @@ DEPENDENCIES
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
skooma
|
||||
slack-ruby-client (~> 2.7.0)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.11.1
|
||||
4.11.2
|
||||
|
||||
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
@contact.save! if @contact.changed?
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
|
||||
@@ -201,7 +201,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contact
|
||||
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
||||
contact_scope = Current.account.contacts
|
||||
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
|
||||
@contact = contact_scope.find(params[:id])
|
||||
end
|
||||
|
||||
def process_avatar_from_url
|
||||
|
||||
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
|
||||
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
|
||||
@@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def account_signup_allowed?
|
||||
# set it to true by default, this is the behaviour across the app
|
||||
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
|
||||
GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def resource_class(_mapping = nil)
|
||||
|
||||
@@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
current_sign_in_ip: Field::String,
|
||||
last_sign_in_ip: Field::String,
|
||||
confirmation_token: Field::String,
|
||||
confirmed_at: Field::DateTime,
|
||||
confirmed_at: ConfirmedAtField,
|
||||
confirmation_sent_at: Field::DateTime,
|
||||
unconfirmed_email: Field::String,
|
||||
name: Field::String.with_options(searchable: true),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class ConfirmedAtField < Administrate::Field::DateTime
|
||||
end
|
||||
@@ -47,11 +47,15 @@ module Filters::FilterHelper
|
||||
|
||||
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
|
||||
if data_type == 'text_case_insensitive'
|
||||
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
ActiveRecord::Base.sanitize_sql_array(
|
||||
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
|
||||
query_hash[:attribute_key]]
|
||||
)
|
||||
else
|
||||
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]} "
|
||||
ActiveRecord::Base.sanitize_sql_array(
|
||||
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
|
||||
query_hash[:attribute_key]]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,7 +74,7 @@ module Filters::FilterHelper
|
||||
|
||||
def date_filter(current_filter, query_hash, filter_operator_value)
|
||||
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
|
||||
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
@@ -28,7 +29,11 @@ const handleButtonClick = () => {
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => emit('close')"
|
||||
v-on-click-outside="[
|
||||
() => emit('close'),
|
||||
// This will prevent closing the modal when the editor Create link popup is open
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -277,7 +277,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
{ ignore: ['div.ProseMirror-prompt'] },
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
|
||||
@@ -18,10 +18,18 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -62,6 +70,7 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -96,6 +96,17 @@ const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Only close if the close event originated from this dialog,
|
||||
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
|
||||
const handleDialogClose = e => e.target === dialogRef.value && close();
|
||||
|
||||
// Only close on click-outside if this dialog is the topmost one.
|
||||
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
|
||||
const handleClickOutside = () => {
|
||||
const dialogs = document.querySelectorAll('dialog[open]');
|
||||
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
@@ -113,9 +124,9 @@ defineExpose({ open, close });
|
||||
positionClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
]"
|
||||
@close="close"
|
||||
@close.prevent="handleDialogClose"
|
||||
>
|
||||
<OnClickOutside @trigger="close">
|
||||
<OnClickOutside @trigger="handleClickOutside">
|
||||
<form
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
|
||||
@@ -978,11 +978,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-backdrop::backdrop {
|
||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
|
||||
|
||||
h5 {
|
||||
@apply text-n-slate-12 mb-1.5;
|
||||
@apply text-n-slate-12 mb-3;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
|
||||
@@ -326,26 +326,4 @@ export default {
|
||||
max-height: 7.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
|
||||
h5 {
|
||||
@apply text-n-slate-12 mb-1.5;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
button {
|
||||
@apply h-8 px-3;
|
||||
|
||||
&[type='submit'] {
|
||||
@apply bg-n-brand text-white hover:bg-n-brand/90;
|
||||
}
|
||||
|
||||
&[type='button'] {
|
||||
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,7 +78,7 @@ const onSend = () => {
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
|
||||
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
|
||||
@@ -162,7 +162,8 @@ export default {
|
||||
this.inReplyTo?.id &&
|
||||
!this.isPrivate &&
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
||||
!this.is360DialogWhatsAppChannel
|
||||
!this.is360DialogWhatsAppChannel &&
|
||||
!this.copilot.isActive.value
|
||||
);
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
@@ -1464,7 +1465,7 @@ export default {
|
||||
}
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
@apply relative py-0 px-3 -mt-px;
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
|
||||
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
|
||||
+2
@@ -100,6 +100,8 @@ export default {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.updateConversations('snoozed', snoozeType);
|
||||
} else {
|
||||
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getAgents': agents,
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
|
||||
@@ -119,6 +119,10 @@ export const COPILOT_EVENTS = Object.freeze({
|
||||
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
|
||||
});
|
||||
|
||||
export const SNOOZE_EVENTS = Object.freeze({
|
||||
NLP_SNOOZE_APPLIED: 'Applied snooze via text-to-date input',
|
||||
});
|
||||
|
||||
export const GENERAL_EVENTS = Object.freeze({
|
||||
COMMAND_BAR: 'Used commandbar',
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-console */
|
||||
import NotificationSubscriptions from '../api/notificationSubscription';
|
||||
import auth from '../api/auth';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -16,7 +15,26 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => {
|
||||
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then(registration => callback(registration))
|
||||
.then(registration => {
|
||||
// Check for updates on load
|
||||
registration.update();
|
||||
|
||||
// Listen for updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (
|
||||
newWorker.state === 'installed' &&
|
||||
navigator.serviceWorker.controller
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('New service worker available');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
callback(registration);
|
||||
})
|
||||
.catch(registrationError => {
|
||||
// eslint-disable-next-line
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* snoozeDateParser — Natural language date/time parser for snooze.
|
||||
*
|
||||
* Barrel re-export from submodules:
|
||||
* - parser.js: core parsing engine (parseDateFromText)
|
||||
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
|
||||
* - suggestions.js: compositional suggestion engine
|
||||
* - tokenMaps.js: shared token maps and utility functions
|
||||
*/
|
||||
|
||||
export { parseDateFromText } from './parser';
|
||||
export { generateDateSuggestions } from './localization';
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Handles non-English input and generates the final suggestion list.
|
||||
* Translates localized words to English before parsing, then converts
|
||||
* suggestion labels back to the user's language for display.
|
||||
*/
|
||||
|
||||
import {
|
||||
WEEKDAY_MAP,
|
||||
MONTH_MAP,
|
||||
UNIT_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
TIME_OF_DAY_MAP,
|
||||
sanitize,
|
||||
stripNoise,
|
||||
normalizeDigits,
|
||||
} from './tokenMaps';
|
||||
|
||||
import { parseDateFromText } from './parser';
|
||||
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
|
||||
|
||||
// ─── English Reference Data ─────────────────────────────────────────────────
|
||||
|
||||
const EN_WEEKDAYS_LIST = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
];
|
||||
|
||||
const EN_MONTHS_LIST = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
];
|
||||
|
||||
const EN_DEFAULTS = {
|
||||
UNITS: {
|
||||
MINUTE: 'minute',
|
||||
MINUTES: 'minutes',
|
||||
HOUR: 'hour',
|
||||
HOURS: 'hours',
|
||||
DAY: 'day',
|
||||
DAYS: 'days',
|
||||
WEEK: 'week',
|
||||
WEEKS: 'weeks',
|
||||
MONTH: 'month',
|
||||
MONTHS: 'months',
|
||||
YEAR: 'year',
|
||||
YEARS: 'years',
|
||||
},
|
||||
RELATIVE: {
|
||||
TOMORROW: 'tomorrow',
|
||||
DAY_AFTER_TOMORROW: 'day after tomorrow',
|
||||
NEXT_WEEK: 'next week',
|
||||
NEXT_MONTH: 'next month',
|
||||
THIS_WEEKEND: 'this weekend',
|
||||
NEXT_WEEKEND: 'next weekend',
|
||||
},
|
||||
TIME_OF_DAY: {
|
||||
MORNING: 'morning',
|
||||
AFTERNOON: 'afternoon',
|
||||
EVENING: 'evening',
|
||||
NIGHT: 'night',
|
||||
NOON: 'noon',
|
||||
MIDNIGHT: 'midnight',
|
||||
},
|
||||
WORD_NUMBERS: {
|
||||
ONE: 'one',
|
||||
TWO: 'two',
|
||||
THREE: 'three',
|
||||
FOUR: 'four',
|
||||
FIVE: 'five',
|
||||
SIX: 'six',
|
||||
SEVEN: 'seven',
|
||||
EIGHT: 'eight',
|
||||
NINE: 'nine',
|
||||
TEN: 'ten',
|
||||
TWELVE: 'twelve',
|
||||
FIFTEEN: 'fifteen',
|
||||
TWENTY: 'twenty',
|
||||
THIRTY: 'thirty',
|
||||
},
|
||||
ORDINALS: {
|
||||
FIRST: 'first',
|
||||
SECOND: 'second',
|
||||
THIRD: 'third',
|
||||
FOURTH: 'fourth',
|
||||
FIFTH: 'fifth',
|
||||
},
|
||||
MERIDIEM: { AM: 'am', PM: 'pm' },
|
||||
HALF: 'half',
|
||||
NEXT: 'next',
|
||||
THIS: 'this',
|
||||
AT: 'at',
|
||||
IN: 'in',
|
||||
OF: 'of',
|
||||
AFTER: 'after',
|
||||
WEEK: 'week',
|
||||
DAY: 'day',
|
||||
FROM_NOW: 'from now',
|
||||
NEXT_YEAR: 'next year',
|
||||
};
|
||||
|
||||
const STRUCTURAL_WORDS = [
|
||||
'at',
|
||||
'in',
|
||||
'next',
|
||||
'this',
|
||||
'from',
|
||||
'now',
|
||||
'after',
|
||||
'half',
|
||||
'same',
|
||||
'time',
|
||||
'weekend',
|
||||
'end',
|
||||
'of',
|
||||
'the',
|
||||
'eod',
|
||||
'am',
|
||||
'pm',
|
||||
'week',
|
||||
'day',
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
'fourth',
|
||||
'fifth',
|
||||
];
|
||||
|
||||
const ENGLISH_VOCAB = new Set([
|
||||
...Object.keys(WEEKDAY_MAP),
|
||||
...Object.keys(MONTH_MAP),
|
||||
...Object.keys(UNIT_MAP),
|
||||
...Object.keys(WORD_NUMBER_MAP),
|
||||
...Object.keys(RELATIVE_DAY_MAP),
|
||||
...Object.keys(TIME_OF_DAY_MAP),
|
||||
...EN_WEEKDAYS_LIST,
|
||||
...EN_MONTHS_LIST,
|
||||
...STRUCTURAL_WORDS,
|
||||
]);
|
||||
|
||||
// ─── Regex for token replacement ────────────────────────────────────────────
|
||||
|
||||
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
||||
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
|
||||
const NUM_TOD_RE =
|
||||
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
|
||||
const TOD_TO_MERIDIEM = {
|
||||
morning: 'am',
|
||||
noon: 'pm',
|
||||
afternoon: 'pm',
|
||||
evening: 'pm',
|
||||
night: 'pm',
|
||||
};
|
||||
|
||||
// ─── Translation Cache ──────────────────────────────────────────────────────
|
||||
|
||||
const safeString = v => (v == null ? '' : String(v));
|
||||
const MAX_PAIRS_CACHE = 20;
|
||||
const pairsCache = new Map();
|
||||
const CACHE_SECTIONS = [
|
||||
'UNITS',
|
||||
'RELATIVE',
|
||||
'TIME_OF_DAY',
|
||||
'WORD_NUMBERS',
|
||||
'ORDINALS',
|
||||
'MERIDIEM',
|
||||
];
|
||||
const SINGLE_KEYS = [
|
||||
'HALF',
|
||||
'NEXT',
|
||||
'THIS',
|
||||
'AT',
|
||||
'IN',
|
||||
'OF',
|
||||
'AFTER',
|
||||
'WEEK',
|
||||
'DAY',
|
||||
'FROM_NOW',
|
||||
'NEXT_YEAR',
|
||||
];
|
||||
|
||||
/** Create a string key from translations so we can cache results. */
|
||||
const translationSignature = translations => {
|
||||
if (!translations || typeof translations !== 'object') return 'none';
|
||||
return [
|
||||
...CACHE_SECTIONS.flatMap(section => {
|
||||
const values = translations[section] || {};
|
||||
return Object.keys(values)
|
||||
.sort()
|
||||
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
|
||||
}),
|
||||
...SINGLE_KEYS.map(
|
||||
k => `${k}:${safeString(translations[k]).toLowerCase()}`
|
||||
),
|
||||
].join('|');
|
||||
};
|
||||
|
||||
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
|
||||
const buildReplacementPairsUncached = (translations, locale) => {
|
||||
const pairs = [];
|
||||
const seen = new Set();
|
||||
const t = translations || {};
|
||||
|
||||
const addPair = (local, en) => {
|
||||
const l = sanitize(safeString(local));
|
||||
const e = safeString(en).toLowerCase();
|
||||
const key = `${l}\0${e}`;
|
||||
if (l && e && l !== e && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
pairs.push([l, e]);
|
||||
}
|
||||
};
|
||||
|
||||
CACHE_SECTIONS.forEach(section => {
|
||||
const localSection = t[section] || {};
|
||||
const enSection = EN_DEFAULTS[section] || {};
|
||||
Object.keys(enSection).forEach(key => {
|
||||
addPair(localSection[key], enSection[key]);
|
||||
});
|
||||
});
|
||||
|
||||
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
|
||||
|
||||
try {
|
||||
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
|
||||
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
|
||||
EN_WEEKDAYS_LIST.forEach((en, i) => {
|
||||
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
|
||||
});
|
||||
} catch {
|
||||
/* locale not supported */
|
||||
}
|
||||
|
||||
try {
|
||||
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
|
||||
EN_MONTHS_LIST.forEach((en, i) => {
|
||||
addPair(moFmt.format(new Date(2024, i, 1)), en);
|
||||
});
|
||||
} catch {
|
||||
/* locale not supported */
|
||||
}
|
||||
|
||||
pairs.sort((a, b) => b[0].length - a[0].length);
|
||||
return pairs;
|
||||
};
|
||||
|
||||
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
|
||||
const buildReplacementPairs = (translations, locale) => {
|
||||
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
|
||||
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
|
||||
const pairs = buildReplacementPairsUncached(translations, locale);
|
||||
if (pairsCache.size >= MAX_PAIRS_CACHE)
|
||||
pairsCache.delete(pairsCache.keys().next().value);
|
||||
pairsCache.set(cacheKey, pairs);
|
||||
return pairs;
|
||||
};
|
||||
|
||||
// ─── Token Replacement ──────────────────────────────────────────────────────
|
||||
|
||||
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/** Swap localized words for their English versions in the text. */
|
||||
const substituteLocalTokens = (text, pairs) => {
|
||||
let r = text;
|
||||
pairs.forEach(([local, en]) => {
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
|
||||
const filterToEnglishVocab = text =>
|
||||
normalizeDigits(text)
|
||||
.replace(/(\d+)h\b/g, '$1:00')
|
||||
.split(/\s+/)
|
||||
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
|
||||
const repositionNextYear = text => {
|
||||
if (!MONTH_NAME_RE.test(text)) return text;
|
||||
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
|
||||
/next/i.test(m) ? m : 'next year'
|
||||
);
|
||||
if (!/\bnext\s+year\b/i.test(r)) return r;
|
||||
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
|
||||
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
|
||||
const timePart = withoutNY.match(timeRe);
|
||||
if (timePart) {
|
||||
const beforeTime = withoutNY.slice(0, timePart.index).trim();
|
||||
r = `${beforeTime} next year ${timePart[0].trim()}`;
|
||||
} else {
|
||||
r = `${withoutNY} next year`;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
|
||||
const replaceTokens = (text, pairs) => {
|
||||
const substituted = substituteLocalTokens(text, pairs);
|
||||
const filtered = filterToEnglishVocab(substituted);
|
||||
const fixed = filtered.replace(
|
||||
NUM_TOD_RE,
|
||||
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
|
||||
);
|
||||
return stripNoise(repositionNextYear(fixed));
|
||||
};
|
||||
|
||||
/** Convert English words back to the user's language for display. */
|
||||
const reverseTokens = (text, pairs) =>
|
||||
pairs.reduce(
|
||||
(r, [local, en]) =>
|
||||
r.replace(
|
||||
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
|
||||
local
|
||||
),
|
||||
text
|
||||
);
|
||||
|
||||
// ─── Main Suggestion Generator ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate snooze suggestions from what the user has typed so far.
|
||||
* Works with any language if translations are provided. Returns up to 5
|
||||
* unique results, each with a label, date, and unix timestamp.
|
||||
*
|
||||
* @param {string} text - what the user typed
|
||||
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
||||
* @param {{ translations?: object, locale?: string }} [options] - i18n config
|
||||
* @returns {Array<{ label: string, date: Date, unix: number }>}
|
||||
*/
|
||||
export const generateDateSuggestions = (
|
||||
text,
|
||||
referenceDate = new Date(),
|
||||
{ translations, locale } = {}
|
||||
) => {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
const normalized = sanitize(text);
|
||||
if (!normalized) return [];
|
||||
|
||||
const stripped = stripNoise(normalized);
|
||||
const pairs =
|
||||
locale && locale !== 'en'
|
||||
? buildReplacementPairs(translations, locale)
|
||||
: [];
|
||||
|
||||
// Try English parse first, then translated parse if we have locale pairs.
|
||||
// This avoids the problem where a single overlapping word (e.g. "in" in German)
|
||||
// would skip token translation entirely.
|
||||
const directParse = parseDateFromText(stripped, referenceDate);
|
||||
|
||||
const translated = pairs.length ? replaceTokens(normalized, pairs) : null;
|
||||
const translatedParse =
|
||||
translated && translated !== stripped
|
||||
? parseDateFromText(translated, referenceDate)
|
||||
: null;
|
||||
|
||||
// Prefer direct English parse; fall back to translated parse
|
||||
const useTranslated = !directParse && !!translatedParse;
|
||||
const englishInput = useTranslated ? translated : stripped;
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
const exact = directParse || translatedParse;
|
||||
if (exact) {
|
||||
seen.add(exact.unix);
|
||||
const exactLabel =
|
||||
useTranslated && pairs.length
|
||||
? reverseTokens(englishInput, pairs)
|
||||
: englishInput;
|
||||
results.push({ label: exactLabel, query: englishInput, ...exact });
|
||||
}
|
||||
|
||||
buildSuggestionCandidates(englishInput).some(candidate => {
|
||||
if (results.length >= MAX_SUGGESTIONS) return true;
|
||||
const result = parseDateFromText(candidate, referenceDate);
|
||||
if (result && !seen.has(result.unix)) {
|
||||
seen.add(result.unix);
|
||||
const label =
|
||||
useTranslated && pairs.length
|
||||
? reverseTokens(candidate, pairs)
|
||||
: candidate;
|
||||
results.push({ label, query: candidate, ...result });
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
@@ -0,0 +1,806 @@
|
||||
/**
|
||||
* Parses natural language text into a future date.
|
||||
*
|
||||
* Flow: clean the input → try each matcher in order → return the first future date.
|
||||
* The MATCHERS order matters — see the comment above the array.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
startOfDay,
|
||||
getDay,
|
||||
isSaturday,
|
||||
isSunday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
getUnixTime,
|
||||
isValid,
|
||||
startOfWeek,
|
||||
addWeeks,
|
||||
isAfter,
|
||||
isBefore,
|
||||
endOfMonth,
|
||||
} from 'date-fns';
|
||||
|
||||
import {
|
||||
WEEKDAY_MAP,
|
||||
MONTH_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
UNIT_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
NEXT_WEEKDAY_FN,
|
||||
TIME_OF_DAY_MAP,
|
||||
TOD_HOUR_RANGE,
|
||||
HALF_UNIT_DURATIONS,
|
||||
sanitize,
|
||||
stripNoise,
|
||||
parseNumber,
|
||||
parseTimeString,
|
||||
applyTimeToDate,
|
||||
applyTimeOrDefault,
|
||||
strictDate,
|
||||
futureOrNextYear,
|
||||
ensureFutureOrNextDay,
|
||||
inferHoursFromTOD,
|
||||
addFractionalSafe,
|
||||
} from './tokenMaps';
|
||||
|
||||
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
|
||||
|
||||
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
|
||||
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
||||
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
|
||||
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
|
||||
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
|
||||
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
|
||||
|
||||
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
|
||||
const UNIT_RE = `(${UNIT_NAMES})`;
|
||||
const TIME_SUFFIX_RE =
|
||||
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
|
||||
|
||||
const ORDINAL_MAP = {
|
||||
first: 1,
|
||||
second: 2,
|
||||
third: 3,
|
||||
fourth: 4,
|
||||
fifth: 5,
|
||||
sixth: 6,
|
||||
seventh: 7,
|
||||
eighth: 8,
|
||||
ninth: 9,
|
||||
tenth: 10,
|
||||
};
|
||||
const parseOrdinal = str => {
|
||||
if (ORDINAL_MAP[str]) return ORDINAL_MAP[str];
|
||||
return parseInt(str.replace(/(?:st|nd|rd|th)$/, ''), 10) || null;
|
||||
};
|
||||
const ORDINAL_WORDS = Object.keys(ORDINAL_MAP).join('|');
|
||||
const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
|
||||
|
||||
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
|
||||
|
||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
||||
const DURATION_FROM_NOW_RE = new RegExp(
|
||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||
);
|
||||
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
|
||||
);
|
||||
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
|
||||
`^(?:(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)|(?:same\\s+time|this\\s+time)\\s+(${RELATIVE_DAYS}))$`
|
||||
);
|
||||
const NEXT_UNIT_RE = new RegExp(
|
||||
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
|
||||
const NEXT_WEEKDAY_TOD_RE = new RegExp(
|
||||
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const NEXT_WEEKDAY_RE = new RegExp(
|
||||
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
|
||||
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
|
||||
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const SAME_TIME_WEEKDAY_RE = new RegExp(
|
||||
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const WEEKDAY_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const TIME_ONLY_MERIDIEM_RE =
|
||||
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
|
||||
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
|
||||
const TOD_WITH_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
|
||||
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const TOD_PLAIN_RE = new RegExp(
|
||||
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
|
||||
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
|
||||
);
|
||||
const ABSOLUTE_DATE_RE = new RegExp(
|
||||
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
|
||||
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
|
||||
// "april first week", "first week of april", "march 2nd day", "5th day of jan"
|
||||
const MONTH_ORDINAL_RE = new RegExp(
|
||||
`^(?:(${MONTH_NAMES})\\s+${ORDINAL_RE}\\s+(week|day)|${ORDINAL_RE}\\s+(week|day)\\s+of\\s+(${MONTH_NAMES}))${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const DAY_AFTER_TOMORROW_RE = new RegExp(
|
||||
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
|
||||
const COMPOUND_DURATION_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
|
||||
);
|
||||
const DURATION_AT_TIME_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
|
||||
const END_OF_NEXT_RE = /^end\s+of\s+(?:the\s+)?next\s+(week|month)$/;
|
||||
const START_OF_NEXT_RE =
|
||||
/^(?:beginning|start)\s+of\s+(?:the\s+)?next\s+(week|month)$/;
|
||||
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
|
||||
const EARLY_LATE_TOD_RE = new RegExp(
|
||||
`^(early|late)\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const ONE_AND_HALF_RE = new RegExp(
|
||||
`^(?:in\\s+)?(?:one\\s+and\\s+(?:a\\s+)?half|an?\\s+hour\\s+and\\s+(?:a\\s+)?half)(?:\\s+${UNIT_RE})?$`
|
||||
);
|
||||
const NEXT_BUSINESS_DAY_RE = /^next\s+(?:business|working)\s+day$/;
|
||||
|
||||
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
|
||||
const ISO_DATE_RE = new RegExp(
|
||||
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const SLASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DOT_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
|
||||
// ─── Pattern Matchers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Read amount and unit from a regex match, then add to now. */
|
||||
const parseDuration = (match, now) => {
|
||||
if (!match) return null;
|
||||
const amount = parseNumber(match[1]);
|
||||
const unit = UNIT_MAP[match[2]];
|
||||
if (amount == null || !unit) return null;
|
||||
return addFractionalSafe(now, unit, amount);
|
||||
};
|
||||
|
||||
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
|
||||
const matchDuration = (text, now) => {
|
||||
const half = text.match(HALF_UNIT_RE);
|
||||
if (half) {
|
||||
return HALF_UNIT_DURATIONS[half[1]]
|
||||
? add(now, HALF_UNIT_DURATIONS[half[1]])
|
||||
: null;
|
||||
}
|
||||
|
||||
// "one and a half hours", "an hour and a half"
|
||||
const oneHalf = text.match(ONE_AND_HALF_RE);
|
||||
if (oneHalf) {
|
||||
const unit = UNIT_MAP[oneHalf[1]] || 'hours';
|
||||
return addFractionalSafe(now, unit, 1.5);
|
||||
}
|
||||
|
||||
const compound = text.match(COMPOUND_DURATION_RE);
|
||||
if (compound) {
|
||||
const a1 = parseNumber(compound[1]);
|
||||
const u1 = UNIT_MAP[compound[2]];
|
||||
const a2 = parseNumber(compound[3]);
|
||||
const u2 = UNIT_MAP[compound[4]];
|
||||
if (a1 == null || !u1 || a2 == null || !u2) {
|
||||
return null;
|
||||
}
|
||||
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
|
||||
}
|
||||
|
||||
const atTime = text.match(DURATION_AT_TIME_RE);
|
||||
if (atTime) {
|
||||
const amount = parseNumber(atTime[1]);
|
||||
const unit = UNIT_MAP[atTime[2]];
|
||||
const time = parseTimeString(atTime[3]);
|
||||
if (amount == null || !unit || !time) {
|
||||
return null;
|
||||
}
|
||||
return applyTimeToDate(
|
||||
add(now, { [unit]: amount }),
|
||||
time.hours,
|
||||
time.minutes
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||
);
|
||||
};
|
||||
|
||||
/** Set time on a day offset. If the result is already past, move to the next day. */
|
||||
const applyTimeWithRollover = (offset, hours, minutes, now) => {
|
||||
const base = add(startOfDay(now), { days: offset });
|
||||
const date = applyTimeToDate(base, hours, minutes);
|
||||
if (isAfter(date, now)) return date;
|
||||
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
|
||||
};
|
||||
|
||||
/** Handle "today", "tonight", "tomorrow" with optional time. */
|
||||
const matchRelativeDay = (text, now) => {
|
||||
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
|
||||
if (dayOnlyMatch) {
|
||||
const key = dayOnlyMatch[1];
|
||||
const offset = RELATIVE_DAY_MAP[key];
|
||||
if (key === 'tonight' || key === 'tonite') {
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
if (offset === 1) {
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
|
||||
}
|
||||
return add(now, { hours: 1 });
|
||||
}
|
||||
|
||||
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
|
||||
if (dayTodTimeMatch) {
|
||||
const timeParts = dayTodTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(
|
||||
dayTodTimeMatch[2],
|
||||
timeParts[0],
|
||||
timeParts[1]
|
||||
);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
|
||||
if (dayTodMatch) {
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodMatch[1]],
|
||||
hours,
|
||||
minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
||||
if (dayAtTimeMatch) {
|
||||
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
||||
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
|
||||
const time = bare
|
||||
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
|
||||
: parseTimeString(timeRaw);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayKey],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
|
||||
if (sameTimeMatch) {
|
||||
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]];
|
||||
if (offset <= 0) return null;
|
||||
return applyTimeToDate(
|
||||
add(startOfDay(now), { days: offset }),
|
||||
now.getHours(),
|
||||
now.getMinutes()
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the given weekday in next week (not this week). */
|
||||
const nextWeekdayInNextWeek = (dayIndex, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const date = fn(now);
|
||||
const sameWeek =
|
||||
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
|
||||
startOfWeek(date, { weekStartsOn: 1 }).getTime();
|
||||
return sameWeek ? fn(date) : date;
|
||||
};
|
||||
|
||||
/** Handle "next friday", "next week", "next month", "next january", etc. */
|
||||
const matchNextPattern = (text, now) => {
|
||||
const nextUnitMatch = text.match(NEXT_UNIT_RE);
|
||||
if (nextUnitMatch) {
|
||||
const unit = nextUnitMatch[1];
|
||||
if (unit === 'hour') return add(now, { hours: 1 });
|
||||
if (unit === 'minute') return add(now, { minutes: 1 });
|
||||
if (unit === 'week') {
|
||||
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
|
||||
const nextMonthMatch = text.match(NEXT_MONTH_RE);
|
||||
if (nextMonthMatch) {
|
||||
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
|
||||
let year = now.getFullYear();
|
||||
if (monthIdx <= now.getMonth()) year += 1;
|
||||
const base = new Date(year, monthIdx, 1);
|
||||
return applyTimeOrDefault(base, nextMonthMatch[2]);
|
||||
}
|
||||
|
||||
// "next monday morning", "next friday midnight" — weekday + time-of-day
|
||||
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
|
||||
if (nextTodMatch) {
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
|
||||
if (!date) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
|
||||
return applyTimeToDate(date, hours, minutes);
|
||||
}
|
||||
|
||||
// "monday of next week", "next week monday", "next friday" — all with optional time
|
||||
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
|
||||
if (weekdayMatch) {
|
||||
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, weekdayMatch[4]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the next occurrence of a weekday, with optional time. */
|
||||
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
let adjusted = timeStr;
|
||||
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
|
||||
const h = parseInt(timeStr, 10);
|
||||
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
|
||||
}
|
||||
|
||||
if (getDay(now) === dayIndex) {
|
||||
const todayDate = applyTimeOrDefault(now, adjusted);
|
||||
if (todayDate && isAfter(todayDate, now)) return todayDate;
|
||||
}
|
||||
|
||||
return applyTimeOrDefault(fn(now), adjusted);
|
||||
};
|
||||
|
||||
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
|
||||
const matchWeekday = (text, now) => {
|
||||
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
|
||||
if (sameTimeWeekday) {
|
||||
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const target = fn(now);
|
||||
return applyTimeToDate(target, now.getHours(), now.getMinutes());
|
||||
}
|
||||
|
||||
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
|
||||
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
|
||||
if (todTimeMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const timeParts = todTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
|
||||
if (!time) return null;
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, time.hours, time.minutes);
|
||||
return isAfter(date, now)
|
||||
? date
|
||||
: applyTimeToDate(fn(now), time.hours, time.minutes);
|
||||
}
|
||||
|
||||
// "monday morning", "friday midnight", "wednesday evening", etc.
|
||||
const todMatch = text.match(WEEKDAY_TOD_RE);
|
||||
if (todMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, hours, minutes);
|
||||
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
|
||||
}
|
||||
|
||||
const match = text.match(WEEKDAY_TIME_RE);
|
||||
if (!match) return null;
|
||||
|
||||
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
|
||||
};
|
||||
|
||||
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
|
||||
const matchTimeOnly = (text, now) => {
|
||||
const match =
|
||||
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const time = parseTimeString(match[1]);
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
|
||||
const matchTimeOfDay = (text, now) => {
|
||||
const todWithTime = text.match(TOD_WITH_TIME_RE);
|
||||
if (todWithTime) {
|
||||
const rawTime = todWithTime[2].trim();
|
||||
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
|
||||
let time;
|
||||
if (hasMeridiem) {
|
||||
time = parseTimeString(rawTime);
|
||||
const range = TOD_HOUR_RANGE[todWithTime[1]];
|
||||
if (!time) return null;
|
||||
if (range) {
|
||||
const h = time.hours === 0 ? 24 : time.hours;
|
||||
if (h < range[0] || h >= range[1]) return null;
|
||||
}
|
||||
} else {
|
||||
const parts = rawTime.split(':');
|
||||
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
|
||||
}
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
// "early morning" → 7am, "late evening" → 21:00, "late night" → 23:00
|
||||
const earlyLate = text.match(EARLY_LATE_TOD_RE);
|
||||
if (earlyLate) {
|
||||
const tod = TIME_OF_DAY_MAP[earlyLate[2]];
|
||||
if (!tod) return null;
|
||||
const shift = earlyLate[1] === 'early' ? -1 : 2;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, tod.hours + shift, 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const match = text.match(TOD_PLAIN_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const key = text
|
||||
.replace(/^(?:later|in)\s+/, '')
|
||||
.replace(/^(?:this|the)\s+/, '')
|
||||
.trim();
|
||||
const tod = TIME_OF_DAY_MAP[key];
|
||||
if (!tod) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, tod.hours, tod.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Turn month + day + optional year into a future date. */
|
||||
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
|
||||
let year = now.getFullYear();
|
||||
if (yearStr && /next\s+year/i.test(yearStr)) {
|
||||
year += 1;
|
||||
} else if (yearStr) {
|
||||
year = parseInt(yearStr, 10);
|
||||
}
|
||||
if (yearStr) {
|
||||
const base = strictDate(year, month, day);
|
||||
if (!base) return null;
|
||||
const date = applyTimeOrDefault(base, timeStr);
|
||||
return date && isAfter(date, now) ? date : null;
|
||||
}
|
||||
return futureOrNextYear(year, month, day, timeStr, now);
|
||||
};
|
||||
|
||||
/** Handle "jan 15", "15 march", "december 2025". */
|
||||
const matchNamedDate = (text, now) => {
|
||||
const abs = text.match(ABSOLUTE_DATE_RE);
|
||||
if (abs) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[abs[1]],
|
||||
parseInt(abs[2], 10),
|
||||
abs[3],
|
||||
abs[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
|
||||
if (rev) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[rev[2]],
|
||||
parseInt(rev[1], 10),
|
||||
rev[3],
|
||||
rev[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const my = text.match(MONTH_YEAR_RE);
|
||||
if (my) {
|
||||
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
|
||||
if (!isValid(date)) return null;
|
||||
const result = applyTimeToDate(date, 9, 0);
|
||||
return isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
// "april first week", "first week of april", "march 2nd day", etc.
|
||||
const mo = text.match(MONTH_ORDINAL_RE);
|
||||
if (mo) {
|
||||
// Groups: (1)month-A (2)ordinal-A (3)unit-A | (4)ordinal-B (5)unit-B (6)month-B (7)time
|
||||
const monthIdx = MONTH_MAP[mo[1] || mo[6]];
|
||||
const num = parseOrdinal(mo[2] || mo[4]);
|
||||
const unit = mo[3] || mo[5];
|
||||
const timeStr = mo[7];
|
||||
|
||||
if (!num || num < 1) return null;
|
||||
|
||||
if (unit === 'day') {
|
||||
if (num > 31) return null;
|
||||
return resolveAbsoluteDate(monthIdx, num, null, timeStr, now);
|
||||
}
|
||||
|
||||
// unit === 'week'
|
||||
if (num > 5) return null;
|
||||
const weekStartDay = (num - 1) * 7 + 1;
|
||||
let year = now.getFullYear();
|
||||
if (
|
||||
monthIdx < now.getMonth() ||
|
||||
(monthIdx === now.getMonth() && now.getDate() > weekStartDay)
|
||||
) {
|
||||
year += 1;
|
||||
}
|
||||
// Reject if weekStartDay overflows the month (e.g. feb fifth week = day 29 in non-leap)
|
||||
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
|
||||
if (weekStartDay > daysInMonth) return null;
|
||||
const d = new Date(year, monthIdx, weekStartDay);
|
||||
if (!isValid(d)) return null;
|
||||
const result = applyTimeOrDefault(d, timeStr);
|
||||
return result && isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Build a date from year/month/day numbers, with optional time. */
|
||||
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
|
||||
const date = strictDate(year, month, day);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, timeStr);
|
||||
};
|
||||
|
||||
// When both values are ≤ 12 (ambiguous), dayFirst controls the fallback:
|
||||
// dayFirst=false (slash M/D/Y) → month first
|
||||
// dayFirst=true (dash/dot D-M-Y, D.M.Y) → day first
|
||||
const disambiguateDayMonth = (a, b, dayFirst = false) => {
|
||||
if (a > 12) return { day: a, month: b - 1 };
|
||||
if (b > 12) return { month: a - 1, day: b };
|
||||
return dayFirst ? { day: a, month: b - 1 } : { month: a - 1, day: b };
|
||||
};
|
||||
|
||||
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
|
||||
const matchFormalDate = (text, now) => {
|
||||
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
|
||||
|
||||
const isoMatch = text.match(ISO_DATE_RE);
|
||||
if (isoMatch) {
|
||||
return ensureFuture(
|
||||
buildDateWithOptionalTime(
|
||||
parseInt(isoMatch[1], 10),
|
||||
parseInt(isoMatch[2], 10) - 1,
|
||||
parseInt(isoMatch[3], 10),
|
||||
isoMatch[4]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Slash = M/D/Y (US), Dash/Dot = D-M-Y / D.M.Y (European)
|
||||
const formats = [
|
||||
{ re: SLASH_DATE_RE, dayFirst: false },
|
||||
{ re: DASH_DATE_RE, dayFirst: true },
|
||||
{ re: DOT_DATE_RE, dayFirst: true },
|
||||
];
|
||||
let result = null;
|
||||
formats.some(({ re, dayFirst }) => {
|
||||
const m = text.match(re);
|
||||
if (!m) return false;
|
||||
const { month, day } = disambiguateDayMonth(
|
||||
parseInt(m[1], 10),
|
||||
parseInt(m[2], 10),
|
||||
dayFirst
|
||||
);
|
||||
result = ensureFuture(
|
||||
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
|
||||
);
|
||||
return true;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
|
||||
const matchSpecial = (text, now) => {
|
||||
const dat = text.match(DAY_AFTER_TOMORROW_RE);
|
||||
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
|
||||
|
||||
const eof = text.match(END_OF_RE);
|
||||
if (eof) {
|
||||
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
|
||||
if (eof[1] === 'week') {
|
||||
const fri = applyTimeToDate(now, 17, 0);
|
||||
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
|
||||
return applyTimeToDate(nextFriday(now), 17, 0);
|
||||
}
|
||||
if (eof[1] === 'month') {
|
||||
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
|
||||
if (isAfter(eom, now)) return eom;
|
||||
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "end of next week", "end of next month"
|
||||
const eofNext = text.match(END_OF_NEXT_RE);
|
||||
if (eofNext) {
|
||||
if (eofNext[1] === 'week') {
|
||||
const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
|
||||
return applyTimeToDate(add(nextWeekStart, { days: 4 }), 17, 0);
|
||||
}
|
||||
if (eofNext[1] === 'month') {
|
||||
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "beginning of next week", "start of next month"
|
||||
const sofNext = text.match(START_OF_NEXT_RE);
|
||||
if (sofNext) {
|
||||
if (sofNext[1] === 'week') {
|
||||
return applyTimeToDate(
|
||||
startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }),
|
||||
9,
|
||||
0
|
||||
);
|
||||
}
|
||||
if (sofNext[1] === 'month') {
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return applyTimeToDate(nextMonth, 9, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "next business day", "next working day"
|
||||
if (NEXT_BUSINESS_DAY_RE.test(text)) {
|
||||
let d = add(startOfDay(now), { days: 1 });
|
||||
while (isSaturday(d) || isSunday(d)) d = add(d, { days: 1 });
|
||||
return applyTimeToDate(d, 9, 0);
|
||||
}
|
||||
|
||||
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
|
||||
|
||||
const weekendMatch = text.match(
|
||||
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
|
||||
);
|
||||
if (weekendMatch) {
|
||||
const isNext = weekendMatch[1] === 'next weekend';
|
||||
const timeStr = weekendMatch[2];
|
||||
|
||||
if (isNext) {
|
||||
const sat = nextSaturday(now);
|
||||
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
|
||||
return applyTimeOrDefault(d, timeStr);
|
||||
}
|
||||
|
||||
if (isSaturday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
if (now.getHours() < 18) return add(now, { hours: 2 });
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
|
||||
}
|
||||
if (isSunday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
return add(now, { hours: 2 });
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
}
|
||||
return applyTimeOrDefault(nextSaturday(now), timeStr);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// ─── Main Parser ────────────────────────────────────────────────────────────
|
||||
|
||||
// Order matters — first match wins. Common patterns go first.
|
||||
// Do not reorder without running the spec.
|
||||
const MATCHERS = [
|
||||
matchDuration, // "in 2 hours", "half day", "3h30m"
|
||||
matchSpecial, // "end of week", "later today", "this weekend"
|
||||
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
|
||||
matchNextPattern, // "next friday", "next week", "next month"
|
||||
matchTimeOfDay, // "morning", "evening 6pm", "eod"
|
||||
matchWeekday, // "friday", "monday 3pm", "wed morning"
|
||||
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
|
||||
matchNamedDate, // "jan 15", "march 20 next year"
|
||||
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse free-form text into a future date.
|
||||
* Returns { date, unix } or null. Only returns dates after referenceDate.
|
||||
*
|
||||
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
|
||||
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
||||
* @returns {{ date: Date, unix: number } | null}
|
||||
*/
|
||||
export const parseDateFromText = (text, referenceDate = new Date()) => {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
|
||||
const normalized = stripNoise(sanitize(text));
|
||||
if (!normalized) return null;
|
||||
|
||||
const maxDate = add(referenceDate, { years: 999 });
|
||||
|
||||
const isValidFuture = d =>
|
||||
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
|
||||
|
||||
let result = null;
|
||||
MATCHERS.some(matcher => {
|
||||
const d = matcher(normalized, referenceDate);
|
||||
if (isValidFuture(d)) {
|
||||
result = { date: d, unix: getUnixTime(d) };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Builds autocomplete suggestions as the user types a snooze date.
|
||||
* Matches partial input against known phrases and ranks them by closeness.
|
||||
*/
|
||||
|
||||
import {
|
||||
UNIT_MAP,
|
||||
WEEKDAY_MAP,
|
||||
TIME_OF_DAY_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
MONTH_MAP,
|
||||
HALF_UNIT_DURATIONS,
|
||||
} from './tokenMaps';
|
||||
|
||||
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
|
||||
u => u !== 'seconds'
|
||||
);
|
||||
|
||||
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
|
||||
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
|
||||
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
|
||||
|
||||
const ALL_SUGGESTION_PHRASES = [
|
||||
...Object.keys(RELATIVE_DAY_MAP),
|
||||
...FULL_WEEKDAYS,
|
||||
...TOD_NAMES,
|
||||
'next week',
|
||||
'next month',
|
||||
'this weekend',
|
||||
'next weekend',
|
||||
'day after tomorrow',
|
||||
'later today',
|
||||
'end of day',
|
||||
'end of week',
|
||||
'end of month',
|
||||
...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${tod}`),
|
||||
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
|
||||
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
|
||||
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
|
||||
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
|
||||
...MONTH_NAMES_LONG.map(m => `${m} 1`),
|
||||
];
|
||||
|
||||
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
|
||||
const prefixMatchScore = (candidate, input) => {
|
||||
if (candidate === input) return -1;
|
||||
if (candidate.startsWith(input)) return 0;
|
||||
const inputWords = input.split(' ');
|
||||
const candidateWords = candidate.split(' ');
|
||||
const lastIdx = inputWords.reduce((prev, iw) => {
|
||||
if (prev === -2) return -2;
|
||||
const idx = candidateWords.findIndex(
|
||||
(cw, ci) => ci > prev && cw.startsWith(iw)
|
||||
);
|
||||
return idx === -1 ? -2 : idx;
|
||||
}, -1);
|
||||
if (lastIdx === -2) return -1;
|
||||
return candidateWords.length - inputWords.length;
|
||||
};
|
||||
|
||||
export const MAX_SUGGESTIONS = 5;
|
||||
|
||||
/** Turn user input into a ranked list of suggestion strings to try parsing. */
|
||||
export const buildSuggestionCandidates = text => {
|
||||
if (!text) return [];
|
||||
|
||||
if (/^\d/.test(text)) {
|
||||
const num = text.match(/^\d+(?:\.5)?/)[0];
|
||||
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim();
|
||||
const spaced = trimmed.replace(/(\d)([a-z])/i, '$1 $2');
|
||||
return spaced.length > num.length
|
||||
? candidates.filter(c => c.startsWith(spaced))
|
||||
: candidates;
|
||||
}
|
||||
|
||||
if (text.length >= 2 && 'half'.startsWith(text)) {
|
||||
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
|
||||
}
|
||||
|
||||
const wordNum = WORD_NUMBER_MAP[text];
|
||||
if (wordNum != null && wordNum >= 1) {
|
||||
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
|
||||
}
|
||||
|
||||
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
|
||||
const score = prefixMatchScore(candidate, text);
|
||||
if (score >= 0) acc.push({ candidate, score });
|
||||
return acc;
|
||||
}, []);
|
||||
scored.sort((a, b) => a.score - b.score);
|
||||
const seen = new Set();
|
||||
return scored.reduce((acc, { candidate }) => {
|
||||
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
|
||||
seen.add(candidate);
|
||||
acc.push(candidate);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Shared lookup tables and helper functions used by the parser,
|
||||
* suggestions, and localization modules.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
set,
|
||||
isValid,
|
||||
isAfter,
|
||||
nextMonday,
|
||||
nextTuesday,
|
||||
nextWednesday,
|
||||
nextThursday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
nextSunday,
|
||||
} from 'date-fns';
|
||||
|
||||
// ─── Token Maps ──────────────────────────────────────────────────────────────
|
||||
// All keys are lowercase. Short forms and full names both work.
|
||||
|
||||
/** Weekday name or short form → day index (0 = Sunday). */
|
||||
export const WEEKDAY_MAP = {
|
||||
sunday: 0,
|
||||
sun: 0,
|
||||
monday: 1,
|
||||
mon: 1,
|
||||
tuesday: 2,
|
||||
tue: 2,
|
||||
tues: 2,
|
||||
wednesday: 3,
|
||||
wed: 3,
|
||||
thursday: 4,
|
||||
thu: 4,
|
||||
thur: 4,
|
||||
thurs: 4,
|
||||
friday: 5,
|
||||
fri: 5,
|
||||
saturday: 6,
|
||||
sat: 6,
|
||||
};
|
||||
|
||||
/** Month name or short form → month index (0 = January). */
|
||||
export const MONTH_MAP = {
|
||||
january: 0,
|
||||
jan: 0,
|
||||
february: 1,
|
||||
feb: 1,
|
||||
march: 2,
|
||||
mar: 2,
|
||||
april: 3,
|
||||
apr: 3,
|
||||
may: 4,
|
||||
june: 5,
|
||||
jun: 5,
|
||||
july: 6,
|
||||
jul: 6,
|
||||
august: 7,
|
||||
aug: 7,
|
||||
september: 8,
|
||||
sep: 8,
|
||||
sept: 8,
|
||||
october: 9,
|
||||
oct: 9,
|
||||
november: 10,
|
||||
nov: 10,
|
||||
december: 11,
|
||||
dec: 11,
|
||||
};
|
||||
|
||||
/** Words like "today" or "tomorrow" → how many days from now. */
|
||||
export const RELATIVE_DAY_MAP = {
|
||||
today: 0,
|
||||
tonight: 0,
|
||||
tonite: 0,
|
||||
tomorrow: 1,
|
||||
tmr: 1,
|
||||
tmrw: 1,
|
||||
};
|
||||
|
||||
/** Unit shorthand → full unit name used by date-fns. */
|
||||
export const UNIT_MAP = {
|
||||
m: 'minutes',
|
||||
min: 'minutes',
|
||||
mins: 'minutes',
|
||||
minute: 'minutes',
|
||||
minutes: 'minutes',
|
||||
h: 'hours',
|
||||
hr: 'hours',
|
||||
hrs: 'hours',
|
||||
hour: 'hours',
|
||||
hours: 'hours',
|
||||
d: 'days',
|
||||
day: 'days',
|
||||
days: 'days',
|
||||
w: 'weeks',
|
||||
wk: 'weeks',
|
||||
wks: 'weeks',
|
||||
week: 'weeks',
|
||||
weeks: 'weeks',
|
||||
mo: 'months',
|
||||
month: 'months',
|
||||
months: 'months',
|
||||
y: 'years',
|
||||
yr: 'years',
|
||||
yrs: 'years',
|
||||
year: 'years',
|
||||
years: 'years',
|
||||
};
|
||||
|
||||
/** English number words → their numeric value. */
|
||||
export const WORD_NUMBER_MAP = {
|
||||
a: 1,
|
||||
an: 1,
|
||||
one: 1,
|
||||
couple: 2,
|
||||
few: 3,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
thirteen: 13,
|
||||
fourteen: 14,
|
||||
fifteen: 15,
|
||||
sixteen: 16,
|
||||
seventeen: 17,
|
||||
eighteen: 18,
|
||||
nineteen: 19,
|
||||
twenty: 20,
|
||||
thirty: 30,
|
||||
forty: 40,
|
||||
fifty: 50,
|
||||
sixty: 60,
|
||||
ninety: 90,
|
||||
half: 0.5,
|
||||
};
|
||||
|
||||
/** Day index → the date-fns function that finds the next occurrence. */
|
||||
export const NEXT_WEEKDAY_FN = {
|
||||
0: nextSunday,
|
||||
1: nextMonday,
|
||||
2: nextTuesday,
|
||||
3: nextWednesday,
|
||||
4: nextThursday,
|
||||
5: nextFriday,
|
||||
6: nextSaturday,
|
||||
};
|
||||
|
||||
/** Time-of-day label → default hour and minute. */
|
||||
export const TIME_OF_DAY_MAP = {
|
||||
morning: { hours: 9, minutes: 0 },
|
||||
noon: { hours: 12, minutes: 0 },
|
||||
afternoon: { hours: 14, minutes: 0 },
|
||||
evening: { hours: 18, minutes: 0 },
|
||||
night: { hours: 20, minutes: 0 },
|
||||
tonight: { hours: 20, minutes: 0 },
|
||||
midnight: { hours: 0, minutes: 0 },
|
||||
eod: { hours: 17, minutes: 0 },
|
||||
'end of day': { hours: 17, minutes: 0 },
|
||||
'end of the day': { hours: 17, minutes: 0 },
|
||||
};
|
||||
|
||||
/** Allowed hour range per label — used to pick am or pm when not specified. */
|
||||
export const TOD_HOUR_RANGE = {
|
||||
morning: [4, 12],
|
||||
noon: [11, 13],
|
||||
afternoon: [12, 18],
|
||||
evening: [16, 22],
|
||||
night: [18, 24],
|
||||
tonight: [18, 24],
|
||||
midnight: [23, 25],
|
||||
};
|
||||
|
||||
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
|
||||
export const HALF_UNIT_DURATIONS = {
|
||||
hour: { minutes: 30 },
|
||||
day: { hours: 12 },
|
||||
week: { days: 3, hours: 12 },
|
||||
month: { days: 15 },
|
||||
year: { months: 6 },
|
||||
};
|
||||
|
||||
const FRACTIONAL_CONVERT = {
|
||||
hours: { unit: 'minutes', factor: 60 },
|
||||
days: { unit: 'hours', factor: 24 },
|
||||
weeks: { unit: 'days', factor: 7 },
|
||||
months: { unit: 'days', factor: 30 },
|
||||
years: { unit: 'months', factor: 12 },
|
||||
};
|
||||
|
||||
// ─── Unicode / Normalization ────────────────────────────────────────────────
|
||||
// Turn non-ASCII digits and punctuation into plain ASCII so the
|
||||
// parser only has to deal with standard characters.
|
||||
|
||||
const UNICODE_DIGIT_RANGES = [
|
||||
[0x30, 0x39],
|
||||
[0x660, 0x669], // Arabic-Indic
|
||||
[0x6f0, 0x6f9], // Eastern Arabic-Indic
|
||||
[0x966, 0x96f], // Devanagari
|
||||
[0x9e6, 0x9ef], // Bengali
|
||||
[0xa66, 0xa6f], // Gurmukhi
|
||||
[0xae6, 0xaef], // Gujarati
|
||||
[0xb66, 0xb6f], // Oriya
|
||||
[0xbe6, 0xbef], // Tamil
|
||||
[0xc66, 0xc6f], // Telugu
|
||||
[0xce6, 0xcef], // Kannada
|
||||
[0xd66, 0xd6f], // Malayalam
|
||||
];
|
||||
|
||||
const toAsciiDigit = char => {
|
||||
const code = char.codePointAt(0);
|
||||
const range = UNICODE_DIGIT_RANGES.find(
|
||||
([start, end]) => code >= start && code <= end
|
||||
);
|
||||
if (!range) return char;
|
||||
return String(code - range[0]);
|
||||
};
|
||||
|
||||
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
|
||||
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
|
||||
|
||||
const ARABIC_PUNCT_MAP = {
|
||||
'\u061f': '?',
|
||||
'\u060c': ',',
|
||||
'\u061b': ';',
|
||||
'\u066b': '.',
|
||||
};
|
||||
|
||||
const NOISE_RE =
|
||||
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from|after|within)\s+)?/;
|
||||
|
||||
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
|
||||
|
||||
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
|
||||
export const sanitize = text =>
|
||||
normalizeDigits(
|
||||
text
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
|
||||
.replace(/[\u064b-\u065f]/g, '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
|
||||
)
|
||||
.replace(/[,!?;]+/g, ' ')
|
||||
.replace(/\.+$/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
|
||||
export const stripNoise = text => {
|
||||
let r = text
|
||||
.replace(/\ba\s+fortnight\b/g, '2 weeks')
|
||||
.replace(/\bfortnight\b/g, '2 weeks')
|
||||
.replace(NOISE_RE, '')
|
||||
.replace(APPROX_RE, '')
|
||||
.replace(/^the\s+/, '')
|
||||
.replace(/\bnxt\b/g, 'next')
|
||||
.replace(/\ba\s+couple\s+of\b/g, 'couple')
|
||||
.replace(/\bcouple\s+of\b/g, 'couple')
|
||||
.replace(/\ba\s+couple\b/g, 'couple')
|
||||
.replace(/\ba\s+few\b/g, 'few')
|
||||
.replace(
|
||||
/\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g,
|
||||
(_, h, m) =>
|
||||
`${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}`
|
||||
)
|
||||
.replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`)
|
||||
.replace(
|
||||
/\b(\d+)m\b/g,
|
||||
(_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}`
|
||||
)
|
||||
.replace(/\btomm?orow\b/g, 'tomorrow')
|
||||
.replace(/\s+later$/, '')
|
||||
.trim();
|
||||
// bare unit without number: "month later" → "1 month", "week" stays
|
||||
r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1');
|
||||
return r;
|
||||
};
|
||||
|
||||
// ─── Utility Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
|
||||
export const parseNumber = str => {
|
||||
if (!str) return null;
|
||||
const lower = normalizeDigits(str.toLowerCase().trim());
|
||||
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
|
||||
const num = Number(lower);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
/** Set the time on a date, clearing seconds and milliseconds. */
|
||||
export const applyTimeToDate = (date, hours, minutes = 0) =>
|
||||
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
|
||||
|
||||
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
|
||||
export const parseTimeString = timeStr => {
|
||||
if (!timeStr) return null;
|
||||
const match = timeStr
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const raw = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
const meridiem = match[3]?.replace(/\./g, '');
|
||||
if (meridiem && (raw < 1 || raw > 12)) return null;
|
||||
|
||||
const toHours = (h, m) => {
|
||||
if (m === 'pm' && h < 12) return h + 12;
|
||||
if (m === 'am' && h === 12) return 0;
|
||||
return h;
|
||||
};
|
||||
const hours = toHours(raw, meridiem);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
return { hours, minutes };
|
||||
};
|
||||
|
||||
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
|
||||
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
|
||||
if (timeStr) {
|
||||
const time = parseTimeString(timeStr);
|
||||
if (!time) return null;
|
||||
return applyTimeToDate(date, time.hours, time.minutes);
|
||||
}
|
||||
return applyTimeToDate(date, defaultHours, 0);
|
||||
};
|
||||
|
||||
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
|
||||
export const strictDate = (year, month, day) => {
|
||||
const date = new Date(year, month, day);
|
||||
if (
|
||||
!isValid(date) ||
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month ||
|
||||
date.getDate() !== day
|
||||
)
|
||||
return null;
|
||||
return date;
|
||||
};
|
||||
|
||||
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
|
||||
export const futureOrNextYear = (year, month, day, timeStr, now) => {
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
const base = strictDate(year + i, month, day);
|
||||
if (base) {
|
||||
const date = applyTimeOrDefault(base, timeStr);
|
||||
if (!date) return null;
|
||||
if (isAfter(date, now)) return date;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** If the date is already past, push it to the next day. */
|
||||
export const ensureFutureOrNextDay = (date, now) =>
|
||||
isAfter(date, now) ? date : add(date, { days: 1 });
|
||||
|
||||
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
|
||||
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
|
||||
const h = parseInt(rawHour, 10);
|
||||
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
|
||||
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
|
||||
const range = TOD_HOUR_RANGE[todLabel];
|
||||
if (!range) return { hours: h, minutes: m };
|
||||
// Try both am and pm interpretations, pick the one in range
|
||||
const am = h === 12 ? 0 : h;
|
||||
const pm = h === 12 ? 12 : h + 12;
|
||||
const inRange = v => v >= range[0] && v < range[1];
|
||||
if (inRange(am)) return { hours: am, minutes: m };
|
||||
if (inRange(pm)) return { hours: pm, minutes: m };
|
||||
const mid = (range[0] + range[1]) / 2;
|
||||
return {
|
||||
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
|
||||
minutes: m,
|
||||
};
|
||||
};
|
||||
|
||||
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
|
||||
export const addFractionalSafe = (date, unit, amount) => {
|
||||
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
|
||||
if (amount % 1 !== 0.5) return null;
|
||||
const conv = FRACTIONAL_CONVERT[unit];
|
||||
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
|
||||
return add(date, { [unit]: Math.round(amount) });
|
||||
};
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
startOfMonth,
|
||||
isMonday,
|
||||
isToday,
|
||||
isSameYear,
|
||||
setHours,
|
||||
setMinutes,
|
||||
setSeconds,
|
||||
} from 'date-fns';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
generateDateSuggestions,
|
||||
parseDateFromText,
|
||||
} from 'dashboard/helper/snoozeDateParser';
|
||||
import { UNIT_MAP } from 'dashboard/helper/snoozeDateParser/tokenMaps';
|
||||
|
||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||
|
||||
@@ -33,65 +39,113 @@ export const findStartOfNextMonth = currentDate => {
|
||||
});
|
||||
};
|
||||
|
||||
export const findNextDay = currentDate => {
|
||||
return add(currentDate, { days: 1 });
|
||||
};
|
||||
export const findNextDay = currentDate => add(currentDate, { days: 1 });
|
||||
|
||||
export const setHoursToNine = date => {
|
||||
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
|
||||
export const setHoursToNine = date =>
|
||||
setSeconds(setMinutes(setHours(date, 9), 0), 0);
|
||||
|
||||
const SNOOZE_RESOLVERS = {
|
||||
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
|
||||
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
|
||||
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
|
||||
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
|
||||
setHoursToNine(findStartOfNextMonth(d)),
|
||||
};
|
||||
|
||||
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
|
||||
let parsedDate = null;
|
||||
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
|
||||
parsedDate = add(currentDate, { hours: 1 });
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
|
||||
parsedDate = setHoursToNine(findNextDay(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
|
||||
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
|
||||
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
|
||||
}
|
||||
|
||||
return parsedDate ? getUnixTime(parsedDate) : null;
|
||||
const resolve = SNOOZE_RESOLVERS[snoozeType];
|
||||
return resolve ? getUnixTime(resolve(currentDate)) : null;
|
||||
};
|
||||
|
||||
export const snoozedReopenTime = snoozedUntil => {
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
if (!snoozedUntil) return null;
|
||||
const date = new Date(snoozedUntil);
|
||||
if (isToday(date)) return format(date, 'h.mmaaa');
|
||||
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
|
||||
return format(date, 'd MMM, h.mmaaa');
|
||||
};
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h.mmaaa');
|
||||
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
|
||||
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
|
||||
|
||||
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
|
||||
const sameYear = isSameYear(snoozeDate, currentDate);
|
||||
try {
|
||||
const opts = {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
};
|
||||
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
|
||||
} catch {
|
||||
return sameYear
|
||||
? format(snoozeDate, 'EEE, d MMM, h:mm a')
|
||||
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
|
||||
}
|
||||
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
|
||||
};
|
||||
|
||||
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
|
||||
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
|
||||
const expandUnit = (num, abbr) => {
|
||||
const full = UNIT_MAP[abbr];
|
||||
if (!full) return `${num} ${abbr}`;
|
||||
return parseFloat(num) === 1
|
||||
? `${num} ${full.replace(/s$/, '')}`
|
||||
: `${num} ${full}`;
|
||||
};
|
||||
|
||||
const capitalizeLabel = text => {
|
||||
const expanded = text
|
||||
.replace(
|
||||
/^(\d+)h(\d+)m(?:in)?$/i,
|
||||
(_, h, m) => `${expandUnit(h, 'h')} ${expandUnit(m, 'm')}`
|
||||
)
|
||||
.replace(/^(\d+(?:\.5)?)\s*([a-z]+)$/i, (_, n, u) =>
|
||||
UNIT_MAP[u.toLowerCase()] ? expandUnit(n, u.toLowerCase()) : `${n} ${u}`
|
||||
);
|
||||
return expanded.replace(/^\w/, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
export const generateSnoozeSuggestions = (
|
||||
searchText,
|
||||
currentDate = new Date(),
|
||||
{ translations, locale } = {}
|
||||
) => {
|
||||
const suggestions = generateDateSuggestions(searchText, currentDate, {
|
||||
translations,
|
||||
locale,
|
||||
});
|
||||
return suggestions.map(s => ({
|
||||
date: s.date,
|
||||
unixTime: s.unix,
|
||||
query: s.query,
|
||||
label: capitalizeLabel(s.label),
|
||||
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
|
||||
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
|
||||
}));
|
||||
};
|
||||
|
||||
const UNIT_SHORT = {
|
||||
minute: 'm',
|
||||
minutes: 'm',
|
||||
hour: 'h',
|
||||
hours: 'h',
|
||||
day: 'd',
|
||||
days: 'd',
|
||||
month: 'mo',
|
||||
months: 'mo',
|
||||
year: 'y',
|
||||
years: 'y',
|
||||
};
|
||||
|
||||
export const shortenSnoozeTime = snoozedUntil => {
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
const unitMap = {
|
||||
minutes: 'm',
|
||||
minute: 'm',
|
||||
hours: 'h',
|
||||
hour: 'h',
|
||||
days: 'd',
|
||||
day: 'd',
|
||||
months: 'mo',
|
||||
month: 'mo',
|
||||
years: 'y',
|
||||
year: 'y',
|
||||
};
|
||||
const shortenTime = snoozedUntil
|
||||
if (!snoozedUntil) return null;
|
||||
return snoozedUntil
|
||||
.replace(/^in\s+/i, '')
|
||||
.replace(
|
||||
/\s(minute|hour|day|month|year)s?\b/gi,
|
||||
(match, unit) => unitMap[unit.toLowerCase()] || match
|
||||
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
|
||||
);
|
||||
|
||||
return shortenTime;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import {
|
||||
setHoursToNine,
|
||||
snoozedReopenTimeToTimestamp,
|
||||
shortenSnoozeTime,
|
||||
generateSnoozeSuggestions,
|
||||
} from '../snoozeHelpers';
|
||||
|
||||
describe('#Snooze Helpers', () => {
|
||||
@@ -91,12 +92,26 @@ describe('#Snooze Helpers', () => {
|
||||
});
|
||||
|
||||
describe('snoozedReopenTime', () => {
|
||||
it('should return nil if snoozedUntil is nil', () => {
|
||||
expect(snoozedReopenTime(null)).toEqual(null);
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('should return formatted date if snoozedUntil is not nil', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return formatted date with year if snoozedUntil is not in current year', () => {
|
||||
// Input is 09:00 UTC.
|
||||
// If your environment is UTC, this will be 9.00am.
|
||||
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
|
||||
'7 Jun 2023, 9.00am'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return formatted date without year if snoozedUntil is in current year', () => {
|
||||
// This uses 2024 because we mocked the system time above
|
||||
expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual(
|
||||
'7 Jun, 9.00am'
|
||||
);
|
||||
});
|
||||
@@ -150,4 +165,56 @@ describe('#Snooze Helpers', () => {
|
||||
expect(shortenSnoozeTime(null)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSnoozeSuggestions label expansion', () => {
|
||||
const now = new Date('2023-06-16T10:00:00');
|
||||
|
||||
it('expands abbreviated units: "1d" → "1 Day"', () => {
|
||||
const results = generateSnoozeSuggestions('1d', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('1 day');
|
||||
});
|
||||
|
||||
it('expands abbreviated units: "2 d" → "2 Days"', () => {
|
||||
const results = generateSnoozeSuggestions('2 d', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('2 days');
|
||||
});
|
||||
|
||||
it('expands abbreviated units: "1h" → "1 Hour"', () => {
|
||||
const results = generateSnoozeSuggestions('1h', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('1 hour');
|
||||
});
|
||||
|
||||
it('expands abbreviated units: "2min" → "2 Minutes"', () => {
|
||||
const results = generateSnoozeSuggestions('2min', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('2 minutes');
|
||||
});
|
||||
|
||||
it('handles singular: "1 hours" → "1 Hour"', () => {
|
||||
const results = generateSnoozeSuggestions('1 hours', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('1 hour');
|
||||
});
|
||||
|
||||
it('handles singular: "1 minutes" → "1 Minute"', () => {
|
||||
const results = generateSnoozeSuggestions('1 minutes', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('1 minute');
|
||||
});
|
||||
|
||||
it('keeps plural for non-1: "2 days" → "2 Days"', () => {
|
||||
const results = generateSnoozeSuggestions('2 days', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('2 days');
|
||||
});
|
||||
|
||||
it('expands compound: "1h30m" → "1 Hour 30 Minutes"', () => {
|
||||
const results = generateSnoozeSuggestions('1h30m', now);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].label).toBe('1 hour 30 minutes');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
},
|
||||
"COMMAND_BAR": {
|
||||
"SEARCH_PLACEHOLDER": "Search or jump to",
|
||||
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
|
||||
"SECTIONS": {
|
||||
"GENERAL": "General",
|
||||
"REPORTS": "Reports",
|
||||
|
||||
@@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
@@ -77,6 +78,7 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "minute",
|
||||
"MINUTES": "minutes",
|
||||
"HOUR": "hour",
|
||||
"HOURS": "hours",
|
||||
"DAY": "day",
|
||||
"DAYS": "days",
|
||||
"WEEK": "week",
|
||||
"WEEKS": "weeks",
|
||||
"MONTH": "month",
|
||||
"MONTHS": "months",
|
||||
"YEAR": "year",
|
||||
"YEARS": "years"
|
||||
},
|
||||
"HALF": "half",
|
||||
"NEXT": "next",
|
||||
"THIS": "this",
|
||||
"AT": "at",
|
||||
"IN": "in",
|
||||
"FROM_NOW": "from now",
|
||||
"NEXT_YEAR": "next year",
|
||||
"MERIDIEM": {
|
||||
"AM": "am",
|
||||
"PM": "pm"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "tomorrow",
|
||||
"DAY_AFTER_TOMORROW": "day after tomorrow",
|
||||
"NEXT_WEEK": "next week",
|
||||
"NEXT_MONTH": "next month",
|
||||
"THIS_WEEKEND": "this weekend",
|
||||
"NEXT_WEEKEND": "next weekend"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "morning",
|
||||
"AFTERNOON": "afternoon",
|
||||
"EVENING": "evening",
|
||||
"NIGHT": "night",
|
||||
"NOON": "noon",
|
||||
"MIDNIGHT": "midnight"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "one",
|
||||
"TWO": "two",
|
||||
"THREE": "three",
|
||||
"FOUR": "four",
|
||||
"FIVE": "five",
|
||||
"SIX": "six",
|
||||
"SEVEN": "seven",
|
||||
"EIGHT": "eight",
|
||||
"NINE": "nine",
|
||||
"TEN": "ten",
|
||||
"TWELVE": "twelve",
|
||||
"FIFTEEN": "fifteen",
|
||||
"TWENTY": "twenty",
|
||||
"THIRTY": "thirty"
|
||||
},
|
||||
"ORDINALS": {
|
||||
"FIRST": "first",
|
||||
"SECOND": "second",
|
||||
"THIRD": "third",
|
||||
"FOURTH": "fourth",
|
||||
"FIFTH": "fifth"
|
||||
},
|
||||
"OF": "of",
|
||||
"AFTER": "after",
|
||||
"WEEK": "week",
|
||||
"DAY": "day"
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
|
||||
:header-title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
|
||||
:is-fetching="isFetching"
|
||||
:show-know-more="false"
|
||||
:show-pagination-footer="false"
|
||||
|
||||
@@ -31,6 +31,8 @@ const toggleStatus = async (status, snoozedUntil) => {
|
||||
const onCmdSnoozeConversation = snoozeType => {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
showCustomSnoozeModal.value = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, snoozeType);
|
||||
} else {
|
||||
toggleStatus(
|
||||
wootConstants.STATUS_TYPE.SNOOZED,
|
||||
|
||||
@@ -4,16 +4,29 @@ import { ref, computed, watchEffect, onMounted } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLocale } from 'shared/composables/useLocale';
|
||||
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
|
||||
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
|
||||
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
|
||||
import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys';
|
||||
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
GENERAL_EVENTS,
|
||||
SNOOZE_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { generateSnoozeSuggestions } from 'dashboard/helper/snoozeHelpers';
|
||||
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
|
||||
import {
|
||||
CMD_SNOOZE_CONVERSATION,
|
||||
CMD_SNOOZE_NOTIFICATION,
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { t, tm } = useI18n();
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
const ninjakeys = ref(null);
|
||||
|
||||
@@ -28,48 +41,168 @@ const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
|
||||
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
|
||||
const SNOOZE_PARENT_IDS = [
|
||||
'snooze_conversation',
|
||||
'snooze_notification',
|
||||
'bulk_action_snooze_conversation',
|
||||
];
|
||||
const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_';
|
||||
|
||||
const hotKeys = computed(() => [
|
||||
...inboxHotKeys.value,
|
||||
...goToCommandHotKeys.value,
|
||||
...goToAppearanceHotKeys.value,
|
||||
...bulkActionsHotKeys.value,
|
||||
...conversationHotKeys.value,
|
||||
]);
|
||||
const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
|
||||
const dynamicSnoozeActions = ref([]);
|
||||
const currentCommandRoot = ref(null);
|
||||
|
||||
const placeholder = computed(() =>
|
||||
SNOOZE_PARENT_IDS.includes(currentCommandRoot.value)
|
||||
? t('COMMAND_BAR.SNOOZE_PLACEHOLDER')
|
||||
: t('COMMAND_BAR.SEARCH_PLACEHOLDER')
|
||||
);
|
||||
|
||||
const SNOOZE_PRESET_IDS = new Set(Object.values(wootConstants.SNOOZE_OPTIONS));
|
||||
|
||||
const hotKeys = computed(() => {
|
||||
const allActions = [
|
||||
...dynamicSnoozeActions.value,
|
||||
...inboxHotKeys.value,
|
||||
...goToCommandHotKeys.value,
|
||||
...goToAppearanceHotKeys.value,
|
||||
...bulkActionsHotKeys.value,
|
||||
...conversationHotKeys.value,
|
||||
];
|
||||
// When dynamic NLP snooze suggestions exist, hide all preset snooze actions to avoid duplication
|
||||
if (!dynamicSnoozeActions.value.length) return allActions;
|
||||
return allActions.filter(
|
||||
a => !SNOOZE_PRESET_IDS.has(a.id) || !SNOOZE_PARENT_IDS.includes(a.parent)
|
||||
);
|
||||
});
|
||||
|
||||
const setCommandBarData = () => {
|
||||
ninjakeys.value.data = hotKeys.value;
|
||||
};
|
||||
|
||||
const onSelected = item => {
|
||||
const {
|
||||
detail: { action: { title = null, section = null, id = null } = {} } = {},
|
||||
} = item;
|
||||
// Added this condition to prevent setting the selectedSnoozeType to null
|
||||
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
|
||||
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
} else {
|
||||
selectedSnoozeType.value = null;
|
||||
const SNOOZE_EVENT_MAP = {
|
||||
snooze_conversation: CMD_SNOOZE_CONVERSATION,
|
||||
snooze_notification: CMD_SNOOZE_NOTIFICATION,
|
||||
bulk_action_snooze_conversation: CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
};
|
||||
|
||||
const SNOOZE_SECTION_MAP = {
|
||||
snooze_conversation: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
|
||||
snooze_notification: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
};
|
||||
|
||||
const snoozeTranslations = computed(() => {
|
||||
const raw = tm('SNOOZE_PARSER');
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
return JSON.parse(JSON.stringify(raw));
|
||||
});
|
||||
|
||||
const buildDynamicSnoozeActions = (search, parentId) => {
|
||||
const suggestions = generateSnoozeSuggestions(search, new Date(), {
|
||||
translations: snoozeTranslations.value,
|
||||
locale: resolvedLocale.value,
|
||||
});
|
||||
if (!suggestions.length) return [];
|
||||
|
||||
const busEvent = SNOOZE_EVENT_MAP[parentId];
|
||||
const section = t(SNOOZE_SECTION_MAP[parentId]);
|
||||
|
||||
return suggestions.map((parsed, index) => ({
|
||||
id: `${DYNAMIC_SNOOZE_PREFIX}${index}`,
|
||||
title:
|
||||
parsed.label !== parsed.formattedDate
|
||||
? `${parsed.label} - ${parsed.formattedDate}`
|
||||
: parsed.formattedDate,
|
||||
parent: parentId,
|
||||
section,
|
||||
icon: ICON_SNOOZE_CONVERSATION,
|
||||
keywords: search,
|
||||
handler: () => {
|
||||
emitter.emit(busEvent, parsed.resolve());
|
||||
useTrack(SNOOZE_EVENTS.NLP_SNOOZE_APPLIED, { label: parsed.label });
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const resetSnoozeState = () => {
|
||||
currentCommandRoot.value = null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
};
|
||||
|
||||
const patchNinjaKeysOpenClose = el => {
|
||||
if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
|
||||
section,
|
||||
action: title,
|
||||
});
|
||||
const originalOpen = el.open.bind(el);
|
||||
const originalClose = el.close.bind(el);
|
||||
|
||||
el.open = (...args) => {
|
||||
const [options = {}] = args;
|
||||
currentCommandRoot.value = options.parent || null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
return originalOpen(...args);
|
||||
};
|
||||
|
||||
el.close = (...args) => {
|
||||
resetSnoozeState();
|
||||
return originalClose(...args);
|
||||
};
|
||||
};
|
||||
|
||||
const onSelected = item => {
|
||||
const {
|
||||
detail: {
|
||||
action: { title = null, section = null, id = null, children = null } = {},
|
||||
} = {},
|
||||
} = item;
|
||||
|
||||
selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null;
|
||||
|
||||
if (Array.isArray(children) && children.length) {
|
||||
currentCommandRoot.value = id;
|
||||
}
|
||||
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title });
|
||||
setCommandBarData();
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
|
||||
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
|
||||
const onCommandBarChange = item => {
|
||||
const { detail: { search = '', actions = [] } = {} } = item;
|
||||
const normalizedSearch = search.trim();
|
||||
|
||||
if (actions.length > 0) {
|
||||
const uniqueParents = [
|
||||
...new Set(actions.map(action => action.parent).filter(Boolean)),
|
||||
];
|
||||
if (uniqueParents.length === 1) {
|
||||
currentCommandRoot.value = uniqueParents[0];
|
||||
} else {
|
||||
currentCommandRoot.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
|
||||
!normalizedSearch ||
|
||||
!SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '')
|
||||
) {
|
||||
dynamicSnoozeActions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
dynamicSnoozeActions.value = buildDynamicSnoozeActions(
|
||||
normalizedSearch,
|
||||
currentCommandRoot.value
|
||||
);
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
if (selectedSnoozeType.value !== CUSTOM_SNOOZE) {
|
||||
store.dispatch('setContextMenuChatId', null);
|
||||
}
|
||||
resetSnoozeState();
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -78,7 +211,10 @@ watchEffect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(setCommandBarData);
|
||||
onMounted(() => {
|
||||
setCommandBarData();
|
||||
patchNinjaKeysOpenClose(ninjakeys.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/attribute-hyphenation -->
|
||||
@@ -88,6 +224,7 @@ onMounted(setCommandBarData);
|
||||
noAutoLoadMdIcons
|
||||
hideBreadcrumbs
|
||||
:placeholder="placeholder"
|
||||
@change="onCommandBarChange"
|
||||
@selected="onSelected"
|
||||
@closed="onClosed"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,8 @@ export default {
|
||||
onCmdSnoozeNotification(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomSnoozeModal = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.snoozeNotification(snoozeType);
|
||||
} else {
|
||||
const snoozedUntil = findSnoozeTime(snoozeType) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
|
||||
@@ -228,7 +228,10 @@ export const mutations = {
|
||||
},
|
||||
|
||||
[types.ADD_CONVERSATION](_state, conversation) {
|
||||
_state.allConversations.push(conversation);
|
||||
const exists = _state.allConversations.some(c => c.id === conversation.id);
|
||||
if (!exists) {
|
||||
_state.allConversations.push(conversation);
|
||||
}
|
||||
},
|
||||
|
||||
[types.DELETE_CONVERSATION](_state, conversationId) {
|
||||
|
||||
@@ -975,6 +975,16 @@ describe('#mutations', () => {
|
||||
mutations[types.ADD_CONVERSATION](state, conversation);
|
||||
expect(state.allConversations).toEqual([conversation]);
|
||||
});
|
||||
|
||||
it('should not add a duplicate conversation', () => {
|
||||
const conversation = { id: 1, messages: [] };
|
||||
const state = {
|
||||
allConversations: [conversation],
|
||||
};
|
||||
|
||||
mutations[types.ADD_CONVERSATION](state, { id: 1, messages: [] });
|
||||
expect(state.allConversations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#DELETE_CONVERSATION', () => {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { build } = require('vite');
|
||||
|
||||
const ROOT_DIR = process.cwd();
|
||||
const PATHS = {
|
||||
manifest: path.join(ROOT_DIR, 'public/vite/.vite/manifest.json'),
|
||||
swRuntime: path.join(__dirname, 'sw-runtime.js'),
|
||||
swRuntimeBuilt: path.join(ROOT_DIR, 'tmp/sw-build/sw-runtime.js'),
|
||||
pushHandlers: path.join(__dirname, 'push-handlers.js'),
|
||||
buildDir: path.join(ROOT_DIR, 'tmp/sw-build'),
|
||||
output: path.join(ROOT_DIR, 'public/sw.js'),
|
||||
};
|
||||
|
||||
const buildSWContent = (runtimeBundle, pushHandlers, assetCount) => `
|
||||
/* Service Worker for Chatwoot */
|
||||
/* Generated: ${new Date().toISOString()} */
|
||||
/* Precached assets: ${assetCount} */
|
||||
|
||||
${runtimeBundle}
|
||||
|
||||
${pushHandlers}
|
||||
`;
|
||||
|
||||
function generateAssetManifest() {
|
||||
const manifestPath = fs.existsSync(PATHS.manifest) ? PATHS.manifest : null;
|
||||
|
||||
if (!manifestPath) {
|
||||
console.warn(
|
||||
'⚠️ No Vite manifest found, skipping asset manifest generation'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`📋 Reading manifest from: ${manifestPath}`);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const assets = [];
|
||||
const seen = new Set();
|
||||
|
||||
Object.entries(manifest).forEach(([, entry]) => {
|
||||
const file = entry.file;
|
||||
if (file && !seen.has(file)) {
|
||||
seen.add(file);
|
||||
if (file.endsWith('.js') || file.endsWith('.css')) {
|
||||
assets.push({ url: file, revision: null });
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.css) {
|
||||
entry.css.forEach(cssFile => {
|
||||
if (!seen.has(cssFile)) {
|
||||
seen.add(cssFile);
|
||||
assets.push({ url: cssFile, revision: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.assets) {
|
||||
entry.assets.forEach(assetFile => {
|
||||
if (!seen.has(assetFile)) {
|
||||
seen.add(assetFile);
|
||||
assets.push({ url: assetFile, revision: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📦 Found ${assets.length} assets to precache`);
|
||||
return assets;
|
||||
}
|
||||
|
||||
async function buildServiceWorker() {
|
||||
console.log('🔨 Building service worker...');
|
||||
|
||||
let assetOrigin = process.env.ASSET_CDN_HOST || '';
|
||||
if (assetOrigin && !assetOrigin.startsWith('http')) {
|
||||
assetOrigin = `https://${assetOrigin}`;
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const assetPath = '/vite/';
|
||||
|
||||
console.log(`📁 Root dir: ${ROOT_DIR}`);
|
||||
console.log(`🌐 Asset origin: ${assetOrigin || '(local)'}`);
|
||||
console.log(`🏭 Environment: ${isProduction ? 'production' : 'development'}`);
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(
|
||||
'⚠️ Development mode: Using push notifications only (no caching)'
|
||||
);
|
||||
|
||||
const devServiceWorker = fs.readFileSync(PATHS.pushHandlers, 'utf8');
|
||||
|
||||
fs.writeFileSync(PATHS.output, devServiceWorker);
|
||||
|
||||
console.log('✅ Development service worker created at public/sw.js');
|
||||
return;
|
||||
}
|
||||
|
||||
const assetManifest = generateAssetManifest();
|
||||
|
||||
try {
|
||||
console.log('📦 Building service worker runtime...');
|
||||
|
||||
await build({
|
||||
configFile: false,
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: PATHS.swRuntime,
|
||||
formats: ['iife'],
|
||||
name: 'ServiceWorkerRuntime',
|
||||
fileName: () => 'sw-runtime.js',
|
||||
},
|
||||
outDir: PATHS.buildDir,
|
||||
emptyOutDir: true,
|
||||
minify: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__ASSET_ORIGIN__: JSON.stringify(assetOrigin),
|
||||
__ASSET_PATH__: JSON.stringify(assetPath),
|
||||
__ASSET_MANIFEST__: JSON.stringify(assetManifest),
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
logLevel: 'warn',
|
||||
});
|
||||
|
||||
console.log('✅ Service worker runtime built');
|
||||
|
||||
const runtimeBundle = fs.readFileSync(PATHS.swRuntimeBuilt, 'utf8');
|
||||
|
||||
const pushHandlers = fs.readFileSync(PATHS.pushHandlers, 'utf8');
|
||||
|
||||
const finalServiceWorker = buildSWContent(
|
||||
runtimeBundle,
|
||||
pushHandlers,
|
||||
assetManifest.length
|
||||
);
|
||||
|
||||
fs.writeFileSync(PATHS.output, finalServiceWorker);
|
||||
|
||||
console.log('✅ Service worker built successfully at public/sw.js');
|
||||
console.log(` - ${assetManifest.length} assets in precache manifest`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build service worker:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildServiceWorker().catch(err => {
|
||||
console.error('❌ Unhandled error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-restricted-globals, no-console */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
/* globals clients */
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
let notification = event.data && event.data.json();
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable no-restricted-globals, no-use-before-define, no-undef */
|
||||
|
||||
// Build-time injected values (replaced by Vite define at build time)
|
||||
const ASSET_ORIGIN = __ASSET_ORIGIN__;
|
||||
const ASSET_PATH = __ASSET_PATH__;
|
||||
const ASSET_MANIFEST = __ASSET_MANIFEST__;
|
||||
|
||||
const CACHE_NAME = 'chatwoot-assets-v1';
|
||||
|
||||
const EXCLUDED_PATHS = [
|
||||
'/api/',
|
||||
'/auth/',
|
||||
'/rails/',
|
||||
'/cable',
|
||||
'/sidekiq',
|
||||
'/super_admin',
|
||||
'/swagger',
|
||||
'/webhooks/',
|
||||
'/widget',
|
||||
'/survey/',
|
||||
'/__vite',
|
||||
'/sw.js',
|
||||
];
|
||||
|
||||
const BATCH_SIZE = 5;
|
||||
|
||||
const buildAssetListChunks = (arr, size) => {
|
||||
const result = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
result.push(arr.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const prefetchBatch = async (cache, baseUrl, assets) => {
|
||||
const fetchAndCacheAsset = async asset => {
|
||||
const url = `${baseUrl}${asset.url}`;
|
||||
if (await cache.match(url)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
cache.put(url, response);
|
||||
}
|
||||
} catch {
|
||||
// Ignore prefetch failures
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(assets.map(fetchAndCacheAsset));
|
||||
};
|
||||
|
||||
const cacheFirst = async request => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
const stale = await cache.match(request);
|
||||
if (stale) return stale;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const prefetchAssets = async () => {
|
||||
if (!ASSET_MANIFEST?.length) return;
|
||||
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const baseUrl = ASSET_ORIGIN
|
||||
? `${ASSET_ORIGIN}${ASSET_PATH}`
|
||||
: `${self.location.origin}${ASSET_PATH}`;
|
||||
|
||||
const batches = buildAssetListChunks(ASSET_MANIFEST, BATCH_SIZE);
|
||||
await batches.reduce(
|
||||
(promise, batch) =>
|
||||
promise.then(() => prefetchBatch(cache, baseUrl, batch)),
|
||||
Promise.resolve()
|
||||
);
|
||||
};
|
||||
const cleanupStaleAssets = async () => {
|
||||
if (!ASSET_MANIFEST?.length) return;
|
||||
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cachedRequests = await cache.keys();
|
||||
|
||||
const baseUrl = ASSET_ORIGIN
|
||||
? `${ASSET_ORIGIN}${ASSET_PATH}`
|
||||
: `${self.location.origin}${ASSET_PATH}`;
|
||||
|
||||
const validUrls = new Set(
|
||||
ASSET_MANIFEST.map(asset => `${baseUrl}${asset.url}`)
|
||||
);
|
||||
|
||||
const deletions = cachedRequests
|
||||
.filter(request => {
|
||||
const url = new URL(request.url);
|
||||
const isAssetPath = url.pathname.startsWith('/vite/assets/');
|
||||
return isAssetPath && !validUrls.has(request.url);
|
||||
})
|
||||
.map(request => cache.delete(request));
|
||||
|
||||
await Promise.all(deletions);
|
||||
};
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
Promise.all([cleanupStaleAssets(), prefetchAssets(), self.clients.claim()])
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (EXCLUDED_PATHS.some(path => url.pathname.startsWith(path))) return;
|
||||
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const isAChatwootRequest =
|
||||
url.origin === self.location.origin ||
|
||||
(ASSET_ORIGIN && url.origin === ASSET_ORIGIN);
|
||||
|
||||
if (!isAChatwootRequest) return;
|
||||
|
||||
const isAnAsset =
|
||||
url.pathname.startsWith('/vite/') ||
|
||||
url.origin === ASSET_ORIGIN ||
|
||||
/\.(js|css|woff2?|ttf|otf|eot|svg|png)$/i.test(url.pathname);
|
||||
|
||||
if (isAnAsset) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,12 @@ const RECONNECT_INTERVAL = 1000;
|
||||
class BaseActionCableConnector {
|
||||
static isDisconnected = false;
|
||||
|
||||
constructor(app, pubsubToken, websocketHost = '') {
|
||||
constructor(
|
||||
app,
|
||||
pubsubToken,
|
||||
websocketHost = '',
|
||||
presenceInterval = PRESENCE_INTERVAL
|
||||
) {
|
||||
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
|
||||
|
||||
this.consumer = createConsumer(websocketURL);
|
||||
@@ -37,7 +42,7 @@ class BaseActionCableConnector {
|
||||
setTimeout(() => {
|
||||
this.subscription.updatePresence();
|
||||
this.triggerPresenceInterval();
|
||||
}, PRESENCE_INTERVAL);
|
||||
}, presenceInterval);
|
||||
};
|
||||
this.triggerPresenceInterval();
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ const isMessageInActiveConversation = (getters, message) => {
|
||||
return activeConversationId && conversationId !== activeConversationId;
|
||||
};
|
||||
|
||||
const WIDGET_PRESENCE_INTERVAL = 60000;
|
||||
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
super(app, pubsubToken);
|
||||
super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL);
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
'message.updated': this.onMessageUpdated,
|
||||
|
||||
@@ -143,7 +143,7 @@ class Conversation < ApplicationRecord
|
||||
end
|
||||
|
||||
def last_incoming_message
|
||||
messages&.incoming&.last
|
||||
messages.where(account_id: account_id)&.incoming&.last
|
||||
end
|
||||
|
||||
def toggle_status
|
||||
|
||||
@@ -30,10 +30,12 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
|
||||
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
|
||||
validates :attribute_display_name, presence: true
|
||||
before_validation :normalize_attribute_fields
|
||||
|
||||
validates :attribute_key,
|
||||
presence: true,
|
||||
uniqueness: { scope: [:account_id, :attribute_model] }
|
||||
uniqueness: { scope: [:account_id, :attribute_model] },
|
||||
format: { with: /\A[\p{L}\p{N}_.\-]+\z/, message: I18n.t('errors.custom_attribute_definition.attribute_key_format') }
|
||||
|
||||
validates :attribute_display_type, presence: true
|
||||
validates :attribute_model, presence: true
|
||||
@@ -48,6 +50,11 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def normalize_attribute_fields
|
||||
self.attribute_key = attribute_key.strip if attribute_key.present?
|
||||
self.attribute_display_name = attribute_display_name.strip if attribute_display_name.present?
|
||||
end
|
||||
|
||||
def sync_widget_pre_chat_custom_fields
|
||||
::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key)
|
||||
end
|
||||
|
||||
@@ -125,7 +125,7 @@ class Message < ApplicationRecord
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation, touch: true
|
||||
belongs_to :conversation
|
||||
belongs_to :sender, polymorphic: true, optional: true
|
||||
|
||||
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
||||
@@ -429,7 +429,7 @@ class Message < ApplicationRecord
|
||||
|
||||
def set_conversation_activity
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_columns(last_activity_at: created_at)
|
||||
conversation.update_columns(last_activity_at: created_at, updated_at: Time.current)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||
private
|
||||
|
||||
def push_messages
|
||||
[messages.chat.last&.push_event_data].compact
|
||||
[messages.where(account_id: account_id).chat.last&.push_event_data].compact
|
||||
end
|
||||
|
||||
def push_meta
|
||||
|
||||
@@ -47,7 +47,9 @@ class ActionService
|
||||
|
||||
@agent = @account.users.find_by(id: agent_ids)
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id) if @agent.present?
|
||||
return unless @agent.present? && @agent.confirmed?
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id)
|
||||
end
|
||||
|
||||
def remove_label(labels)
|
||||
|
||||
@@ -2,6 +2,7 @@ require 'json'
|
||||
|
||||
class FilterService
|
||||
include Filters::FilterHelper
|
||||
include Filters::CustomAttributeFilterHelper
|
||||
include CustomExceptions::CustomFilter
|
||||
|
||||
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
|
||||
@@ -33,9 +34,9 @@ class FilterService
|
||||
when 'is_not_present'
|
||||
@filter_values["value_#{current_index}"] = 'IS NULL'
|
||||
when 'is_greater_than', 'is_less_than'
|
||||
@filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash)
|
||||
lt_gt_filter_query(query_hash, current_index)
|
||||
when 'days_before'
|
||||
@filter_values["value_#{current_index}"] = days_before_filter_values(query_hash)
|
||||
days_before_filter_query(query_hash, current_index)
|
||||
else
|
||||
@filter_values["value_#{current_index}"] = filter_values(query_hash).to_s
|
||||
"= :value_#{current_index}"
|
||||
@@ -81,21 +82,29 @@ class FilterService
|
||||
query_hash['values'].downcase
|
||||
end
|
||||
|
||||
def lt_gt_filter_values(query_hash)
|
||||
def lt_gt_filter_query(query_hash, current_index)
|
||||
attribute_key = query_hash[:attribute_key]
|
||||
attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
|
||||
attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
|
||||
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
|
||||
value = query_hash['values'][0]
|
||||
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] || standard_attribute_data_type(attribute_key)
|
||||
|
||||
@filter_values["value_#{current_index}"] = coerce_lt_gt_value(
|
||||
query_hash['values'][0],
|
||||
attribute_data_type,
|
||||
attribute_key
|
||||
)
|
||||
operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
|
||||
"#{operator} '#{value}'::#{attribute_data_type}"
|
||||
"#{operator} :value_#{current_index}"
|
||||
end
|
||||
|
||||
def days_before_filter_values(query_hash)
|
||||
def days_before_filter_query(query_hash, current_index)
|
||||
date = Time.zone.today - query_hash['values'][0].to_i.days
|
||||
query_hash['values'] = [date.strftime]
|
||||
query_hash['filter_operator'] = 'is_less_than'
|
||||
lt_gt_filter_values(query_hash)
|
||||
updated_query_hash = query_hash.with_indifferent_access.merge(
|
||||
values: [date.strftime],
|
||||
filter_operator: 'is_less_than'
|
||||
)
|
||||
|
||||
lt_gt_filter_query(updated_query_hash, current_index)
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
@@ -129,52 +138,26 @@ class FilterService
|
||||
end
|
||||
end
|
||||
|
||||
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
|
||||
@attribute_key = query_hash[:attribute_key]
|
||||
@custom_attribute_type = custom_attribute_type
|
||||
attribute_data_type
|
||||
return '' if @custom_attribute.blank?
|
||||
|
||||
build_custom_attr_query(query_hash, current_index)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attribute_model
|
||||
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
|
||||
def standard_attribute_data_type(attribute_key)
|
||||
@filters.each_value do |section|
|
||||
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def attribute_data_type
|
||||
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
|
||||
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
|
||||
end
|
||||
|
||||
def build_custom_attr_query(query_hash, current_index)
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
query_operator = query_hash[:query_operator]
|
||||
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
|
||||
|
||||
query = if attribute_data_type == 'text'
|
||||
"LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
|
||||
else
|
||||
"(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
|
||||
end
|
||||
|
||||
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
|
||||
end
|
||||
|
||||
def custom_attribute(attribute_key, account, custom_attribute_type)
|
||||
current_account = account || Current.account
|
||||
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
|
||||
@custom_attribute = current_account.custom_attribute_definitions.where(
|
||||
attribute_model: attribute_model
|
||||
).find_by(attribute_key: attribute_key)
|
||||
end
|
||||
|
||||
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
|
||||
return '' unless query_hash[:filter_operator] == 'not_equal_to'
|
||||
|
||||
" OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL "
|
||||
def coerce_lt_gt_value(raw_value, attribute_data_type, attribute_key)
|
||||
case attribute_data_type
|
||||
when 'date'
|
||||
Date.iso8601(raw_value.to_s)
|
||||
when 'numeric'
|
||||
BigDecimal(raw_value.to_s)
|
||||
else
|
||||
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
|
||||
end
|
||||
rescue ArgumentError, FloatDomainError, TypeError
|
||||
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
|
||||
end
|
||||
|
||||
def equals_to_filter_string(filter_operator, current_index)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
module Filters::CustomAttributeFilterHelper
|
||||
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
|
||||
@attribute_key = query_hash[:attribute_key]
|
||||
@custom_attribute_type = custom_attribute_type
|
||||
attribute_data_type
|
||||
return '' if @custom_attribute.blank?
|
||||
|
||||
build_custom_attr_query(query_hash, current_index)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attribute_model
|
||||
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
|
||||
end
|
||||
|
||||
def attribute_data_type
|
||||
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
|
||||
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
|
||||
end
|
||||
|
||||
def build_custom_attr_query(query_hash, current_index)
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
query_operator = query_hash[:query_operator]
|
||||
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
|
||||
|
||||
query = if attribute_data_type == 'text'
|
||||
ActiveRecord::Base.sanitize_sql_array(
|
||||
["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
|
||||
)
|
||||
else
|
||||
ActiveRecord::Base.sanitize_sql_array(
|
||||
["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
|
||||
)
|
||||
end
|
||||
|
||||
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
|
||||
end
|
||||
|
||||
def custom_attribute(attribute_key, account, custom_attribute_type)
|
||||
current_account = account || Current.account
|
||||
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
|
||||
@custom_attribute = current_account.custom_attribute_definitions.where(
|
||||
attribute_model: attribute_model
|
||||
).find_by(attribute_key: attribute_key)
|
||||
end
|
||||
|
||||
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
|
||||
return '' unless query_hash[:filter_operator] == 'not_equal_to'
|
||||
|
||||
ActiveRecord::Base.sanitize_sql_array(
|
||||
[" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,7 @@ class Internal::RemoveStaleRedisKeysService
|
||||
|
||||
def perform
|
||||
Rails.logger.info "Removing redis stale keys for account #{@account_id}"
|
||||
range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i
|
||||
range_start = (Time.zone.now - OnlineStatusTracker::CONTACT_PRESENCE_DURATION).to_i
|
||||
# exclusive minimum score is specified by prefixing (
|
||||
# we are clearing old records because this could clogg up the sorted set
|
||||
::Redis::Alfred.zremrangebyscore(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="field-unit__label">
|
||||
<%= f.label field.attribute %>
|
||||
</div>
|
||||
<div class="field-unit__field">
|
||||
<% value = field.data %>
|
||||
<% value = Time.current if value.blank? && action_name == 'new' %>
|
||||
<%= f.datetime_local_field field.attribute, step: 1, value: value %>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<% if field.data %>
|
||||
<%= field.datetime %>
|
||||
<% end %>
|
||||
@@ -53,6 +53,14 @@ as well as a link to its edit page.
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% end %>
|
||||
|
||||
<dt class="attribute-label" id="mfa_enabled">MFA</dt>
|
||||
<dd class="attribute-data attribute-data--string">
|
||||
<% mfa_enabled = page.resource.mfa_enabled? %>
|
||||
<span class="<%= mfa_enabled ? 'bg-green-100/70 text-green-800' : 'bg-slate-100 text-slate-700' %> inline-flex items-center px-2 h-4 leading-4 rounded-xl text-xxs font-medium">
|
||||
<%= mfa_enabled ? 'Enabled' : 'Disabled' %>
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.11.1'
|
||||
version: '4.11.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -191,7 +191,6 @@
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
|
||||
@@ -185,7 +185,8 @@ class Rack::Attack
|
||||
###-----------------------------------------------###
|
||||
|
||||
## Prevent Abuse of Converstion Transcript APIs ###
|
||||
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript', limit: 30, period: 1.hour) do |req|
|
||||
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript',
|
||||
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_TRANSCRIPT', '1000').to_i, period: 1.hour) do |req|
|
||||
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/conversations/(?<conversation_id>\d+)/transcript}.match(req.path)
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
@@ -122,6 +122,7 @@ en:
|
||||
invalid_query_operator: Query operator must be either "AND" or "OR".
|
||||
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
||||
custom_attribute_definition:
|
||||
attribute_key_format: must only contain letters, numbers, underscores, hyphens, and dots
|
||||
key_conflict: The provided key is not allowed as it might conflict with default attributes.
|
||||
mfa:
|
||||
already_enabled: MFA is already enabled
|
||||
|
||||
@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground
|
||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
additional_message: params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
response = if captain_v2_enabled?
|
||||
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
message_history: playground_message_history
|
||||
)
|
||||
else
|
||||
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
additional_message: playground_params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
end
|
||||
|
||||
render json: response
|
||||
end
|
||||
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground_params
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
|
||||
end
|
||||
|
||||
def message_history
|
||||
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
|
||||
(playground_params[:message_history] || []).map do |message|
|
||||
{
|
||||
role: message[:role],
|
||||
content: message[:content],
|
||||
agent_name: message[:agent_name]
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
|
||||
def playground_message_history
|
||||
history = message_history
|
||||
current_message = playground_params[:message_content]
|
||||
return history if current_message.blank?
|
||||
|
||||
current_user_message = { role: 'user', content: current_message }
|
||||
return history if history.last == current_user_message
|
||||
|
||||
history + [current_user_message]
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
@assistant.account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,6 @@ module Captain::ChatHelper
|
||||
|
||||
def request_chat_completion
|
||||
log_chat_completion_request
|
||||
|
||||
chat = build_chat
|
||||
|
||||
add_messages_to_chat(chat)
|
||||
@@ -86,7 +85,8 @@ module Captain::ChatHelper
|
||||
temperature: temperature,
|
||||
metadata: {
|
||||
assistant_id: @assistant&.id,
|
||||
channel_type: resolved_channel_type
|
||||
channel_type: resolved_channel_type,
|
||||
source: @source
|
||||
}.compact
|
||||
}
|
||||
end
|
||||
@@ -130,7 +130,6 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def log_chat_completion_request
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
|
||||
"for messages #{@messages} with #{@tools&.length || 0} tools")
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
scenarios: scenarios.enabled.map do |scenario|
|
||||
{
|
||||
title: scenario.title,
|
||||
key: scenario.title.parameterize.underscore,
|
||||
key: scenario.handoff_key,
|
||||
description: scenario.description
|
||||
}
|
||||
end,
|
||||
|
||||
@@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
# OpenAI enforces a 64-char limit on function names. The ai-agents gem
|
||||
# prepends "handoff_to_" (11 chars), so we keep a safety margin and cap
|
||||
# the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars).
|
||||
# Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable),
|
||||
# and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated
|
||||
# based on the available length budget.
|
||||
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
|
||||
HANDOFF_KEY_PREFIX = 'scenario'.freeze
|
||||
HANDOFF_KEY_SUFFIX = 'agent'.freeze
|
||||
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
|
||||
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length
|
||||
MAX_HANDOFF_SLUG_LENGTH = 24
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
@@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord
|
||||
|
||||
before_save :resolve_tool_references
|
||||
|
||||
def handoff_key
|
||||
[handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_')
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
title: title,
|
||||
@@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord
|
||||
private
|
||||
|
||||
def agent_name
|
||||
"#{title} Agent".parameterize(separator: '_')
|
||||
handoff_key
|
||||
end
|
||||
|
||||
def handoff_id_key
|
||||
return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present?
|
||||
|
||||
"#{HANDOFF_KEY_PREFIX}_draft"
|
||||
end
|
||||
|
||||
def compact_handoff_slug
|
||||
slug = title.to_s.parameterize(separator: '_').presence
|
||||
return nil if slug.blank?
|
||||
|
||||
max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min
|
||||
return nil if max_slug_length <= 0
|
||||
|
||||
slug.first(max_slug_length).sub(/_+\z/, '').presence
|
||||
end
|
||||
|
||||
def dynamic_slug_max_length
|
||||
# handoff_to_#{scenario_<id>_<slug>_agent}
|
||||
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
|
||||
@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {})
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
@source = source
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
process_agent_result(result)
|
||||
rescue StandardError => e
|
||||
# when running the agent runner service in a rake task, the conversation might not have an account associated
|
||||
# for regular production usage, it will run just fine
|
||||
# In rake/local runs, conversation may not be present, so account is optional here.
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
return unless @conversation.contact_inbox
|
||||
|
||||
state[:contact_inbox] =
|
||||
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
|
||||
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
|
||||
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
|
||||
}.compact.transform_values(&:to_s)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
def initialize(assistant: nil, conversation_id: nil, source: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_id = conversation_id
|
||||
@source = source
|
||||
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
|
||||
@@ -14,4 +14,8 @@ class GlobalConfigService
|
||||
GlobalConfig.clear_cache
|
||||
i.value
|
||||
end
|
||||
|
||||
def self.account_signup_enabled?
|
||||
load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class OnlineStatusTracker
|
||||
# NOTE: You can customise the environment variable to keep your agents/contacts as online for longer
|
||||
PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds
|
||||
# Widget pings every 60s, so contacts need a longer presence window
|
||||
CONTACT_PRESENCE_DURATION = ENV.fetch('CONTACT_PRESENCE_DURATION', 90).to_i.seconds
|
||||
|
||||
# presence : sorted set with timestamp as the score & object id as value
|
||||
|
||||
@@ -11,7 +13,8 @@ class OnlineStatusTracker
|
||||
|
||||
def self.get_presence(account_id, obj_type, obj_id)
|
||||
connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id)
|
||||
connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i
|
||||
duration = obj_type == 'Contact' ? CONTACT_PRESENCE_DURATION : PRESENCE_DURATION
|
||||
connected_time && connected_time > (Time.zone.now - duration).to_i
|
||||
end
|
||||
|
||||
def self.presence_key(account_id, type)
|
||||
@@ -39,7 +42,7 @@ class OnlineStatusTracker
|
||||
end
|
||||
|
||||
def self.get_available_contact_ids(account_id)
|
||||
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
|
||||
range_start = (Time.zone.now - CONTACT_PRESENCE_DURATION).to_i
|
||||
# exclusive minimum score is specified by prefixing (
|
||||
# we are clearing old records because this could clogg up the sorted set
|
||||
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
|
||||
|
||||
@@ -8,6 +8,12 @@ task before_assets_precompile: :environment do
|
||||
system('echo "-------------- Bulding App for Production --------------"')
|
||||
end
|
||||
|
||||
# every time you execute 'rake assets:precompile'
|
||||
# run 'before_assets_precompile' first
|
||||
Rake::Task['assets:precompile'].enhance %w[before_assets_precompile]
|
||||
task after_assets_precompile: :environment do
|
||||
# Build service worker after Vite has generated the asset manifest
|
||||
system('echo "-------------- Building Service Worker --------------"')
|
||||
system('NODE_ENV=production pnpm run build:sw')
|
||||
end
|
||||
|
||||
Rake::Task['assets:precompile'].enhance %w[before_assets_precompile] do
|
||||
Rake::Task['after_assets_precompile'].invoke
|
||||
end
|
||||
|
||||
+14
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.11.1",
|
||||
"version": "4.11.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -13,6 +13,7 @@
|
||||
"dev": "overmind start -f ./Procfile.dev",
|
||||
"ruby:prettier": "bundle exec rubocop -a",
|
||||
"build:sdk": "BUILD_MODE=library vite build",
|
||||
"build:sw": "node app/javascript/service-worker/build.js",
|
||||
"prepare": "husky install",
|
||||
"size": "size-limit",
|
||||
"story:dev": "histoire dev",
|
||||
@@ -34,10 +35,10 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.6",
|
||||
"@chatwoot/prosemirror-schema": "1.3.7",
|
||||
"@chatwoot/utils": "^0.0.52",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@iconify-json/fluent": "^1.2.32",
|
||||
@@ -49,7 +50,7 @@
|
||||
"@scmmishra/pico-search": "0.6.0",
|
||||
"@sentry/vue": "^8.55.0",
|
||||
"@sindresorhus/slugify": "2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@twilio/voice-sdk": "^2.12.4",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
@@ -59,7 +60,7 @@
|
||||
"@vueuse/components": "^12.0.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"activestorage": "^5.2.6",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.13.6",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"chart.js": "~4.4.4",
|
||||
"color2k": "^2.0.2",
|
||||
@@ -68,7 +69,7 @@
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"date-fns": "2.21.1",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"dompurify": "3.2.4",
|
||||
"dompurify": "3.3.2",
|
||||
"flag-icons": "^7.2.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
@@ -99,7 +100,7 @@
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
"vue-dompurify-html": "^5.1.0",
|
||||
"vue-dompurify-html": "^5.3.0",
|
||||
"vue-i18n": "9.14.5",
|
||||
"vue-letter": "^0.2.1",
|
||||
"vue-router": "~4.4.5",
|
||||
@@ -111,7 +112,7 @@
|
||||
"wavesurfer.js": "7.8.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.9.0",
|
||||
"@egoist/tailwindcss-icons": "^1.9.2",
|
||||
"@histoire/plugin-vue": "0.17.15",
|
||||
"@iconify-json/logos": "^1.2.10",
|
||||
"@iconify-json/lucide": "^1.2.82",
|
||||
@@ -142,7 +143,7 @@
|
||||
"prettier": "^3.3.3",
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"size-limit": "^8.2.4",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-ruby": "^5.0.0",
|
||||
"vitest": "3.0.5"
|
||||
@@ -160,7 +161,9 @@
|
||||
"overrides": {
|
||||
"vite-node": "2.0.1",
|
||||
"vite": "5.4.21",
|
||||
"vitest": "3.0.5"
|
||||
"vitest": "3.0.5",
|
||||
"minimatch@<4": "3.1.5",
|
||||
"minimatch@>=9.0.0 <9.0.7": "9.0.9"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
Generated
+157
-245
@@ -8,6 +8,8 @@ overrides:
|
||||
vite-node: 2.0.1
|
||||
vite: 5.4.21
|
||||
vitest: 3.0.5
|
||||
minimatch@<4: 3.1.5
|
||||
minimatch@>=9.0.0 <9.0.7: 9.0.9
|
||||
|
||||
importers:
|
||||
|
||||
@@ -23,17 +25,17 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.6
|
||||
version: 1.3.6
|
||||
specifier: 1.3.7
|
||||
version: 1.3.7
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.52
|
||||
version: 0.0.52
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2
|
||||
'@formkit/vue':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7(tailwindcss@3.4.13)(vue@3.5.12(typescript@5.6.2))
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2(vue@3.5.12(typescript@5.6.2))
|
||||
'@hcaptcha/vue3-hcaptcha':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -68,8 +70,8 @@ importers:
|
||||
specifier: 2.2.1
|
||||
version: 2.2.1
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.15
|
||||
version: 0.5.15(tailwindcss@3.4.13)
|
||||
specifier: ^0.5.19
|
||||
version: 0.5.19(tailwindcss@3.4.19)
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.20.5
|
||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -98,8 +100,8 @@ importers:
|
||||
specifier: ^5.2.6
|
||||
version: 5.2.8
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
specifier: ^1.13.6
|
||||
version: 1.13.6
|
||||
camelcase-keys:
|
||||
specifier: ^9.1.3
|
||||
version: 9.1.3
|
||||
@@ -125,8 +127,8 @@ importers:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.8(date-fns@2.21.1)
|
||||
dompurify:
|
||||
specifier: 3.2.4
|
||||
version: 3.2.4
|
||||
specifier: 3.3.2
|
||||
version: 3.3.2
|
||||
flag-icons:
|
||||
specifier: ^7.2.3
|
||||
version: 7.2.3
|
||||
@@ -218,8 +220,8 @@ importers:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(vue@3.5.12(typescript@5.6.2))
|
||||
vue-dompurify-html:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0(vue@3.5.12(typescript@5.6.2))
|
||||
vue-i18n:
|
||||
specifier: 9.14.5
|
||||
version: 9.14.5(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -249,8 +251,8 @@ importers:
|
||||
version: 7.8.6
|
||||
devDependencies:
|
||||
'@egoist/tailwindcss-icons':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(tailwindcss@3.4.13)
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2(tailwindcss@3.4.19)
|
||||
'@histoire/plugin-vue':
|
||||
specifier: 0.17.15
|
||||
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -342,8 +344,8 @@ importers:
|
||||
specifier: ^8.2.4
|
||||
version: 8.2.6
|
||||
tailwindcss:
|
||||
specifier: ^3.4.13
|
||||
version: 3.4.13
|
||||
specifier: ^3.4.19
|
||||
version: 3.4.19
|
||||
vite:
|
||||
specifier: 5.4.21
|
||||
version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
@@ -402,9 +404,6 @@ packages:
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
'@antfu/utils@8.1.1':
|
||||
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||
|
||||
'@asamuzakjp/css-color@4.1.0':
|
||||
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
|
||||
|
||||
@@ -454,8 +453,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.6':
|
||||
resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==}
|
||||
'@chatwoot/prosemirror-schema@1.3.7':
|
||||
resolution: {integrity: sha512-N+Gicecp18TSEJQoRtGZXkp8R+kC0iPSms8ezu1k8U+ySY9FAENzFQQ1rBVSSC4hDFwb9/EbSI9IFqDjHGds7g==}
|
||||
|
||||
'@chatwoot/utils@0.0.52':
|
||||
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
|
||||
@@ -700,8 +699,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss-selector-parser: ^6.0.10
|
||||
|
||||
'@egoist/tailwindcss-icons@1.9.0':
|
||||
resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
|
||||
'@egoist/tailwindcss-icons@1.9.2':
|
||||
resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==}
|
||||
peerDependencies:
|
||||
tailwindcss: '*'
|
||||
|
||||
@@ -874,46 +873,35 @@ packages:
|
||||
'@floating-ui/utils@0.2.7':
|
||||
resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==}
|
||||
|
||||
'@formkit/core@1.6.7':
|
||||
resolution: {integrity: sha512-wEoWK7crcCPRV5KJfEGLjjIS+qwbuD8I5Ur0zTtKRQrdO4oRL6kVoubxQOpgnq1l8sWfcRY8Wpf22Wna2LD20Q==}
|
||||
'@formkit/core@1.7.2':
|
||||
resolution: {integrity: sha512-XDRVqkDtOziU3z44hdvgWEqGpiv6nTNeCmP8cnESKkr24a4keQuEwmfDVvibYV0ywyTMg6ylp2lyklCYeRHykQ==}
|
||||
|
||||
'@formkit/dev@1.6.7':
|
||||
resolution: {integrity: sha512-mMtkfvfkl1P1v0haizUE4DadalbG9/3m0ZymmMKKb0F3pojQJFtdfohy67ZQKtmzy4bamowHyEUr+XzLbKY2EA==}
|
||||
'@formkit/dev@1.7.2':
|
||||
resolution: {integrity: sha512-W38xbbFS4h4LTV22kUC6ZbPHBmlM2lVbAz5PSYU3SPaNc4FeXp4GTe4GzLEmcS82B+5L1zbSOgGB43kWZts7wA==}
|
||||
|
||||
'@formkit/i18n@1.6.7':
|
||||
resolution: {integrity: sha512-i9Mnc2XHCm2c10fppEIxdGv+jqOaixO22iFXX3xF+AkJnxtOzV5hP4f3/TeG+BNahGUq8vj+e1y+VMnjS6duxA==}
|
||||
'@formkit/i18n@1.7.2':
|
||||
resolution: {integrity: sha512-Zs6f+rtP2j2Nnt1HkNrL85WU6rtS1l1Q0BAQjCMGQPsbrppiL7/TZ5bNIrVm0DTntLgG3firDS7bNWAxPSwerQ==}
|
||||
|
||||
'@formkit/inputs@1.6.7':
|
||||
resolution: {integrity: sha512-VLxoAJn5VGOEXkGI499lju5Irnu12cu+spI1HL//46nlVkqnb1XcV0k0MK9K+hogz4fGmyZUvHf0lxU6dh7ZZQ==}
|
||||
'@formkit/inputs@1.7.2':
|
||||
resolution: {integrity: sha512-4xs7RJN8EFGctTCNRXBTvor2O8RvuEEK3q2Hl/RMU5lhhn5tmck90fkkeAr9o+rWRoSNiV8XgbLQArQ/B1IYPw==}
|
||||
|
||||
'@formkit/observer@1.6.7':
|
||||
resolution: {integrity: sha512-ei5z5ernNMKKiBuoRcFgEthhP1i+KKb02hsPsikLA3XehuoJdWIejn9AAq6jOEGbUMZ5XAAgFJcxzO5tnKuPnw==}
|
||||
'@formkit/observer@1.7.2':
|
||||
resolution: {integrity: sha512-KUr5mcu2SAeHOOK1FaMyFigtA8hkHLsUkaPFWpTC81j79o1AUjJppmfPaX6PbtlHLeMs2Zq8Qhp6VAhi7D2WJg==}
|
||||
|
||||
'@formkit/rules@1.6.7':
|
||||
resolution: {integrity: sha512-adzOuTvf6ghZbV0g0ZH9+MU9jfoF4DojBztAbqzFP/fT4d+WxhSHHlkWq6PU66fHPy3OH4DkWdx9trL1wGHuzQ==}
|
||||
'@formkit/rules@1.7.2':
|
||||
resolution: {integrity: sha512-2e5qKlXzmL9LAetncFp7xkHEX/UAJsU3bo1iL3idloH3HALf5fIdg3lgOKwCNDMdLjbfiKJ6lVwFaeFfFpcDKw==}
|
||||
|
||||
'@formkit/themes@1.6.7':
|
||||
resolution: {integrity: sha512-TIiWr4TMAFUg1pQz2E4GErfAhBv2Q2VbWlk6pqXPWI8UyPTjmcinEnCSIWDCX6FPPqiYShBnh8123nTO7pyvjA==}
|
||||
peerDependencies:
|
||||
tailwindcss: ^3.2.0
|
||||
unocss: 0.x.x
|
||||
windicss: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
tailwindcss:
|
||||
optional: true
|
||||
unocss:
|
||||
optional: true
|
||||
windicss:
|
||||
optional: true
|
||||
'@formkit/themes@1.7.2':
|
||||
resolution: {integrity: sha512-AaZHy7l9D44Ya3cQRqZ8RIpaTTsCjTB1gX13NlpqYSs6yG0yBTmJ7L7kW60bOGdhs/vmIwSel578EBwz98nybQ==}
|
||||
|
||||
'@formkit/utils@1.6.7':
|
||||
resolution: {integrity: sha512-aU3CDLzCkC5Dnx6iS3swbsIbys7E+2VOaLWFRnS7wk7kFa8EnENi67qc2E2KFE05RT4UCEAIYMAQY6wvek29gA==}
|
||||
'@formkit/utils@1.7.2':
|
||||
resolution: {integrity: sha512-bBF6alUBOqFfJHjVB95Vck0hp36vlw4QfFJxGfTO6BX68AEaFzzzabtpwfy0DbcHtwHh4Yn7l/rOWGxXEve+QQ==}
|
||||
|
||||
'@formkit/validation@1.6.7':
|
||||
resolution: {integrity: sha512-4wUUG+Pz3hPeiLccYiXAzsrF7SXk28PYAeHJeBngIv9K82ieljBJpvvuCJDyA6SeSMOvmbI92TG4wx4u5cDLOw==}
|
||||
'@formkit/validation@1.7.2':
|
||||
resolution: {integrity: sha512-JJsLP7AI/++VujdwtBeUcPhCqY3FXSKZ7hajT7Ow5Feab7JLqVPjxVMFbXDgEOLjq0ex1NX/I+EQHfm5sqMRsA==}
|
||||
|
||||
'@formkit/vue@1.6.7':
|
||||
resolution: {integrity: sha512-w3kjQD0lvtImyyTiy+fLaIZM/4r6sLDbpBIwke8ZA/d5orzNE96JPaloVKbH+HwCo6+z/1mclh1pW1S+7RgMcw==}
|
||||
'@formkit/vue@1.7.2':
|
||||
resolution: {integrity: sha512-jrvXl2ZhS6X5klTbP4N5zc0Dg37AjbAf8SCVoz7mfligVkmwrrF4SN3waMBcH+n9gqT+vrkOYb6bQXdvjUdnCQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.0
|
||||
|
||||
@@ -985,8 +973,8 @@ packages:
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
|
||||
'@iconify/utils@2.3.0':
|
||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||
'@iconify/utils@3.1.0':
|
||||
resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
|
||||
|
||||
'@intlify/core-base@9.14.2':
|
||||
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
|
||||
@@ -1291,10 +1279,10 @@ packages:
|
||||
peerDependencies:
|
||||
size-limit: 8.2.6
|
||||
|
||||
'@tailwindcss/typography@0.5.15':
|
||||
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
|
||||
'@tailwindcss/typography@0.5.19':
|
||||
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20'
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tanstack/table-core@8.20.5':
|
||||
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
|
||||
@@ -1579,8 +1567,8 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1712,8 +1700,8 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
axios@1.13.6:
|
||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@@ -1745,8 +1733,8 @@ packages:
|
||||
brace-expansion@1.1.11:
|
||||
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
|
||||
|
||||
brace-expansion@2.0.1:
|
||||
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -1922,9 +1910,6 @@ packages:
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
confbox@0.2.2:
|
||||
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
|
||||
|
||||
config-chain@1.1.13:
|
||||
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
||||
|
||||
@@ -2166,8 +2151,9 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.2.4:
|
||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||
dompurify@3.3.2:
|
||||
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
domutils@3.1.0:
|
||||
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
||||
@@ -2450,9 +2436,6 @@ packages:
|
||||
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
exsolve@1.0.8:
|
||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||
|
||||
extend-shallow@2.0.1:
|
||||
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2645,10 +2628,6 @@ packages:
|
||||
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
globals@15.15.0:
|
||||
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
globalthis@1.0.3:
|
||||
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2796,8 +2775,8 @@ packages:
|
||||
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immutable@4.3.7:
|
||||
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
|
||||
immutable@4.3.8:
|
||||
resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==}
|
||||
|
||||
import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
@@ -3001,6 +2980,10 @@ packages:
|
||||
resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
|
||||
hasBin: true
|
||||
|
||||
jiti@1.21.7:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
js-beautify@1.15.1:
|
||||
resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3077,9 +3060,6 @@ packages:
|
||||
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
kolorist@1.8.0:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
|
||||
launch-editor@2.9.1:
|
||||
resolution: {integrity: sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==}
|
||||
|
||||
@@ -3097,8 +3077,8 @@ packages:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lilconfig@3.1.2:
|
||||
resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==}
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
@@ -3131,10 +3111,6 @@ packages:
|
||||
lit@2.2.6:
|
||||
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
|
||||
|
||||
local-pkg@1.1.2:
|
||||
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3147,12 +3123,6 @@ packages:
|
||||
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lodash.castarray@4.4.0:
|
||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -3287,15 +3257,11 @@ packages:
|
||||
min-document@2.19.0:
|
||||
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
minimatch@9.0.1:
|
||||
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimist@1.2.8:
|
||||
@@ -3308,8 +3274,8 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
mlly@1.8.1:
|
||||
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
||||
@@ -3606,9 +3572,6 @@ packages:
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
pkg-types@2.3.0:
|
||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -3915,9 +3878,6 @@ packages:
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
querystringify@2.2.0:
|
||||
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
||||
|
||||
@@ -4269,8 +4229,8 @@ packages:
|
||||
resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
tailwindcss@3.4.13:
|
||||
resolution: {integrity: sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==}
|
||||
tailwindcss@3.4.19:
|
||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -4432,8 +4392,8 @@ packages:
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
ufo@1.6.1:
|
||||
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||
ufo@1.6.3:
|
||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||
|
||||
unbox-primitive@1.0.2:
|
||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||
@@ -4622,10 +4582,10 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-dompurify-html@5.1.0:
|
||||
resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==}
|
||||
vue-dompurify-html@5.3.0:
|
||||
resolution: {integrity: sha512-HJQGBHbfSPcb6Mu97McdKbX7TqRHZa6Ji8OCpCNyuHca5QvQZ8IiuwghFPSO8OkSQfqXPNPKFMZdCOrnGGmOSQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
vue: ^3.4.36
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
@@ -4944,8 +4904,6 @@ snapshots:
|
||||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.0.2
|
||||
|
||||
'@antfu/utils@8.1.1': {}
|
||||
|
||||
'@asamuzakjp/css-color@4.1.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
@@ -4999,7 +4957,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.6':
|
||||
'@chatwoot/prosemirror-schema@1.3.7':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.6.0
|
||||
@@ -5262,12 +5220,10 @@ snapshots:
|
||||
dependencies:
|
||||
postcss-selector-parser: 6.1.1
|
||||
|
||||
'@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.13)':
|
||||
'@egoist/tailwindcss-icons@1.9.2(tailwindcss@3.4.19)':
|
||||
dependencies:
|
||||
'@iconify/utils': 2.3.0
|
||||
tailwindcss: 3.4.13
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
'@iconify/utils': 3.1.0
|
||||
tailwindcss: 3.4.19
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
@@ -5354,7 +5310,7 @@ snapshots:
|
||||
ignore: 5.2.4
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -5368,7 +5324,7 @@ snapshots:
|
||||
ignore: 5.2.4
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -5385,67 +5341,61 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.7': {}
|
||||
|
||||
'@formkit/core@1.6.7':
|
||||
'@formkit/core@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/dev@1.6.7':
|
||||
'@formkit/dev@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/i18n@1.6.7':
|
||||
'@formkit/i18n@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/validation': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
'@formkit/validation': 1.7.2
|
||||
|
||||
'@formkit/inputs@1.6.7':
|
||||
'@formkit/inputs@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/observer@1.6.7':
|
||||
'@formkit/observer@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/rules@1.6.7':
|
||||
'@formkit/rules@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/validation': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
'@formkit/validation': 1.7.2
|
||||
|
||||
'@formkit/themes@1.6.7(tailwindcss@3.4.13)':
|
||||
'@formkit/themes@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
optionalDependencies:
|
||||
tailwindcss: 3.4.13
|
||||
'@formkit/core': 1.7.2
|
||||
|
||||
'@formkit/utils@1.6.7': {}
|
||||
'@formkit/utils@1.7.2': {}
|
||||
|
||||
'@formkit/validation@1.6.7':
|
||||
'@formkit/validation@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/observer': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/observer': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/vue@1.6.7(tailwindcss@3.4.13)(vue@3.5.12(typescript@5.6.2))':
|
||||
'@formkit/vue@1.7.2(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/dev': 1.6.7
|
||||
'@formkit/i18n': 1.6.7
|
||||
'@formkit/inputs': 1.6.7
|
||||
'@formkit/observer': 1.6.7
|
||||
'@formkit/rules': 1.6.7
|
||||
'@formkit/themes': 1.6.7(tailwindcss@3.4.13)
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/validation': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/dev': 1.7.2
|
||||
'@formkit/i18n': 1.7.2
|
||||
'@formkit/inputs': 1.7.2
|
||||
'@formkit/observer': 1.7.2
|
||||
'@formkit/rules': 1.7.2
|
||||
'@formkit/themes': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
'@formkit/validation': 1.7.2
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
- unocss
|
||||
- windicss
|
||||
|
||||
'@hcaptcha/vue3-hcaptcha@1.3.0(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
@@ -5511,7 +5461,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.3.5
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5549,18 +5499,11 @@ snapshots:
|
||||
|
||||
'@iconify/types@2.0.0': {}
|
||||
|
||||
'@iconify/utils@2.3.0':
|
||||
'@iconify/utils@3.1.0':
|
||||
dependencies:
|
||||
'@antfu/install-pkg': 1.1.0
|
||||
'@antfu/utils': 8.1.1
|
||||
'@iconify/types': 2.0.0
|
||||
debug: 4.4.3
|
||||
globals: 15.15.0
|
||||
kolorist: 1.8.0
|
||||
local-pkg: 1.1.2
|
||||
mlly: 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
mlly: 1.8.1
|
||||
|
||||
'@intlify/core-base@9.14.2':
|
||||
dependencies:
|
||||
@@ -5847,13 +5790,10 @@ snapshots:
|
||||
semver: 7.5.3
|
||||
size-limit: 8.2.6
|
||||
|
||||
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)':
|
||||
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)':
|
||||
dependencies:
|
||||
lodash.castarray: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.merge: 4.6.2
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 3.4.13
|
||||
tailwindcss: 3.4.19
|
||||
|
||||
'@tanstack/table-core@8.20.5': {}
|
||||
|
||||
@@ -6230,7 +6170,7 @@ snapshots:
|
||||
|
||||
acorn@8.14.0: {}
|
||||
|
||||
acorn@8.15.0: {}
|
||||
acorn@8.16.0: {}
|
||||
|
||||
activestorage@5.2.8:
|
||||
dependencies:
|
||||
@@ -6391,7 +6331,7 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.0.0
|
||||
|
||||
axios@1.13.2:
|
||||
axios@1.13.6:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
@@ -6431,7 +6371,7 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.0.1:
|
||||
brace-expansion@2.0.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -6633,8 +6573,6 @@ snapshots:
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
confbox@0.2.2: {}
|
||||
|
||||
config-chain@1.1.13:
|
||||
dependencies:
|
||||
ini: 1.3.8
|
||||
@@ -6850,7 +6788,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.2.4:
|
||||
dompurify@3.3.2:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -6877,7 +6815,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.1.1
|
||||
commander: 10.0.1
|
||||
minimatch: 9.0.1
|
||||
minimatch: 9.0.9
|
||||
semver: 7.6.3
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
@@ -7144,7 +7082,7 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.15.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.0
|
||||
@@ -7224,7 +7162,7 @@ snapshots:
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.3
|
||||
strip-ansi: 6.0.1
|
||||
@@ -7270,8 +7208,6 @@ snapshots:
|
||||
|
||||
expect-type@1.1.0: {}
|
||||
|
||||
exsolve@1.0.8: {}
|
||||
|
||||
extend-shallow@2.0.1:
|
||||
dependencies:
|
||||
is-extendable: 0.1.1
|
||||
@@ -7456,7 +7392,7 @@ snapshots:
|
||||
dependencies:
|
||||
foreground-child: 3.3.0
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.9
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.0
|
||||
path-scurry: 1.11.1
|
||||
@@ -7466,7 +7402,7 @@ snapshots:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
@@ -7487,8 +7423,6 @@ snapshots:
|
||||
|
||||
globals@15.14.0: {}
|
||||
|
||||
globals@15.15.0: {}
|
||||
|
||||
globalthis@1.0.3:
|
||||
dependencies:
|
||||
define-properties: 1.2.0
|
||||
@@ -7684,7 +7618,7 @@ snapshots:
|
||||
|
||||
ignore@6.0.2: {}
|
||||
|
||||
immutable@4.3.7:
|
||||
immutable@4.3.8:
|
||||
optional: true
|
||||
|
||||
import-fresh@3.3.0:
|
||||
@@ -7874,6 +7808,8 @@ snapshots:
|
||||
|
||||
jiti@1.21.6: {}
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
js-beautify@1.15.1:
|
||||
dependencies:
|
||||
config-chain: 1.1.13
|
||||
@@ -7990,8 +7926,6 @@ snapshots:
|
||||
|
||||
kind-of@6.0.3: {}
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
launch-editor@2.9.1:
|
||||
dependencies:
|
||||
picocolors: 1.1.0
|
||||
@@ -8008,7 +7942,7 @@ snapshots:
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
lilconfig@3.1.2: {}
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
@@ -8059,12 +7993,6 @@ snapshots:
|
||||
lit-element: 3.3.3
|
||||
lit-html: 2.8.0
|
||||
|
||||
local-pkg@1.1.2:
|
||||
dependencies:
|
||||
mlly: 1.8.0
|
||||
pkg-types: 2.3.0
|
||||
quansync: 0.2.11
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
@@ -8077,10 +8005,6 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 6.0.0
|
||||
|
||||
lodash.castarray@4.4.0: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.truncate@4.4.2: {}
|
||||
@@ -8217,17 +8141,13 @@ snapshots:
|
||||
dependencies:
|
||||
dom-walk: 0.1.2
|
||||
|
||||
minimatch@3.1.2:
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.11
|
||||
|
||||
minimatch@9.0.1:
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.1
|
||||
|
||||
minimatch@9.0.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.1
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -8235,12 +8155,12 @@ snapshots:
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.8.0:
|
||||
mlly@1.8.1:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
acorn: 8.16.0
|
||||
pathe: 2.0.3
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.1
|
||||
ufo: 1.6.3
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
dependencies:
|
||||
@@ -8511,13 +8431,7 @@ snapshots:
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
mlly: 1.8.0
|
||||
pathe: 2.0.3
|
||||
|
||||
pkg-types@2.3.0:
|
||||
dependencies:
|
||||
confbox: 0.2.2
|
||||
exsolve: 1.0.8
|
||||
mlly: 1.8.1
|
||||
pathe: 2.0.3
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
@@ -8634,7 +8548,7 @@ snapshots:
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.4.47):
|
||||
dependencies:
|
||||
lilconfig: 3.1.2
|
||||
lilconfig: 3.1.3
|
||||
yaml: 2.5.1
|
||||
optionalDependencies:
|
||||
postcss: 8.4.47
|
||||
@@ -8889,8 +8803,6 @@ snapshots:
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
querystringify@2.2.0: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
@@ -9035,7 +8947,7 @@ snapshots:
|
||||
sass@1.79.3:
|
||||
dependencies:
|
||||
chokidar: 4.0.3
|
||||
immutable: 4.3.7
|
||||
immutable: 4.3.8
|
||||
source-map-js: 1.2.1
|
||||
optional: true
|
||||
|
||||
@@ -9289,7 +9201,7 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
tailwindcss@3.4.13:
|
||||
tailwindcss@3.4.19:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
@@ -9299,12 +9211,12 @@ snapshots:
|
||||
fast-glob: 3.3.2
|
||||
glob-parent: 6.0.2
|
||||
is-glob: 4.0.3
|
||||
jiti: 1.21.6
|
||||
lilconfig: 2.1.0
|
||||
jiti: 1.21.7
|
||||
lilconfig: 3.1.3
|
||||
micromatch: 4.0.8
|
||||
normalize-path: 3.0.0
|
||||
object-hash: 3.0.0
|
||||
picocolors: 1.1.0
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.4.47
|
||||
postcss-import: 15.1.0(postcss@8.4.47)
|
||||
postcss-js: 4.0.1(postcss@8.4.47)
|
||||
@@ -9324,7 +9236,7 @@ snapshots:
|
||||
terser@5.33.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.15.0
|
||||
acorn: 8.16.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
@@ -9333,7 +9245,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 10.4.5
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.9
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
@@ -9487,7 +9399,7 @@ snapshots:
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
ufo@1.6.3: {}
|
||||
|
||||
unbox-primitive@1.0.2:
|
||||
dependencies:
|
||||
@@ -9672,9 +9584,9 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)):
|
||||
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
dompurify: 3.2.4
|
||||
dompurify: 3.3.2
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.0):
|
||||
|
||||
@@ -145,5 +145,24 @@ describe ContactIdentifyAction do
|
||||
expect(contact.phone_number).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when params have not changed' do
|
||||
it 'skips save and does not issue an UPDATE query' do
|
||||
contact.update!(name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' })
|
||||
params = { name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' } }
|
||||
|
||||
# any_instance is needed because merge lookup can reassign @contact to a different Ruby object
|
||||
expect_any_instance_of(Contact).not_to receive(:save!) # rubocop:disable RSpec/AnyInstance
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
|
||||
it 'still enqueues avatar job even when attributes have not changed' do
|
||||
contact.update!(name: 'test')
|
||||
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
|
||||
|
||||
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,7 @@ RSpec.describe 'Agents API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body.size).to eq(account.users.count)
|
||||
end
|
||||
|
||||
@@ -122,6 +123,7 @@ RSpec.describe 'Agents API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(other_agent.reload.name).to eq(params[:name])
|
||||
end
|
||||
|
||||
@@ -171,6 +173,7 @@ RSpec.describe 'Agents API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body['email']).to eq(params[:email])
|
||||
expect(account.users.last.name).to eq('NewUser')
|
||||
end
|
||||
|
||||
@@ -45,6 +45,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
response_body = response.parsed_body
|
||||
contact_emails = response_body['payload'].pluck('email')
|
||||
contact_inboxes_source_ids = response_body['payload'].flat_map { |c| c['contact_inboxes'].pluck('source_id') }
|
||||
@@ -331,6 +332,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(contact2.email)
|
||||
expect(response.body).not_to include(contact1.email)
|
||||
end
|
||||
@@ -443,6 +445,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(contact2.email)
|
||||
expect(response.body).to include(contact1.email)
|
||||
end
|
||||
@@ -497,6 +500,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(contact.name)
|
||||
end
|
||||
end
|
||||
@@ -620,6 +624,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(contact.reload.name).to eq('Test Blub')
|
||||
# custom attributes are merged properly without overwriting existing ones
|
||||
expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' })
|
||||
|
||||
@@ -31,6 +31,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.first.content).to eq(params[:content])
|
||||
end
|
||||
@@ -182,6 +183,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:contact][:id]).to eq(conversation.contact_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +27,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
body = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(body[:data][:meta][:all_count]).to eq(1)
|
||||
expect(body[:data][:meta].keys).to include(:all_count, :mine_count, :assigned_count, :unassigned_count)
|
||||
@@ -165,6 +166,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(response_data.count).to eq(2)
|
||||
end
|
||||
@@ -234,6 +236,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(conversation.display_id)
|
||||
end
|
||||
|
||||
@@ -282,6 +285,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
|
||||
end
|
||||
|
||||
@@ -342,6 +346,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(response_data[:additional_attributes]).to eq(additional_attributes)
|
||||
end
|
||||
@@ -449,9 +454,11 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { status: 'open' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
|
||||
end
|
||||
|
||||
@@ -95,6 +96,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
|
||||
end
|
||||
|
||||
@@ -383,6 +385,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include('test.com')
|
||||
end
|
||||
|
||||
@@ -478,6 +481,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(inbox.reload.enable_auto_assignment).to be_falsey
|
||||
expect(inbox.reload.portal_id).to eq(portal.id)
|
||||
expect(response.parsed_body['name']).to eq 'new test inbox'
|
||||
|
||||
@@ -22,6 +22,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body.first['id']).to eq(account.teams.first.id)
|
||||
end
|
||||
end
|
||||
@@ -45,6 +46,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body['id']).to eq(team.id)
|
||||
end
|
||||
end
|
||||
@@ -83,6 +85,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(Team.count).to eq(2)
|
||||
end
|
||||
end
|
||||
@@ -121,6 +124,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(team.reload.name).to eq('new-team')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,6 +81,29 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
|
||||
end
|
||||
|
||||
after do
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'responds 404 on requests' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||
|
||||
post api_v1_accounts_url,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
|
||||
it 'does not respond 404 on requests' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||
@@ -126,6 +149,7 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(account.name)
|
||||
expect(response.body).to include(account.locale)
|
||||
expect(response.body).to include(account.domain)
|
||||
@@ -161,22 +185,22 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}' do
|
||||
describe 'PATCH /api/v1/accounts/{account.id}' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}"
|
||||
patch "/api/v1/accounts/#{account.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthorized user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
@@ -196,11 +220,20 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
company_size: '1-10'
|
||||
}
|
||||
|
||||
it 'returns a valid schema' do
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to conform_schema(200)
|
||||
end
|
||||
|
||||
it 'modifies an account' do
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.reload.name).to eq(params[:name])
|
||||
@@ -219,19 +252,19 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
|
||||
it 'updates onboarding step to invite_team if onboarding step is present in account custom attributes' do
|
||||
account.update(custom_attributes: { onboarding_step: 'account_update' })
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to be_nil
|
||||
end
|
||||
@@ -239,10 +272,10 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
it 'Throws error 422' do
|
||||
params[:name] = 'test' * 999
|
||||
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
|
||||
@@ -21,6 +21,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['id']).to eq(agent.id)
|
||||
expect(json_response['email']).to eq(agent.email)
|
||||
@@ -50,6 +51,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
json_response = response.parsed_body
|
||||
agent.reload
|
||||
expect(json_response['id']).to eq(agent.id)
|
||||
@@ -64,6 +66,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
agent.reload
|
||||
|
||||
expect(agent.custom_attributes['phone_number']).to eq('+123456789')
|
||||
@@ -91,6 +94,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(agent.reload.valid_password?('Test1234!')).to be true
|
||||
end
|
||||
|
||||
|
||||
@@ -94,6 +94,29 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
|
||||
end
|
||||
|
||||
after do
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'responds 404 on requests' do
|
||||
params = { email: email, password: 'Password1!' }
|
||||
|
||||
post api_v2_accounts_url,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
|
||||
let(:account_builder) { double }
|
||||
let(:account) { create(:account) }
|
||||
|
||||
@@ -106,6 +106,26 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'blocks signup if config is stored as boolean false' do
|
||||
GlobalConfig.clear_cache
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
|
||||
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
set_omniauth_config('does-not-exist-for-sure@example.com')
|
||||
allow(email_validation_service).to receive(:perform).and_return(true)
|
||||
|
||||
get '/omniauth/google_oauth2/callback'
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
|
||||
follow_redirect!
|
||||
expect(response).to redirect_to(%r{/app/login\?error=no-account-found$})
|
||||
end
|
||||
ensure
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'allows login' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
create(:user, email: 'test@example.com')
|
||||
|
||||
@@ -144,6 +144,7 @@ RSpec.describe 'Platform Accounts API', type: :request do
|
||||
headers: { api_access_token: platform_app.access_token.token }, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(account.name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ RSpec.describe 'Super Admin Users API', type: :request do
|
||||
end
|
||||
|
||||
context 'when it is an authenticated super admin' do
|
||||
let!(:user) { create(:user) }
|
||||
let!(:user) { create(:user, name: 'Disabled User') }
|
||||
let!(:params) do
|
||||
{ user: {
|
||||
name: 'admin@example.com',
|
||||
@@ -23,13 +23,46 @@ RSpec.describe 'Super Admin Users API', type: :request do
|
||||
type: 'SuperAdmin'
|
||||
} }
|
||||
end
|
||||
let!(:params_without_confirmed_at) do
|
||||
{ user: {
|
||||
name: 'agent@example.com',
|
||||
display_name: 'agent@example.com',
|
||||
email: 'agent@example.com',
|
||||
password: 'Password1!',
|
||||
type: 'SuperAdmin'
|
||||
} }
|
||||
end
|
||||
let!(:params_with_blank_confirmed_at) do
|
||||
{ user: {
|
||||
name: 'agent-2@example.com',
|
||||
display_name: 'agent-2@example.com',
|
||||
email: 'agent-2@example.com',
|
||||
password: 'Password1!',
|
||||
confirmed_at: '',
|
||||
type: 'SuperAdmin'
|
||||
} }
|
||||
end
|
||||
|
||||
it 'shows the list of users' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
get '/super_admin/users'
|
||||
doc = Nokogiri::HTML(response.body)
|
||||
header_texts = doc.css('table thead th').map { |header| header.text.squish }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('New user')
|
||||
expect(response.body).to include(CGI.escapeHTML(user.name))
|
||||
expect(header_texts).not_to include('MFA')
|
||||
end
|
||||
|
||||
it 'prefills confirmed_at on new user form' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
get '/super_admin/users/new'
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('name="user[confirmed_at]"')
|
||||
confirmed_at_value = response.body[/name="user\[confirmed_at\]".*?value="([^"]+)"/m, 1]
|
||||
expect(confirmed_at_value).to be_present
|
||||
end
|
||||
|
||||
it 'creates the new super_admin record' do
|
||||
@@ -43,6 +76,24 @@ RSpec.describe 'Super Admin Users API', type: :request do
|
||||
post '/super_admin/users', params: params
|
||||
expect(response).to redirect_to('http://www.example.com/super_admin/users/new')
|
||||
end
|
||||
|
||||
it 'creates unconfirmed users when confirmed_at is not provided in payload' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
post '/super_admin/users', params: params_without_confirmed_at
|
||||
|
||||
expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}")
|
||||
expect(User.last).not_to be_confirmed
|
||||
end
|
||||
|
||||
it 'creates unconfirmed users when confirmed_at is explicitly cleared' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
post '/super_admin/users', params: params_with_blank_confirmed_at
|
||||
|
||||
expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}")
|
||||
expect(User.last).not_to be_confirmed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -100,4 +151,21 @@ RSpec.describe 'Super Admin Users API', type: :request do
|
||||
expect(mail_jobs.count).to be >= 1
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /super_admin/users/:id' do
|
||||
let!(:user) { create(:user, name: 'MFA Enabled User', otp_required_for_login: true) }
|
||||
|
||||
it 'shows the MFA status on the user detail page' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
get "/super_admin/users/#{user.id}"
|
||||
doc = Nokogiri::HTML(response.body)
|
||||
labels = doc.css('dt.attribute-label').map { |label| label.text.squish }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(labels).to include('MFA')
|
||||
expect(response.body).to include('Enabled')
|
||||
expect(response.body).to include(CGI.escapeHTML(user.name))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -259,10 +259,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
message_content: 'Hello assistant',
|
||||
message_history: [
|
||||
{ role: 'user', content: 'Previous message' },
|
||||
{ role: 'assistant', content: 'Previous response' }
|
||||
{ role: 'assistant', content: 'Previous response', agent_name: 'billing_scenario' }
|
||||
]
|
||||
}
|
||||
end
|
||||
let(:chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -274,11 +276,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'generates a response' do
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
context 'when captain v2 is disabled' do
|
||||
it 'generates a response with the legacy assistant chat service' do
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
@@ -292,14 +297,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
)
|
||||
expect(json_response[:content]).to eq('Assistant response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message_history is not provided' do
|
||||
it 'uses empty array as default' do
|
||||
params_without_history = { message_content: 'Hello assistant' }
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: params_without_history,
|
||||
@@ -313,5 +319,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain v2 is enabled' do
|
||||
before do
|
||||
account.enable_features('captain_integration_v2')
|
||||
end
|
||||
|
||||
it 'generates a response with the agent runner service' do
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(agent_runner_service)
|
||||
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
|
||||
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent_runner_service).to have_received(:generate_response).with(
|
||||
message_history: valid_params[:message_history] + [{ role: 'user', content: valid_params[:message_content] }]
|
||||
)
|
||||
expect(json_response[:response]).to eq('Assistant response')
|
||||
end
|
||||
|
||||
it 'does not duplicate the latest user message if it is already in history' do
|
||||
params_with_latest_message = {
|
||||
message_content: 'Hello assistant',
|
||||
message_history: [{ role: 'user', content: 'Hello assistant' }]
|
||||
}
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(agent_runner_service)
|
||||
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: params_with_latest_message,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent_runner_service).to have_received(:generate_response).with(
|
||||
message_history: params_with_latest_message[:message_history]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,6 +42,45 @@ RSpec.describe Captain::Scenario, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#handoff_key' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'uses id plus readable slug for persisted scenarios' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'Handle complex refund requests requiring manager approval steps')
|
||||
|
||||
expect(scenario.handoff_key).to start_with("scenario_#{scenario.id}_")
|
||||
expect(scenario.handoff_key).to end_with('_agent')
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'uses a truncated slug key for unsaved scenarios' do
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'Troubleshoot payment gateway errors for recurring subscription charges')
|
||||
|
||||
expect(scenario.handoff_key).to match(/\Ascenario_draft_[a-z0-9_]+_agent\z/)
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'stays within length budget even for large ids' do
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'A very long scenario title used only for budget verification')
|
||||
allow(scenario).to receive(:id).and_return(1_234_567_890_123_456_789)
|
||||
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'exposes handoff keys in assistant prompt context' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
|
||||
prompt_context = assistant.send(:prompt_context)
|
||||
scenario_config = prompt_context[:scenarios].find { |entry| entry[:title] == scenario.title }
|
||||
|
||||
expect(scenario_config[:key]).to eq(scenario.handoff_key)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tool validation and population' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
+22
-22
@@ -24,7 +24,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job for incoming messages on pending conversations' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,7 +43,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
|
||||
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
|
||||
@@ -52,7 +52,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
|
||||
)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
@@ -62,7 +62,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
@@ -76,7 +76,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job regardless of time' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,7 +95,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
end
|
||||
|
||||
it 'performs handoff within business hours when quota exceeded' do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
@@ -110,7 +110,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -122,7 +122,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -130,7 +130,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :outgoing)
|
||||
create(:message, conversation: conversation, message_type: :outgoing, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -144,7 +144,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { conversation.reload.messages.template.count })
|
||||
end
|
||||
|
||||
@@ -160,7 +160,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { conversation.reload.messages.template.count })
|
||||
end
|
||||
end
|
||||
@@ -174,7 +174,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
greeting_message = conversation.reload.messages.template.last
|
||||
@@ -193,7 +193,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
out_of_office_message = conversation.reload.messages.template.last
|
||||
@@ -211,7 +211,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
greeting_message = conversation.reload.messages.template.last
|
||||
@@ -230,7 +230,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
out_of_office_message = conversation.reload.messages.template.last
|
||||
@@ -245,7 +245,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job for incoming messages on pending campaign conversations' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(campaign_conversation, assistant)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
|
||||
it 'does not send greeting template on campaign conversations' do
|
||||
@@ -255,7 +255,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
end
|
||||
@@ -271,7 +271,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
@@ -284,7 +284,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
|
||||
allow(email_collect_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
|
||||
end
|
||||
@@ -304,7 +304,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { campaign_conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
@@ -332,7 +332,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
|
||||
it 'sends out of office message after handoff due to quota exceeded' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
@@ -356,7 +356,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
|
||||
it 'does not send out of office message after handoff' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
|
||||
@@ -42,7 +42,7 @@ describe OnlineStatusTracker do
|
||||
described_class.update_presence(account.id, 'Contact', online_contact.id)
|
||||
# creating a stale record for offline contact presence
|
||||
Redis::Alfred.zadd(format(Redis::Alfred::ONLINE_PRESENCE_CONTACTS, account_id: account.id),
|
||||
(Time.zone.now - (OnlineStatusTracker::PRESENCE_DURATION + 20)).to_i, offline_contact.id)
|
||||
(Time.zone.now - (OnlineStatusTracker::CONTACT_PRESENCE_DURATION + 20)).to_i, offline_contact.id)
|
||||
end
|
||||
|
||||
it 'returns only the online contact ids with presence' do
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CustomAttributeDefinition do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'validations' do
|
||||
describe 'attribute_key format' do
|
||||
it 'allows alphanumeric keys with underscores' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: 'order_date_1')
|
||||
expect(cad).to be_valid
|
||||
end
|
||||
|
||||
it 'allows hyphens and dots' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: 'order-date.v2')
|
||||
expect(cad).to be_valid
|
||||
end
|
||||
|
||||
it 'allows Unicode letters' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: '客户类型')
|
||||
expect(cad).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects keys with single quotes' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: "x'||(SELECT 1)||'")
|
||||
expect(cad).not_to be_valid
|
||||
expect(cad.errors[:attribute_key]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects keys with spaces' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: 'order date')
|
||||
expect(cad).not_to be_valid
|
||||
end
|
||||
|
||||
it 'rejects keys with semicolons' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: 'key; DROP TABLE users--')
|
||||
expect(cad).not_to be_valid
|
||||
end
|
||||
|
||||
it 'rejects keys with parentheses' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: 'key()')
|
||||
expect(cad).not_to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
describe '#strip_attribute_key' do
|
||||
it 'strips leading and trailing whitespace from attribute_key' do
|
||||
cad = create(:custom_attribute_definition, account: account, attribute_key: ' order_date ')
|
||||
expect(cad.attribute_key).to eq('order_date')
|
||||
end
|
||||
|
||||
it 'strips leading and trailing whitespace from attribute_display_name' do
|
||||
cad = create(:custom_attribute_definition, account: account, attribute_display_name: ' Order Date ')
|
||||
expect(cad.attribute_display_name).to eq('Order Date')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -75,6 +75,10 @@ RSpec.configure do |config|
|
||||
config.include ActiveSupport::Testing::TimeHelpers
|
||||
config.include ActionCable::TestHelper
|
||||
config.include ActiveJob::TestHelper
|
||||
|
||||
# OpenAPI response validation via Skooma
|
||||
path_to_openapi = Rails.root.join('swagger/swagger.json')
|
||||
config.include Skooma::RSpec[path_to_openapi], type: :request
|
||||
end
|
||||
|
||||
Shoulda::Matchers.configure do |config|
|
||||
|
||||
@@ -50,6 +50,26 @@ describe ActionService do
|
||||
action_service.assign_agent(['nil'])
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
context 'when agent is confirmed' do
|
||||
it 'assigns the agent to the conversation' do
|
||||
inbox_member
|
||||
action_service.assign_agent([agent.id])
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is unconfirmed' do
|
||||
let(:unconfirmed_agent) { create(:user, account: account, role: :agent, skip_confirmation: false) }
|
||||
let(:unconfirmed_inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: unconfirmed_agent) }
|
||||
|
||||
it 'does not assign unconfirmed agent to the conversation' do
|
||||
unconfirmed_inbox_member
|
||||
original_assignee = conversation.assignee
|
||||
action_service.assign_agent([unconfirmed_agent.id])
|
||||
expect(conversation.reload.assignee).to eq(original_assignee)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_team' do
|
||||
|
||||
@@ -49,6 +49,11 @@ describe Contacts::FilterService do
|
||||
account: account,
|
||||
attribute_model: 'contact_attribute',
|
||||
attribute_display_type: 'date')
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'lifetime_value',
|
||||
account: account,
|
||||
attribute_model: 'contact_attribute',
|
||||
attribute_display_type: 'number')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
@@ -60,7 +65,7 @@ describe Contacts::FilterService do
|
||||
|
||||
en_contact.update!(custom_attributes: { contact_additional_information: 'test custom data' })
|
||||
el_contact.update!(custom_attributes: { contact_additional_information: 'test custom data', customer_type: 'platinum' })
|
||||
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
|
||||
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19', lifetime_value: '120.50' })
|
||||
end
|
||||
|
||||
context 'with standard attributes - name' do
|
||||
@@ -272,6 +277,39 @@ describe Contacts::FilterService do
|
||||
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
|
||||
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
|
||||
end
|
||||
|
||||
it 'binds last_activity_at comparison values as dates' do
|
||||
date_value = '2024-01-01'
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: [date_value],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
service = filter_service.new(account, first_user, params)
|
||||
filters = service.instance_variable_get(:@filters)['contacts']
|
||||
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
|
||||
|
||||
expect(condition_query).to include('(contacts.last_activity_at)::date > :value_0')
|
||||
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
|
||||
end
|
||||
|
||||
it 'rejects invalid last_activity_at comparison values' do
|
||||
malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: [malicious_value],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with additional attributes' do
|
||||
@@ -369,6 +407,72 @@ describe Contacts::FilterService do
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
|
||||
end
|
||||
|
||||
it 'binds custom date comparison values as dates' do
|
||||
date_value = '2024-01-01'
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'signed_in_at',
|
||||
filter_operator: 'is_less_than',
|
||||
values: [date_value],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
service = filter_service.new(account, first_user, params)
|
||||
filters = service.instance_variable_get(:@filters)['contacts']
|
||||
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
|
||||
|
||||
expect(condition_query).to include("(contacts.custom_attributes ->> 'signed_in_at')::date < :value_0")
|
||||
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
|
||||
end
|
||||
|
||||
it 'binds custom numeric comparison values as decimals' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'lifetime_value',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: ['100.25'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
service = filter_service.new(account, first_user, params)
|
||||
filters = service.instance_variable_get(:@filters)['contacts']
|
||||
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
|
||||
|
||||
expect(condition_query).to include("(contacts.custom_attributes ->> 'lifetime_value')::numeric > :value_0")
|
||||
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(BigDecimal('100.25'))
|
||||
end
|
||||
|
||||
it 'filters by custom numeric attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'lifetime_value',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: ['100.25'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
|
||||
expect(result[:contacts].pluck(:id)).to eq([cs_contact.id])
|
||||
end
|
||||
|
||||
it 'rejects invalid custom date comparison values' do
|
||||
malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'signed_in_at',
|
||||
filter_operator: 'is_less_than',
|
||||
values: [malicious_value],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -417,6 +417,41 @@ describe Conversations::FilterService do
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
end
|
||||
|
||||
it 'binds created_at comparison values as dates' do
|
||||
date_value = '2024-01-01'
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: [date_value],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
service = filter_service.new(params, user_1, account)
|
||||
filters = service.instance_variable_get(:@filters)['conversations']
|
||||
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
|
||||
|
||||
expect(condition_query).to include('(conversations.created_at)::date > :value_0')
|
||||
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
|
||||
end
|
||||
|
||||
it 'rejects invalid created_at comparison values' do
|
||||
malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: [malicious_value],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
|
||||
end
|
||||
|
||||
it 'filter by created_at and conversation_type' do
|
||||
params[:payload] = [
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
# described class gets called in message after commit
|
||||
create(:message, conversation: conversation, message_type: 'activity', content: 'Conversation marked resolved!!')
|
||||
create(:message, conversation: conversation, account: conversation.account, message_type: 'activity', content: 'Conversation marked resolved!!')
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
|
||||
@@ -36,7 +36,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).to have_received(:new).with(conversation: message.conversation)
|
||||
@@ -54,7 +54,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
end
|
||||
@@ -75,7 +75,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(greeting_service).to have_received(:perform)
|
||||
@@ -90,7 +90,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
@@ -105,7 +105,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
@@ -123,7 +123,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
end
|
||||
@@ -139,7 +139,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
@@ -151,7 +151,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
conversation = create(:conversation, contact: contact)
|
||||
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
|
||||
|
||||
message = create(:message, conversation: conversation, content_type: :incoming_email)
|
||||
message = create(:message, conversation: conversation, account: conversation.account, content_type: :incoming_email)
|
||||
message.content_attributes = { email: { auto_reply: true } }
|
||||
message.save!
|
||||
|
||||
@@ -188,7 +188,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
@@ -202,13 +202,13 @@ describe MessageTemplates::HookExecutionService do
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
create(:message, conversation: conversation, account: conversation.account, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
@@ -221,13 +221,13 @@ describe MessageTemplates::HookExecutionService do
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
create(:message, conversation: conversation, account: conversation.account, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: conversation)
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
@@ -247,7 +247,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation, message_type: 'outgoing')
|
||||
message = create(:message, conversation: conversation, account: conversation.account, message_type: 'outgoing')
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
@@ -265,7 +265,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(false)
|
||||
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).not_to receive(:perform)
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user