Aicontent.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. <?php
  2. namespace addons\aicontent;
  3. use think\Addons;
  4. /**
  5. * AI Content Assistant Plugin
  6. *
  7. * Integrates multiple AI providers (Claude, OpenAI, Gemini, DeepSeek, Qwen, GLM)
  8. * to generate and enrich MaCMS content: descriptions, tags, SEO titles.
  9. */
  10. class Aicontent extends Addons
  11. {
  12. public $info = [
  13. 'name' => 'aicontent',
  14. 'title' => 'AI内容助理',
  15. 'intro' => 'AI Content Assistant',
  16. 'author' => 'Yutaka Yoshi',
  17. 'version' => '1.0.0',
  18. 'state' => 1,
  19. ];
  20. /**
  21. * Called on plugin installation.
  22. * Creates the mac_ai_task database table and deploys static assets.
  23. */
  24. public function install(): bool
  25. {
  26. $sqlFile = $this->addon_path . 'install.sql';
  27. if (is_file($sqlFile)) {
  28. $sql = file_get_contents($sqlFile);
  29. $statements = array_filter(array_map('trim', explode(';', $sql)));
  30. foreach ($statements as $statement) {
  31. if ($statement) {
  32. \think\Db::execute($statement);
  33. }
  34. }
  35. }
  36. $this->deployAssets();
  37. return true;
  38. }
  39. /**
  40. * Called when the plugin is enabled.
  41. * Ensures static assets are in place.
  42. */
  43. public function enable(): bool
  44. {
  45. $this->deployAssets();
  46. return true;
  47. }
  48. /**
  49. * Return a per-session CSRF token for this plugin's POST endpoints.
  50. * Generated once and stored in the ThinkPHP session so both the page render
  51. * and subsequent API requests always see the same value.
  52. */
  53. public static function generateCsrfToken(): string
  54. {
  55. $token = session('aicontent_csrf_token');
  56. if (empty($token)) {
  57. $token = bin2hex(random_bytes(16));
  58. session('aicontent_csrf_token', $token);
  59. }
  60. return (string) $token;
  61. }
  62. /**
  63. * Copies assets/ → ROOT_PATH/static/addons/aicontent/
  64. * so that JS, CSS, and images are web-accessible.
  65. * Only copies files that are missing or older than the source.
  66. */
  67. private function deployAssets(): void
  68. {
  69. $srcBase = $this->addon_path . 'assets' . DS;
  70. $dstBase = ROOT_PATH . 'static' . DS . 'addons' . DS . 'aicontent' . DS;
  71. if (!is_dir($srcBase)) {
  72. return;
  73. }
  74. $this->copyDir($srcBase, $dstBase);
  75. }
  76. /**
  77. * Recursively copies $src directory into $dst.
  78. * Only copies files that are missing or older than the source.
  79. */
  80. private function copyDir(string $src, string $dst): void
  81. {
  82. if (!is_dir($dst)) {
  83. mkdir($dst, 0755, true);
  84. }
  85. $items = scandir($src);
  86. foreach ($items as $item) {
  87. if ($item === '.' || $item === '..') {
  88. continue;
  89. }
  90. $srcPath = $src . $item;
  91. $dstPath = $dst . $item;
  92. if (is_dir($srcPath)) {
  93. $this->copyDir($srcPath . DS, $dstPath . DS);
  94. } elseif (!is_file($dstPath) || filemtime($srcPath) > filemtime($dstPath)) {
  95. copy($srcPath, $dstPath);
  96. }
  97. }
  98. }
  99. /**
  100. * Recursively removes a directory and all its contents.
  101. */
  102. private function removeDir(string $dir): void
  103. {
  104. if (!is_dir($dir)) {
  105. return;
  106. }
  107. $items = scandir($dir);
  108. foreach ($items as $item) {
  109. if ($item === '.' || $item === '..') {
  110. continue;
  111. }
  112. $path = $dir . DS . $item;
  113. if (is_dir($path)) {
  114. $this->removeDir($path);
  115. } else {
  116. unlink($path);
  117. }
  118. }
  119. rmdir($dir);
  120. }
  121. /**
  122. * Called on plugin uninstallation.
  123. * Drops the mac_ai_task table and removes deployed static assets.
  124. */
  125. public function uninstall(): bool
  126. {
  127. \think\Db::execute('DROP TABLE IF EXISTS `mac_ai_task`');
  128. $target = ROOT_PATH . 'static' . DS . 'addons' . DS . 'aicontent';
  129. if (is_link($target)) {
  130. unlink($target);
  131. } elseif (is_dir($target)) {
  132. $this->removeDir($target);
  133. }
  134. return true;
  135. }
  136. /**
  137. * Return a JSON string of all JS-facing translations for the current locale.
  138. * Inject into pages as: <script>window.AI_LANG = <?= Aicontent::jsLangJson() ?>;</script>
  139. */
  140. public static function jsLangJson(): string
  141. {
  142. $map = [
  143. 'write_first' => 'Write something in this field first, then click AI to enhance it.',
  144. 'enhanced' => 'Enhanced!',
  145. 'enhance_failed' => 'Enhancement failed.',
  146. 'enhance_title' => 'Enhance with AI',
  147. 'enter_key_first' => 'Enter the API key first.',
  148. 'please_enter_key' => 'Please enter the API key first.',
  149. 'testing' => 'Testing...',
  150. 'generating' => 'Generating...',
  151. 'generation_failed' => 'Generation failed.',
  152. 'copied' => 'Copied!',
  153. 'failed' => 'Failed',
  154. 'seo_title_label' => 'SEO Title: ',
  155. 'description_label' => 'Description: ',
  156. 'tags_label' => 'Tags: ',
  157. 'ok' => '✓ OK',
  158. 'fail' => '✗ Fail',
  159. 'connected' => '✓ Connected',
  160. 'delete_confirm' => 'Delete this task record?',
  161. 'generate_btn' => 'Generate Content',
  162. ];
  163. $result = [];
  164. foreach ($map as $jsKey => $langKey) {
  165. $result[$jsKey] = lang($langKey);
  166. }
  167. return json_encode($result, JSON_UNESCAPED_UNICODE);
  168. }
  169. /**
  170. * Hook: app_init
  171. * Registers the plugin's service namespace so ThinkPHP's autoloader
  172. * can find classes under addons\aicontent\service\*.
  173. */
  174. public function appInit(): void
  175. {
  176. // Only deploy assets when the target directory is missing (first boot or
  177. // manual deletion). install() and enable() handle the normal deploy path;
  178. // running a full scandir+filemtime pass on every request is wasteful.
  179. $dstBase = ROOT_PATH . 'static' . DS . 'addons' . DS . 'aicontent' . DS;
  180. if (!is_dir($dstBase)) {
  181. $this->deployAssets();
  182. }
  183. // Load plugin translations into ThinkPHP's lang pool so that lang('key')
  184. // and {:lang('key')} in templates return the correct locale strings.
  185. // ThinkPHP 5.0 does not expose getLangSet(); read the cookie that MaCMS
  186. // sets when the admin switches language, then fall back to the app config.
  187. $locale = \think\Cookie::get('think_lang')
  188. ?: \think\Config::get('default_lang')
  189. ?: 'en';
  190. $langFile = ADDON_PATH . 'aicontent' . DS . 'lang' . DS . $locale . '.php';
  191. if (!is_file($langFile)) {
  192. // Normalize: zh-* → zh-cn, anything else → en
  193. $fallback = (strpos($locale, 'zh') === 0) ? 'zh-cn' : 'en';
  194. $langFile = ADDON_PATH . 'aicontent' . DS . 'lang' . DS . $fallback . '.php';
  195. }
  196. if (is_file($langFile)) {
  197. \think\Lang::load($langFile);
  198. }
  199. \think\Loader::addNamespace(
  200. 'addons\\aicontent\\service',
  201. ADDON_PATH . 'aicontent' . DS . 'service' . DS
  202. );
  203. // maccms disables ThinkPHP route checking (url_route_on=false) by default,
  204. // which prevents the fastadmin-addons Route::any('addons/:addon/...') from matching.
  205. // Only enable route checking when the request targets this addon, to avoid
  206. // side-effects on MaCMS's own URL resolution for all other requests.
  207. $uri = $_SERVER['REQUEST_URI'] ?? '';
  208. if (strpos($uri, 'addons/aicontent') !== false) {
  209. \think\App::route(true);
  210. }
  211. }
  212. /**
  213. * Hook: action_begin
  214. * Sets a flag when we are on a content edit page.
  215. * The actual injection is done in viewFilter() below.
  216. */
  217. public function actionBegin(&$params): void
  218. {
  219. $request = \think\Request::instance();
  220. $controller = strtolower($request->controller());
  221. $action = strtolower($request->action());
  222. $module = strtolower($request->module());
  223. if ($module !== 'admin') {
  224. return;
  225. }
  226. $editControllers = ['vod', 'art', 'topic'];
  227. $editActions = ['info'];
  228. if (in_array($controller, $editControllers) && in_array($action, $editActions)) {
  229. if (!defined('AICONTENT_INJECT')) {
  230. define('AICONTENT_INJECT', true);
  231. }
  232. }
  233. }
  234. /**
  235. * Hook: view_filter
  236. * Appends the "AI Generate" button script to the HTML output
  237. * of content edit pages.
  238. * Per the MaCMS plugin docs, view_filter receives the rendered HTML
  239. * by reference and this method modifies it directly.
  240. */
  241. public function viewFilter(string &$content): void
  242. {
  243. // ── CSRF token ────────────────────────────────────────────────────────
  244. // Inject only on pages that actually interact with this plugin's API:
  245. // - Content edit pages (vod/art/topic info) — flagged by AICONTENT_INJECT
  246. // - Addon controller pages (config, generate, index, addon list)
  247. $req = \think\Request::instance();
  248. $needsToken = defined('AICONTENT_INJECT')
  249. || (strtolower($req->module()) === 'admin'
  250. && strtolower($req->controller()) === 'addon');
  251. if ($needsToken && strpos($content, '</body>') !== false) {
  252. $token = self::generateCsrfToken();
  253. $content = str_replace(
  254. '</body>',
  255. "<script>window.AI_CSRF_TOKEN='{$token}';</script></body>",
  256. $content
  257. );
  258. }
  259. // ── Addon list page ───────────────────────────────────────────────────
  260. // Fix logo image URL: info.ini uses /static/addons/... which is root-relative,
  261. // but MaCMS may be installed in a subdirectory. Prepend ROOT_PATH in JS.
  262. // Also compact the addon-card action buttons so English labels don't wrap.
  263. $req = \think\Request::instance();
  264. if (strtolower($req->controller()) === 'addon'
  265. && strtolower($req->action()) === 'index') {
  266. $fix = <<<'JS'
  267. <style>
  268. /* Compact addon-card action buttons so English labels fit in one row */
  269. .add-btn { display:flex !important; flex-wrap:nowrap !important; gap:3px !important; }
  270. .add-btn > a,
  271. .add-btn > button,
  272. .add-btn > span { flex:1 !important; padding-left:4px !important; padding-right:4px !important;
  273. text-align:center !important; min-width:0 !important;
  274. font-size:12px !important; white-space:nowrap !important;
  275. overflow:hidden !important; text-overflow:ellipsis !important; }
  276. </style>
  277. <script>
  278. (function () {
  279. function fixAddonImages() {
  280. document.querySelectorAll('img.add-logo').forEach(function (img) {
  281. var src = img.getAttribute('src');
  282. if (src && src.indexOf('/static/addons/') === 0) {
  283. img.src = ROOT_PATH + src;
  284. }
  285. });
  286. }
  287. // Compact button groups that MaCMS may render with class names other than .add-btn
  288. function fixButtonLayout() {
  289. var selectors = [
  290. '.add-btn',
  291. '.layui-card-body .layui-btn-group',
  292. '.addons-item .operate',
  293. '.addons-item [class*="btn-group"]',
  294. ];
  295. selectors.forEach(function (sel) {
  296. document.querySelectorAll(sel).forEach(function (group) {
  297. group.style.cssText += ';display:flex!important;flex-wrap:nowrap!important;gap:3px!important;';
  298. group.querySelectorAll('a,button,span').forEach(function (btn) {
  299. if (btn.classList && (btn.className.indexOf('btn') !== -1 || btn.tagName === 'A')) {
  300. btn.style.cssText += ';flex:1!important;padding:0 4px!important;text-align:center!important;min-width:0!important;font-size:12px!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;';
  301. }
  302. });
  303. });
  304. });
  305. }
  306. var observer = new MutationObserver(function () {
  307. fixAddonImages();
  308. fixButtonLayout();
  309. });
  310. observer.observe(document.body, { childList: true, subtree: true });
  311. setTimeout(function () { fixAddonImages(); fixButtonLayout(); }, 300);
  312. setTimeout(function () { fixAddonImages(); fixButtonLayout(); }, 1200);
  313. })();
  314. </script>
  315. JS;
  316. $content = str_replace('</body>', $fix . '</body>', $content);
  317. return;
  318. }
  319. // ── Config page detection ─────────────────────────────────────────────
  320. // Primary: check for id="ai-provider-select" injected via config.php extend.
  321. // Fallback: detect by URL (controller=addon, action=config, name=aicontent)
  322. // in case MaCMS does not forward the extend attribute to the rendered <select>.
  323. $isOurConfigPage = strpos($content, 'id="ai-provider-select"') !== false;
  324. if (!$isOurConfigPage) {
  325. $req = \think\Request::instance();
  326. if (strtolower($req->controller()) === 'addon'
  327. && strtolower($req->action()) === 'config'
  328. && strtolower($req->param('name', '')) === 'aicontent') {
  329. $isOurConfigPage = true;
  330. }
  331. }
  332. if ($isOurConfigPage) {
  333. $this->injectConfigPageJs($content);
  334. return;
  335. }
  336. // ── Content edit pages ────────────────────────────────────────────────
  337. if (!defined('AICONTENT_INJECT')) {
  338. return;
  339. }
  340. // Guard: skip if the addon is disabled (state=0).
  341. // maccms disable() may leave hooks registered while setting state=0.
  342. $addonInfo = get_addon_info('aicontent');
  343. if (empty($addonInfo) || (int)($addonInfo['state'] ?? 0) !== 1) {
  344. return;
  345. }
  346. // Guard: skip if no API key is configured for the active provider.
  347. $cfg = get_addon_config('aicontent');
  348. $provider = $cfg['default_provider'] ?? 'claude';
  349. if (empty($cfg[$provider . '_key'])) {
  350. return;
  351. }
  352. $request = \think\Request::instance();
  353. $controller = strtolower($request->controller());
  354. $isArticle = ($controller === 'art');
  355. $contentType = $isArticle ? 'article' : 'video';
  356. $titleField = $isArticle ? 'art_name' : 'vod_name';
  357. // The enhance URL must go through index.php (ROOT_PATH), NOT manage.php (ADMIN_PATH).
  358. // manage.php triggers Begin.php behavior which redirects any non-admin module → 302.
  359. // index.php (ENTRANCE='index') skips that check; the addon route resolves correctly.
  360. // Fields to skip (non-content technical fields)
  361. $skipPatterns = ['pic', 'img', 'thumb', 'screenshot', 'poster', 'url',
  362. 'play', 'down', 'from', 'server', 'color', 'letter',
  363. 'en_', '_en', 'sub', 'rel_', 'class', 'note', 'remarks'];
  364. $btn = '<button type="button" class="layui-btn layui-btn-xs layui-btn-normal ai-enhance-btn" '
  365. . 'style="margin-top:4px;display:inline-block" '
  366. . 'data-target="%s">&#10024; AI</button>';
  367. // Inject button after every eligible textarea
  368. $content = preg_replace_callback(
  369. '/<textarea([^>]*)>(.*?)<\/textarea>/s',
  370. function ($m) use ($skipPatterns, $btn) {
  371. $attrs = $m[1];
  372. preg_match('/name="([^"]*)"/', $attrs, $nm);
  373. preg_match('/id="([^"]*)"/', $attrs, $im);
  374. $name = isset($nm[1]) ? $nm[1] : '';
  375. $id = isset($im[1]) ? $im[1] : '';
  376. $check = strtolower($name . ' ' . $id);
  377. foreach ($skipPatterns as $p) {
  378. if (strpos($check, $p) !== false) return $m[0];
  379. }
  380. $target = $id ?: $name;
  381. return $m[0] . sprintf($btn, htmlspecialchars($target));
  382. },
  383. $content
  384. );
  385. // Inject button after every eligible text input
  386. $content = preg_replace_callback(
  387. '/<input([^>]+type="text"[^>]*)>/i',
  388. function ($m) use ($skipPatterns, $btn) {
  389. $attrs = $m[1];
  390. preg_match('/name="([^"]*)"/', $attrs, $nm);
  391. preg_match('/id="([^"]*)"/', $attrs, $im);
  392. $name = isset($nm[1]) ? $nm[1] : '';
  393. $id = isset($im[1]) ? $im[1] : '';
  394. $check = strtolower($name . ' ' . $id);
  395. foreach ($skipPatterns as $p) {
  396. if (strpos($check, $p) !== false) return $m[0];
  397. }
  398. $target = $id ?: $name;
  399. return $m[0] . sprintf($btn, htmlspecialchars($target));
  400. },
  401. $content
  402. );
  403. // Use ROOT_PATH and ADMIN_PATH JS globals (set by maccms admin head template)
  404. $jsLang = self::jsLangJson();
  405. $script = <<<JS
  406. <script>
  407. window.AI_LANG = {$jsLang};
  408. (function () {
  409. var s = document.createElement('script');
  410. s.src = ROOT_PATH + '/static/addons/aicontent/js/aicontent.js';
  411. s.onload = function () {
  412. AiContent.initEnhance({
  413. enhanceUrl : ROOT_PATH + '/index.php/addons/aicontent/api/enhance',
  414. titleField : '{$titleField}',
  415. contentType : '{$contentType}'
  416. });
  417. };
  418. document.head.appendChild(s);
  419. })();
  420. </script>
  421. JS;
  422. $content = str_replace('</body>', $script . '</body>', $content);
  423. }
  424. /**
  425. * Inject provider→model auto-fill and API key show/hide JS into the
  426. * maccms generic addon config page.
  427. * Called by viewFilter() when the current page is admin/addon/config?name=aicontent.
  428. */
  429. private function injectConfigPageJs(string &$content): void
  430. {
  431. $jsLang = self::jsLangJson();
  432. $script = "<script>window.AI_LANG = {$jsLang};</script>\n" . <<<'JS'
  433. <script>
  434. (function () {
  435. var AI_MODELS = {
  436. claude: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001'],
  437. openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
  438. gemini: ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
  439. deepseek: ['deepseek-chat', 'deepseek-reasoner'],
  440. qwen: ['qwen-plus', 'qwen-turbo', 'qwen-max', 'qwen-long'],
  441. glm: ['glm-4', 'glm-4-flash', 'glm-3-turbo']
  442. };
  443. var KEY_PROVIDERS = {
  444. 'claude_key': 'claude',
  445. 'openai_key': 'openai',
  446. 'gemini_key': 'gemini',
  447. 'deepseek_key': 'deepseek',
  448. 'qwen_key': 'qwen',
  449. 'glm_key': 'glm'
  450. };
  451. // Walk up the DOM tree to find the form row element
  452. function findRow(el) {
  453. var p = el;
  454. while (p && p !== document.body) {
  455. if (p.classList &&
  456. (p.classList.contains('layui-form-item') || p.tagName === 'TR')) {
  457. return p;
  458. }
  459. p = p.parentElement;
  460. }
  461. return el.parentElement;
  462. }
  463. // Replace the plain text input for model with a proper <select> dropdown
  464. function buildModelSelect(provider, savedModel) {
  465. var modelInput = document.querySelector('input[name="row[default_model]"]');
  466. if (!modelInput) return;
  467. var sel = document.createElement('select');
  468. sel.id = 'ai-cfg-model-sel';
  469. sel.name = 'row[default_model]';
  470. sel.className = modelInput.className;
  471. sel.setAttribute('lay-filter', 'ai-cfg-model');
  472. (AI_MODELS[provider] || []).forEach(function (m) {
  473. var opt = document.createElement('option');
  474. opt.value = m;
  475. opt.textContent = m;
  476. if (m === savedModel) opt.selected = true;
  477. sel.appendChild(opt);
  478. });
  479. modelInput.parentNode.replaceChild(sel, modelInput);
  480. // Ask Layui to style the new select (if available)
  481. if (window.layui) {
  482. layui.use('form', function () { layui.form.render('select'); });
  483. }
  484. }
  485. // Update the model dropdown options when provider changes
  486. function updateModelOptions(provider) {
  487. var sel = document.getElementById('ai-cfg-model-sel');
  488. if (!sel) return;
  489. var models = AI_MODELS[provider] || [];
  490. sel.innerHTML = '';
  491. models.forEach(function (m) {
  492. var opt = document.createElement('option');
  493. opt.value = m;
  494. opt.textContent = m;
  495. sel.appendChild(opt);
  496. });
  497. // Re-render Layui styled select to reflect updated options
  498. if (window.layui) {
  499. layui.use('form', function () { layui.form.render('select'); });
  500. }
  501. }
  502. // Show only the key row that belongs to the active provider; hide the rest
  503. function showKeyRow(provider) {
  504. Object.keys(KEY_PROVIDERS).forEach(function (keyName) {
  505. var input = document.querySelector('input[name="row[' + keyName + ']"]');
  506. if (!input) return;
  507. var row = findRow(input);
  508. if (row) row.style.display = (KEY_PROVIDERS[keyName] === provider) ? '' : 'none';
  509. });
  510. }
  511. function init() {
  512. var providerSel = document.querySelector('select[name="row[default_provider]"]');
  513. if (!providerSel) return;
  514. var modelInput = document.querySelector('input[name="row[default_model]"]');
  515. var savedModel = modelInput ? modelInput.value : '';
  516. // 1. Swap the text input → styled select dropdown
  517. buildModelSelect(providerSel.value, savedModel);
  518. // 2. Hide all key rows except the active provider's
  519. showKeyRow(providerSel.value);
  520. // 3. Native change listener (fires when Layui is absent or on manual DOM select change)
  521. providerSel.addEventListener('change', function () {
  522. showKeyRow(this.value);
  523. updateModelOptions(this.value);
  524. });
  525. // 4. Layui styled-select event (fires for the custom Layui dropdown UI)
  526. if (window.layui) {
  527. layui.use('form', function () {
  528. var form = layui.form;
  529. if (!providerSel.getAttribute('lay-filter')) {
  530. providerSel.setAttribute('lay-filter', 'ai-provider-select');
  531. }
  532. form.render('select');
  533. form.on('select(ai-provider-select)', function (data) {
  534. showKeyRow(data.value);
  535. updateModelOptions(data.value);
  536. });
  537. });
  538. }
  539. }
  540. if (document.readyState === 'loading') {
  541. document.addEventListener('DOMContentLoaded', function () { setTimeout(init, 400); });
  542. } else {
  543. setTimeout(init, 400);
  544. }
  545. })();
  546. </script>
  547. JS;
  548. $content = str_replace('</body>', $script . '</body>', $content);
  549. }
  550. }