const Wiki = { state: null, selectedSectionId: 'all', selectedArticleId: null, searchQuery: '', importOpen: false, editMode: false, SOURCE_URL: 'https://recycle-object.notion.site/775cbbbf4d224ea0ad565ea90feb9d3b?v=79ea9890039443acadb90e43a5cfae70&pvs=73', SOURCE_BASE_URL: 'https://recycle-object.notion.site', async load() { const root = document.getElementById('wiki-root'); if (!root) return; if (!this.state) { const raw = await loadWikiState(); this.state = this._normalizeState(raw); const migration = this._mergeImportedNotionMap(this.state, !raw); if (migration.changed) { this.state = migration.state; } if (!raw || migration.changed) { await saveWikiState(this.state); } } if (!this.selectedArticleId) { const first = this._getVisibleArticles()[0] || this.state.articles[0] || null; this.selectedArticleId = first ? first.id : null; } this.render(); }, _clone(value) { return JSON.parse(JSON.stringify(value)); }, _uid(prefix) { return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 100000)}`; }, _esc(value) { return App && App.escHtml ? App.escHtml(value) : String(value || ''); }, _nowIso() { return new Date().toISOString(); }, _currentAuthor() { return (App && App.getCurrentEmployeeName && App.getCurrentEmployeeName()) || 'Система'; }, _defaultState() { const now = this._nowIso(); const sections = [ { id: 'quick-start', title: 'Быстрый старт', description: 'Как пользоваться внутренней базой знаний и как переносить материалы из Notion.', sort_index: 10 }, { id: 'company-core', title: 'Компания и старт', description: 'Что такое RO, базовый онбординг и общая картина процессов.', sort_index: 20 }, { id: 'sales-clients', title: 'Продажи, клиенты и CRM', description: 'Маркетплейсы, интернет-магазин, ведение проекта, коммуникация и amoCRM.', sort_index: 30 }, { id: 'production-ops', title: 'Производство и операции', description: 'Производство, калькуляторы, инвентаризация, гайды и производственные FAQ.', sort_index: 40 }, { id: 'finance-docs', title: 'Финансы и документы', description: 'ФинТабло, оплаты, документы и финансовые справки.', sort_index: 50 }, { id: 'china-purchasing', title: 'Китай и закупки', description: 'Крипта, переводы, Alipay и закупки в Китае.', sort_index: 60 }, { id: 'people-hr', title: 'Команда и HR', description: 'Отпуска, вакансии, телефоны, дни рождения и внутренние HR-материалы.', sort_index: 70 }, { id: 'tools-access', title: 'Инструменты и доступы', description: 'Пароли, скрипты, бот и служебные внутренние инструменты.', sort_index: 80 }, { id: 'content-brand', title: 'Контент и визуал', description: 'Фото, иллюстрации и материалы для визуального контента.', sort_index: 90 }, { id: 'drafts', title: 'Черновики переноса', description: 'Временное место для сырого текста из Notion перед разбором.', sort_index: 100 }, ]; const articles = [ { id: 'wiki_start', section_id: 'quick-start', title: 'Как пользоваться внутренней базой знаний', summary: 'Короткая инструкция: как искать, редактировать и дополнять статьи прямо в системе.', body: [ '1. Слева выбирайте раздел, чтобы сузить тему.', '2. Сверху используйте поиск по словам, тегам и тексту статьи.', '3. Справа открывайте статью и редактируйте заголовок, краткое описание, теги и основной текст.', '4. Для переноса из Notion используйте кнопку «Импорт текста» и сначала складывайте сырой текст в раздел «Черновики переноса».', '5. Если текст уже размечен заголовками, используйте «Разбить по # заголовкам».', '6. Потом разбирайте черновик на отдельные понятные статьи по разделам.', ].join('\n'), tags: ['wiki', 'поиск', 'редактирование'], sort_index: 10, updated_at: now, updated_by: 'Система', }, { id: 'wiki_notion_sync', section_id: 'quick-start', title: 'Как перенести публичный Notion в систему', summary: 'Скелет разделов и карточек из домашней страницы Notion уже импортирован. Дальше можно дополнять статьи постепенно.', body: [ 'Что уже сделано:', '', `- Источник: ${this.SOURCE_URL}`, '- Разделы и карточки верхнего уровня перенесены в Базу знаний.', '- У каждой импортированной статьи есть ссылка на исходную страницу Notion.', '- Теперь можно спокойно разносить внутрь регламенты, чеклисты, контакты и ссылки без хаоса.', ].join('\n'), tags: ['notion', 'перенос', 'структура'], sort_index: 20, updated_at: now, updated_by: 'Система', }, { id: 'notion_migration_draft', section_id: 'drafts', title: 'Черновик переноса из Notion', summary: 'Сюда удобно складывать сырой текст из внешней базы знаний перед разбором.', body: [ 'Источник: ' + this.SOURCE_URL, '', 'Сама Notion-страница сейчас требует логин в workspace, поэтому автоперенос без доступа невозможен.', 'Используйте кнопку «Импорт текста», чтобы вставлять блоки из Notion сюда и потом раскладывать их по нормальным разделам.', ].join('\n'), tags: ['notion', 'перенос', 'черновик'], sort_index: 10, updated_at: now, updated_by: 'Система', }, ]; return { version: 1, source_url: this.SOURCE_URL, updated_at: now, updated_by: 'Система', sections, articles, }; }, _getPublicNotionSiteMap() { return [ { id: 'company_core', section_id: 'company-core', title: 'Компания и старт', description: 'Что такое RO, базовый онбординг и общая картина процессов.', links: [ ['🧩 О Recycle Object', '/Recycle-Object-c6357e76fd7d4c049879064c575e7016?pvs=25'], ], }, { id: 'sales_clients', section_id: 'sales-clients', title: 'Продажи, клиенты и CRM', description: 'Маркетплейсы, интернет-магазин, ведение проекта, коммуникация и amoCRM.', links: [ ['💻 Работа с ИМ и МП', '/4bfa8b46ec004d6f8d60dc3058550ebf?pvs=25'], ['🛒 Маркетплейсы', '/a3ab7ed0995d43e1bc3a03495f81cf95?pvs=25'], ['🤪 Ведение проекта', '/2093e666a1eb803c9155d1ba912ef87c?pvs=25'], ['📊 Работа в amoCRM', '/amoCRM-1f53e666a1eb80f79fc7f3152e8cd00a?pvs=25'], ['🤑 Инструкция по заполнению Юнит-экономики', '/2023e666a1eb80dc86f7cc262c2a287c?pvs=25'], ['💬 Скрипт для общения с клиентом', '/21f3e666a1eb80759f85db7cd585785f?pvs=25'], ['☎️ Список контактов клиентов', '/0473cddc59e449d2bc492f5fa49fcc26?pvs=25'], ['🏛️ Музеи', '/8c4a1ff29dd84161a3ba3fc352ac7a69?pvs=25'], ], }, { id: 'production_ops', section_id: 'production-ops', title: 'Производство и операции', description: 'Производство, калькуляторы, инвентаризация, гайды и производственные FAQ.', links: [ ['🔧 Производство (общая инфа, полезные ссылки, контакты поставщиков и подрядчиков)', '/adff3c8f7cb441d7a62b66a58261da4b?pvs=25'], ['🧮 Калькуляторы, прайсы, инвентаризация', '/1ad3e666a1eb80f8b40fe38ba0e91562?pvs=25'], ['ℹ️ Гайды', '/b8350468caa0486c9b3bf989584aba81?pvs=25'], ['❓ FAQ Изделия из АБС', '/FAQ-79128817511a455bb679912863573f0c?pvs=25'], ['❓ FAQ Литье', '/FAQ-be9a41c4df214c009ab824946881f1de?pvs=25'], ['❓ FAQ Дмитров', '/FAQ-5b0dce1bde9a4f5dbd40a05aaa82ee93?pvs=25'], ], }, { id: 'finance_docs', section_id: 'finance-docs', title: 'Финансы и документы', description: 'ФинТабло, оплаты, документы и финансовые справки.', links: [ ['💸 Финансы: Финтабло и Точка', '/5cca97efb6fc4bd5970865075681132e?pvs=25'], ['📄 Документы Полина', '/b10f6528052d406285451e53211ddb5f?pvs=25'], ['📄 Документы Никита', '/ce00a9838a09431a92bfc71cb6ee296b?pvs=25'], ['💵 Оплата', '/dfe635b5a8124df0b0e735f05ef46b50?pvs=25'], ], }, { id: 'china_purchasing', section_id: 'china-purchasing', title: 'Китай и закупки', description: 'Крипта, переводы, Alipay и закупки в Китае.', links: [ ['🤑 Покупка криптовалюты в BINACE', '/BINACE-1e3a5d5294ad408da2c5003347afbc10?pvs=25'], ['💸 Перевод криптовалюты c BINANCE в BYBIT', '/c-BINANCE-BYBIT-87e3238910cd45358777a0eeb7ff6522?pvs=25'], ['💱 Перевод с BYBIT на ALIPAY', '/BYBIT-ALIPAY-c80bf40bbd5c4321ad8c36395787b27d?pvs=25'], ['🈲 Alipay', '/Alipay-0be4f1a8c9ae443aa9ceb83486ccade6?pvs=25'], ['🧰 Закупки в Китае', '/22c3e666a1eb807bb68aece6bf52ca20?pvs=25'], ], }, { id: 'people_hr', section_id: 'people-hr', title: 'Команда и HR', description: 'Отпуска, вакансии, телефоны, дни рождения и внутренние HR-материалы.', links: [ ['🏖️ Отпуск и больничный', '/125dff96d7444a9f82ba598515af1e6b?pvs=25'], ['📞 Телефоны сотрудников', '/3f7cdf24579849248ff0a4a45f856756?pvs=25'], ['🎂 Дни рождения', '/73b14634d6cd463b82aa3d3266b2084c?pvs=25'], ['🏗️ Актуальные вакансии в RO', '/RO-1e03e666a1eb80009138eb52bb6db7d3?pvs=25'], ], }, { id: 'tools_access', section_id: 'tools-access', title: 'Инструменты и доступы', description: 'Пароли, скрипты, бот и служебные внутренние инструменты.', links: [ ['🤖 Бот RO', '/RO-ae30db333237459ebc779fe5e69482a9?pvs=25'], ['🔐 Пароли', '/ef027345e13f4bc8a33af6f82cb765bf?pvs=25'], ['💌 Скрипты', '/94034de2740c474b8cd1cc579f85b401?pvs=25'], ], }, { id: 'content_brand', section_id: 'content-brand', title: 'Контент и визуал', description: 'Фото, иллюстрации и материалы для визуального контента.', links: [ ['📸 Ссылки на фото', '/f84c01568654450bb55d3ce0731eae01?pvs=25'], ['ТЗ для иллюстратора', '/1e33e666a1eb8060a0a1e57a71d7538f?pvs=25'], ], }, ]; }, _stripEmoji(value) { return String(value || '') .replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '') .replace(/\s+/g, ' ') .trim(); }, _slug(value) { return this._stripEmoji(value) .toLowerCase() .replace(/[^a-zа-яё0-9]+/giu, '_') .replace(/^_+|_+$/g, '') || 'item'; }, _buildNotionUrl(path) { if (!path) return this.SOURCE_URL; if (/^https?:\/\//i.test(path)) return path; return `${this.SOURCE_BASE_URL}${path}`; }, _buildImportedArticle(sectionConfig, linkTitle, href, index) { const cleanTitle = this._stripEmoji(linkTitle); const sourceUrl = this._buildNotionUrl(href); const now = this._nowIso(); return { id: `notion_article_${sectionConfig.id}_${this._slug(cleanTitle)}`, section_id: sectionConfig.section_id, title: cleanTitle, summary: `Импортировано из публичной карты Notion. Откройте источник и перенесите сюда нормальную структурированную инструкцию.`, body: [ `# ${cleanTitle}`, '', 'Импортировано из публичной wiki Notion как стартовая карточка.', '', '## Источник', sourceUrl, '', '## Что стоит перенести сюда', '- пошаговый процесс', '- важные ссылки и доступы', '- частые ошибки и проверки', '- ответственных и обновления', ].join('\n'), tags: ['notion', 'импорт', this._slug(sectionConfig.title)], sort_index: (index + 1) * 10, updated_at: now, updated_by: 'Система', source_url: sourceUrl, }; }, _isStarterState(state) { const starterSectionIds = ['quick-start', 'company-core', 'sales-clients', 'production-ops', 'finance-docs', 'china-purchasing', 'people-hr', 'tools-access', 'content-brand', 'drafts']; const currentSectionIds = (state.sections || []).map(section => section.id).sort(); const starterArticles = ['wiki_start', 'wiki_notion_sync', 'notion_migration_draft']; const currentArticleIds = (state.articles || []).map(article => article.id); return starterSectionIds.every(id => currentSectionIds.includes(id)) && starterArticles.every(id => currentArticleIds.includes(id)) && !currentArticleIds.some(id => String(id).startsWith('notion_article_')); }, _mergeImportedNotionMap(state, forceReplace = false) { const next = this._clone(state); const notionMap = this._getPublicNotionSiteMap(); let changed = false; const shouldReplaceStarter = forceReplace || this._isStarterState(next); if (shouldReplaceStarter) { const keepArticles = (next.articles || []).filter(article => ['wiki_start', 'wiki_notion_sync', 'notion_migration_draft'].includes(article.id)); next.sections = this._defaultState().sections; next.articles = keepArticles; changed = true; } const sectionIds = new Set((next.sections || []).map(section => section.id)); notionMap.forEach((sectionConfig, sectionIndex) => { if (!sectionIds.has(sectionConfig.section_id)) { next.sections.push({ id: sectionConfig.section_id, title: sectionConfig.title, description: sectionConfig.description, sort_index: 20 + (sectionIndex * 10), }); sectionIds.add(sectionConfig.section_id); changed = true; } }); const articleIds = new Set((next.articles || []).map(article => article.id)); notionMap.forEach(sectionConfig => { sectionConfig.links.forEach((linkEntry, index) => { const [title, href] = linkEntry; const article = this._buildImportedArticle(sectionConfig, title, href, index); if (!articleIds.has(article.id)) { next.articles.push(article); articleIds.add(article.id); changed = true; } }); }); if (changed) { next.source_url = this.SOURCE_URL; next.articles.sort((a, b) => { if (a.section_id !== b.section_id) return String(a.section_id).localeCompare(String(b.section_id), 'ru'); return (Number(a.sort_index) - Number(b.sort_index)) || String(a.title).localeCompare(String(b.title), 'ru'); }); next.sections.sort((a, b) => (Number(a.sort_index) - Number(b.sort_index)) || String(a.title).localeCompare(String(b.title), 'ru')); } return { state: next, changed }; }, _normalizeState(raw) { const base = raw && typeof raw === 'object' ? this._clone(raw) : this._defaultState(); const state = { version: Number(base.version) || 1, source_url: base.source_url || this.SOURCE_URL, updated_at: base.updated_at || this._nowIso(), updated_by: base.updated_by || 'Система', sections: Array.isArray(base.sections) ? base.sections : [], articles: Array.isArray(base.articles) ? base.articles : [], }; if (!state.sections.length) { const fallback = this._defaultState(); state.sections = fallback.sections; if (!state.articles.length) state.articles = fallback.articles; } state.sections = state.sections .map((section, index) => ({ id: section.id || this._uid('section'), title: section.title || `Раздел ${index + 1}`, description: section.description || '', sort_index: Number(section.sort_index) || ((index + 1) * 10), })) .sort((a, b) => (a.sort_index - b.sort_index) || String(a.title).localeCompare(String(b.title), 'ru')); const validSectionIds = new Set(state.sections.map(section => section.id)); const fallbackSectionId = state.sections[0] ? state.sections[0].id : 'drafts'; const legacySectionAliases = { 'orders-sales': 'sales-clients', production: 'production-ops', warehouse: 'production-ops', finance: 'finance-docs', reference: 'tools-access', 'notion_home_onboarding': 'sales-clients', 'notion_home_general': 'finance-docs', 'notion_home_china': 'china-purchasing', 'notion_home_wholesale': 'sales-clients', 'notion_home_faq': 'production-ops', 'notion_home_hiring': 'people-hr', 'notion_home_illustration': 'content-brand', }; state.articles = state.articles .map((article, index) => ({ id: article.id || this._uid('article'), section_id: validSectionIds.has(article.section_id) ? article.section_id : (validSectionIds.has(legacySectionAliases[article.section_id]) ? legacySectionAliases[article.section_id] : fallbackSectionId), title: article.title || `Статья ${index + 1}`, summary: article.summary || '', body: article.body || '', source_url: article.source_url || '', tags: Array.isArray(article.tags) ? article.tags.map(tag => String(tag || '').trim()).filter(Boolean) : String(article.tags || '').split(',').map(tag => tag.trim()).filter(Boolean), sort_index: Number(article.sort_index) || ((index + 1) * 10), updated_at: article.updated_at || state.updated_at || this._nowIso(), updated_by: article.updated_by || state.updated_by || 'Система', })) .sort((a, b) => { if (a.section_id !== b.section_id) return String(a.section_id).localeCompare(String(b.section_id)); return (a.sort_index - b.sort_index) || String(a.title).localeCompare(String(b.title), 'ru'); }); return state; }, _getSection(sectionId) { return (this.state.sections || []).find(section => section.id === sectionId) || null; }, _getArticle(articleId) { return (this.state.articles || []).find(article => article.id === articleId) || null; }, _getSectionCounts() { const counts = new Map(); (this.state.articles || []).forEach(article => { counts.set(article.section_id, (counts.get(article.section_id) || 0) + 1); }); return counts; }, _matchesSearch(article) { if (!this.searchQuery) return true; const haystack = [ article.title, article.summary, article.body, (article.tags || []).join(' '), (this._getSection(article.section_id) || {}).title || '', ].join(' ').toLowerCase(); return haystack.includes(this.searchQuery.toLowerCase()); }, _getVisibleArticles() { const selectedSectionId = this.selectedSectionId; return (this.state.articles || []).filter(article => { if (selectedSectionId !== 'all' && article.section_id !== selectedSectionId) return false; return this._matchesSearch(article); }); }, _excerpt(text, max = 180) { const clean = String(text || '').replace(/\s+/g, ' ').trim(); if (clean.length <= max) return clean; return `${clean.slice(0, max).trim()}…`; }, _renderBodyPreview(text) { const lines = String(text || '').replace(/\r/g, '').split('\n'); const html = []; let listType = ''; let listItems = []; const flushList = () => { if (!listItems.length) return; const tag = listType === 'ol' ? 'ol' : 'ul'; html.push(`<${tag}>${listItems.join('')}${tag}>`); listItems = []; listType = ''; }; lines.forEach(rawLine => { const line = String(rawLine || '').trim(); if (!line) { flushList(); return; } const heading1 = line.match(/^#\s+(.+)$/); const heading2 = line.match(/^##\s+(.+)$/); const ordered = line.match(/^\d+[.)]\s+(.+)$/); const bullet = line.match(/^[-*•]\s+(.+)$/); if (heading1) { flushList(); html.push(`
${this._esc(line)}
`); }); flushList(); return html.join('') || 'Пока пусто. Здесь появится читаемый предпросмотр статьи.
'; }, _getEditorDraft(article = null) { const fallback = article || this._getArticle(this.selectedArticleId) || {}; const titleInput = document.getElementById('wiki-article-title'); const sectionSelect = document.getElementById('wiki-article-section'); const summaryInput = document.getElementById('wiki-article-summary'); const tagsInput = document.getElementById('wiki-article-tags'); const bodyInput = document.getElementById('wiki-article-body'); const tags = String(tagsInput ? tagsInput.value : ((fallback.tags || []).join(', '))) .split(',') .map(tag => tag.trim()) .filter(Boolean); return { title: String(titleInput ? titleInput.value : (fallback.title || '')).trim(), section_id: String(sectionSelect ? sectionSelect.value : (fallback.section_id || '')).trim(), summary: String(summaryInput ? summaryInput.value : (fallback.summary || '')).trim(), tags, body: String(bodyInput ? bodyInput.value : (fallback.body || '')).trim(), }; }, _renderPreviewCard(draft) { const section = this._getSection(draft.section_id); return `#, ##, маркированные и нумерованные списки для предпросмотра.