diff --git a/.agents/skills/documentation-workflow/SKILL.md b/.agents/skills/documentation-workflow/SKILL.md index 60023486e..354434f1b 100644 --- a/.agents/skills/documentation-workflow/SKILL.md +++ b/.agents/skills/documentation-workflow/SKILL.md @@ -25,12 +25,46 @@ description: Use when adding or updating massCode documentation, documenting a n - Пользовательская документация сайта пишется на английском, в стиле существующих docs pages. - Добавляй или обновляй наиболее конкретную страницу в `docs/website/documentation`. - Для новых страниц используй frontmatter с `title` и `description`. -- Если фича доступна только с конкретного релиза, добавляй `` ближе к началу страницы. - Добавляй страницу в `docs/website/.vitepress/config.mts` только если она должна появиться в навигации. - Ссылайся из `docs/website/documentation/index.md` только на широкие, cross-cutting фичи. - Пиши короткими task-oriented секциями. Предпочитай пользовательские флоу, а не детали реализации. - Shortcuts документируй через `...` и указывай macOS плюс Windows/Linux варианты, если они отличаются. +## Version Availability + +- Перед добавлением или изменением `` проверяй историю фичи в коде, документации или предыдущем релизном теге. +- Считай `` минимальной версией, где появилась ровно описываемая возможность, а не версией её последнего улучшения. +- Ставь marker на уровне страницы или общего раздела только когда вся описываемая сущность впервые появилась в этой версии. +- Если новый релиз расширяет существующую фичу, сохраняй её исходный marker или отсутствие marker, а новую версию указывай только у отдельного подпункта или предложения про улучшение. +- Разделяй смешанное описание на базовую возможность и versioned enhancement, если общий marker создаёт впечатление, что старая возможность раньше была недоступна. +- Не добавляй version marker для bugfix или внутренней переработки без нового пользовательского сценария. Локально отмечай изменение формата хранения, если оно влияет на совместимость. + +Пример: если custom folder icons существуют с 3.7, а Emoji и Upload добавлены в 5.9, оставляй `>=3.7` у базовой возможности и ставь `>=5.9` только у подпункта про Emoji и Upload. + +## Documentation Weight + +- Перед созданием страницы или раздела оцени самостоятельность пользовательского сценария, количество шагов, настроек и ограничений. +- Описывай мелкое одношаговое действие одной строкой или буллетом внутри существующего релевантного раздела. +- Создавай отдельный раздел для самостоятельного workflow с несколькими шагами, вариантами, настройками или важными ограничениями. +- Выбирай одно основное место для подробного описания cross-cutting фичи. В других страницах оставляй короткое упоминание или ссылку вместо повторения полного объяснения. +- Оставляй bugfix, внутреннюю оптимизацию и implementation detail только в release notes, если они не меняют пользовательский сценарий или требования совместимости. + +## Callouts + +- Используй `warning` для риска потери данных, несовместимости, необратимого действия или существенного security-ограничения. +- Используй `info` для автоматической миграции и неочевидного поведения, которое помогает правильно понять основной workflow. +- Оставляй основные инструкции обычным текстом; callout должен выделять контекст или исключение, а не содержать весь сценарий. +- Объединяй связанные риски в один callout и избегай нескольких соседних блоков, если их можно прочитать как одно сообщение. +- Добавляй короткий предметный заголовок, например `::: warning Compatibility` или `::: info Automatic migration`. + +## VitePress Markdown Gotchas + +- VitePress компилирует markdown как Vue-компонент, поэтому `{{ ... }}` трактуется как Vue-интерполяция и **исчезает** из вывода. +- Fenced-блоки (` ``` `) защищены автоматически — внутри них `{{var}}` рендерится буквально. +- **Инлайн-код в backticks НЕ защищён**: `` `{{variables}}` `` отрендерится пустым. Чтобы вывести литеральные двойные фигурные скобки в тексте или таблице, оборачивай в `v-pre`: + `{{variables}}` +- Это же касается любых других Vue-конструкций (`{{ }}`, директивы) в произвольном тексте страницы. + ## Images And Assets Rules - Картинки для docs клади в `docs/website/public`. @@ -59,5 +93,10 @@ description: Use when adding or updating massCode documentation, documenting a n - Добавлять README-only документацию для фичи, которой нужна настоящая docs page. - Забывать VitePress sidebar для новой страницы, которая должна быть в навигации. - Добавлять version availability в README. +- Переносить `` всего существующего раздела на версию, в которой фича лишь получила улучшение. +- Создавать отдельный раздел для одношаговой мелкой фичи, которую достаточно упомянуть в существующем разделе. +- Повторять полное описание cross-cutting фичи на нескольких страницах вместо одного основного места и коротких упоминаний. +- Использовать callout для обычной инструкции без риска, совместимости или неочевидного поведения. - Документировать shortcuts или поведение без проверки реализации. - Запускать широкие formatters, которые переписывают существующий стиль docs config. +- Писать литеральные `{{ ... }}` в инлайн-коде без `v-pre` — VitePress съест их как Vue-интерполяцию. diff --git a/.agents/skills/release-notes/SKILL.md b/.agents/skills/release-notes/SKILL.md new file mode 100644 index 000000000..a3cfd7354 --- /dev/null +++ b/.agents/skills/release-notes/SKILL.md @@ -0,0 +1,93 @@ +--- +name: release-notes +description: Use when generating massCode release notes for a GitHub release. Writes the notes to a gitignored file `docs/releases/.md` for the user to paste into the GitHub release. Use when summarizing merged PRs of a version into a consistent release notes structure. +--- + +# Release Notes + +## Overview + +Этот skill генерирует release notes одного релиза `vX.Y.Z` для вставки в GitHub release. Цель — единая повторяемая структура от релиза к релизу. + +Не трогай `docs/website/download/latest-release.md` — это отдельная repo-страница со своим форматом. + +## Source Of Truth + +- Содержимое выводи из merged PR между предыдущим и новым тегом, не из памяти. +- Список изменений бери так: + `git log --first-parent --oneline ..` (например `v5.5.0..v5.6.0`). +- Если новый тег ещё не создан, используй `..HEAD` и спроси/подтверди номер новой версии. +- Поведение значимых фич проверяй в коде или в `docs/website/documentation`, прежде чем формулировать, если оно неочевидно из заголовка PR. + +## Output + +- Записывай результат в файл `docs/releases/.md` (например `docs/releases/v5.6.0.md`). Папка `docs/releases` в `.gitignore`, это рабочий буфер, а не часть репозитория. +- Пиши именно в файл, а не выводом в терминал: так пользователь копирует чистый markdown без переносов строк, которые добавляет терминал. +- После записи коротко сообщи путь к файлу и при необходимости покажи итог. Не коммить файл. + +## Format + +Файл содержит один markdown-блок в этой структуре и порядке: + +``` +## + +<1-2 абзаца: что это и что даёт пользователю.> + +## + +<1-2 абзаца.> + +## Workflow Improvements + +- <мелкое улучшение> +- <мелкое улучшение> + +## Fixes + +- <исправление> +- <исправление> + +**Full Changelog**: https://github.com/massCodeIO/massCode/compare/... +``` + +Правила структуры: + +- Начинай прямо с тематических `##`-разделов. Не добавляй заголовок версии `# vX.Y.Z` (его даёт сам GitHub release), frontmatter или `` — это артефакты repo-страницы. +- Один `##`-раздел на каждую значимую фичу или связную группу фич. +- `## Workflow Improvements` и `## Fixes` включай только если для них есть содержимое. +- Compare-ссылка всегда последней строкой, с актуальными предыдущим и новым тегами. + +## What To Include + +- User-facing изменения: новые spaces и фичи, заметные улучшения workflow, видимые исправления. +- Крупную фичу выноси в отдельный `##`-раздел с 1-2 абзацами о пользе для пользователя. +- Мелкие улучшения собирай в `## Workflow Improvements` буллетами. +- Исправления собирай в `## Fixes` буллетами, по одному на исправление. + +## What To Omit + +- Чисто внутренние коммиты: `ci`, `build`, `chore`, рефакторинг, bump зависимостей, релизные служебные коммиты (`build: release vX.Y.Z`). +- Исключение: выноси платформенные изменения, заметные пользователю (например code signing и notarization на macOS), в `## Workflow Improvements`. +- Не описывай детали реализации; пиши о результате для пользователя. + +## Writing Style + +- Английский, спокойный фактический тон. Что делает фича и зачем она пользователю, без маркетинговых превосходных степеней. +- Никаких em dash. Это общее правило репозитория. +- Имена spaces пиши консистентно: `Code`, `Notes`, `Math Notebook`, `Drawings`, `HTTP`, `Tools`. Не смешивай `Code` и `Snippets` как имя пространства. +- `snippet` — единица контента внутри пространства Code, а не имя пространства. В Fixes допустимо «snippet fences», «snippet sync», но пространство всё равно называется Code. +- Code identifiers, расширения файлов и токены оборачивай в backticks (`` `.excalidraw` ``, `` `@` ``, `` `Esc` ``). +- Shortcuts указывай как в интерфейсе; macOS и Windows/Linux варианты различай, если они отличаются. + +## Common Mistakes + +- Перечислять коммиты дословно вместо группировки в user-facing разделы. +- Включать `ci` / `build` / `chore` шум, который не виден пользователю. +- Добавлять `# vX.Y.Z`, frontmatter или `` (это формат repo-страницы, а не GitHub release). +- Выводить нотис простыней в терминал вместо файла `docs/releases/.md` (терминал ломает переносы строк при копировании). +- Коммитить файл из `docs/releases` — это игнорируемый рабочий буфер. +- Смешивать имена пространств (`Code` против `Snippets`) внутри одного документа. +- Описывать детали реализации вместо пользы для пользователя. +- Забыть обновить compare-ссылку на актуальные теги. +- Использовать em dash вопреки правилу репозитория. diff --git a/.claude/skills/release-notes b/.claude/skills/release-notes new file mode 120000 index 000000000..e51ba69b9 --- /dev/null +++ b/.claude/skills/release-notes @@ -0,0 +1 @@ +../../.agents/skills/release-notes \ No newline at end of file diff --git a/.github/workflows/build-sponsored.yml b/.github/workflows/build-sponsored.yml deleted file mode 100644 index fb05aed36..000000000 --- a/.github/workflows/build-sponsored.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Build Sponsored - -on: - workflow_dispatch: - -jobs: - build-sponsored: - name: Build Sponsored for ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: macos-15-intel - platform: mac - arch: x64 - - os: macos-15 - platform: mac - arch: arm64 - - os: windows-latest - platform: win - - os: ubuntu-latest - platform: linux - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: latest - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.14.1 - cache: pnpm - - - name: Install dependencies - run: pnpm install - - - name: Rebuild native dependencies (macOS) - if: matrix.platform == 'mac' - run: pnpm run rebuild --arch ${{ matrix.arch }} - - - name: Rebuild native dependencies (other platforms) - if: matrix.platform != 'mac' - run: pnpm run rebuild - - - name: Build application (sponsored macOS) - if: matrix.platform == 'mac' - run: pnpm run build:sponsored:${{ matrix.platform }}:${{ matrix.arch }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VITE_SPONSORED: true - - - name: Build application (sponsored other platforms) - if: matrix.platform != 'mac' - run: pnpm run build:sponsored:${{ matrix.platform }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VITE_SPONSORED: true - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: sponsored-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.ref_name }} - path: | - dist/*.dmg - dist/*.pkg - dist/*.exe - dist/*.msi - dist/*.AppImage - dist/*.snap - if-no-files-found: warn - retention-days: 30 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 3119e327e..3e71a76dc 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -22,10 +22,22 @@ jobs: with: node-version: 24.14.1 cache: pnpm - cache-dependency-path: docs/website/pnpm-lock.yaml + cache-dependency-path: | + docs/website/pnpm-lock.yaml + integrations/clipper/pnpm-lock.yaml - name: Install dependencies - run: pnpm --dir docs/website install + run: | + pnpm --dir docs/website install + pnpm --dir integrations/clipper install + + - name: Build clipper downloads + run: | + pnpm --dir integrations/clipper package + mkdir -p docs/website/public/clipper/downloads + cp integrations/clipper/builds/*-chrome.zip docs/website/public/clipper/downloads/clipper-chrome.zip + cp integrations/clipper/builds/*-firefox.zip docs/website/public/clipper/downloads/clipper-firefox.zip + cp integrations/clipper/builds/*-safari.zip docs/website/public/clipper/downloads/clipper-safari.zip - name: Build docs run: pnpm docs:build diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml index d80cc4495..c85bbda60 100644 --- a/.github/workflows/issue-close-require.yml +++ b/.github/workflows/issue-close-require.yml @@ -4,14 +4,36 @@ on: schedule: - cron: '0 0 * * *' +permissions: + contents: read + issues: write + jobs: close-issues: runs-on: ubuntu-latest steps: - name: need reproduction - uses: actions-cool/issues-helper@v3 - with: - actions: close-issues - token: ${{ secrets.GITHUB_TOKEN }} - labels: need reproduction - inactive-day: 3 + env: + GH_TOKEN: ${{ github.token }} + INACTIVE_DAYS: 3 + LABEL: need reproduction + REPO: ${{ github.repository }} + run: | + set -o pipefail + + cutoff="$(date -u -d "$INACTIVE_DAYS days ago" +%s)" + + gh issue list \ + --repo "$REPO" \ + --state open \ + --label "$LABEL" \ + --limit 1000 \ + --json number,updatedAt \ + --jq '.[] | [.number, .updatedAt] | @tsv' \ + | while IFS=$'\t' read -r number updated_at; do + updated_at_epoch="$(date -u -d "$updated_at" +%s)" + + if [ "$updated_at_epoch" -le "$cutoff" ]; then + gh issue close "$number" --repo "$REPO" + fi + done diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml index 43512f759..3b265d4bf 100644 --- a/.github/workflows/issue-labeled.yml +++ b/.github/workflows/issue-labeled.yml @@ -4,18 +4,23 @@ on: issues: types: [labeled] +permissions: + contents: read + issues: write + jobs: reply-labeled: runs-on: ubuntu-latest steps: - - name: need reproduction if: github.event.label.name == 'need reproduction' - uses: actions-cool/issues-helper@v3 - with: - actions: 'create-comment, remove-labels' - token: ${{ secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Hello @${{ github.event.issue.user.login }}. Please describe in detail the sequence of actions that leads to the bug (skip it if it's already there). Add screenshots of errors from the console. If possible add a video. Issues marked with `need reproduction` will be closed if they have no activity within 3 days. - labels: pending triage + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + REPO: ${{ github.repository }} + run: | + body="Hello @${ISSUE_AUTHOR}. Please describe in detail the sequence of actions that leads to the bug (skip it if it's already there). Add screenshots of errors from the console. If possible add a video. Issues marked with \`need reproduction\` will be closed if they have no activity within 3 days." + + gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$body" + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label "pending triage" || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 906319cce..3c1231371 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,16 +15,20 @@ jobs: build: name: Build for ${{ matrix.os }} runs-on: ${{ matrix.os }} + env: + NODE_OPTIONS: --max-old-space-size=4096 strategy: matrix: include: - - os: macos-15-intel - platform: mac - arch: x64 + # Обе архитектуры macOS собираются одной джобой: раздельные джобы + # затирали latest-mac.yml друг друга, ломая автообновление. - os: macos-15 platform: mac - arch: arm64 - - os: windows-latest + # Закреплено на windows-2022 (VS 2022): на windows-latest приехал + # VS 2026 (v18), который приколоченный форк @electron/node-gyp не + # распознаёт ("version undefined") и падает при rebuild нативных + # модулей. Снять после апдейта electron-rebuild/node-gyp. + - os: windows-2022 platform: win - os: ubuntu-latest platform: linux @@ -38,7 +42,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: latest + version: 10.29.2 - name: Setup Node.js uses: actions/setup-node@v4 @@ -46,22 +50,30 @@ jobs: node-version: 24.14.1 cache: pnpm + # node-gyp внутри electron-builder требует Python с distutils (< 3.12) + # для кросс-архитектурной пересборки нативных модулей. + - name: Setup Python for node-gyp (macOS) + if: matrix.platform == 'mac' + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies run: pnpm install - - name: Rebuild native dependencies (macOS) - if: matrix.platform == 'mac' - run: pnpm run rebuild --arch ${{ matrix.arch }} - - - name: Rebuild native dependencies (other platforms) - if: matrix.platform != 'mac' + - name: Rebuild native dependencies run: pnpm run rebuild - name: Build application (macOS) if: matrix.platform == 'mac' - run: pnpm run build:${{ matrix.platform }}:${{ matrix.arch }} + run: pnpm run build:mac env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Build application (other platforms) if: matrix.platform != 'mac' @@ -72,15 +84,15 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: masscode-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.ref_name || inputs.tag }} + name: masscode-${{ matrix.platform }}-${{ github.ref_name || inputs.tag }} path: | dist/*.dmg + dist/*.zip dist/*.pkg dist/*.exe dist/*.msi dist/*.AppImage dist/*.snap - !dist/*-sponsored.* !dist/*.yml !dist/*.blockmap if-no-files-found: warn diff --git a/.gitignore b/.gitignore index c197b2a6f..2d06a04e3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ build/renderer build/shared build/src build/package.json -scripts/build-sponsored.sh node_modules .DS_Store @@ -13,6 +12,9 @@ auto-imports.d.ts .github/*-instructions.md .env .env.* +electron-builder.env docs/superpowers +docs/releases docs/website/.vitepress/cache docs/website/.vitepress/dist +docs/strategy diff --git a/AGENTS.md b/AGENTS.md index 4add61ab4..b5d134c33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ massCode — это приложение на Electron + Vue 3 + TypeScript с T Используй для правил локализации, размещения locale keys, `i18n.t(...)` и требования не хардкодить строки. - `.agents/skills/documentation-workflow/SKILL.md` Используй для добавления или обновления документации, страниц `docs/website/documentation`, sidebar, assets и README-упоминаний фич. +- `.agents/skills/release-notes/SKILL.md` + Используй для генерации release notes нового релиза в игнорируемый файл `docs/releases/.md` для вставки в GitHub release: группировка merged PR в user-facing разделы и compare-ссылка. - `.agents/skills/development-workflow/SKILL.md` Используй для repo-specific workflow rules: scoped lint/test команды и обязательные follow-up шаги после изменений source-of-truth файлов. - `.agents/skills/github-workflow/SKILL.md` @@ -48,6 +50,7 @@ massCode — это приложение на Electron + Vue 3 + TypeScript с T - Spaces задача: `architecture-standards` → `spaces-architecture`. - Задача про текст и локализацию: `i18n`. - Задача про документацию или README: `documentation-workflow`. +- Задача про release notes нового релиза: `release-notes`. - Workflow-чувствительная задача: `development-workflow`. - Задача про git / branch / commit / PR: `github-workflow`. diff --git a/README.md b/README.md index 6d4478cdf..e2e50859f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A free, open-source developer workspace.

- Snippets, notes, HTTP requests, calculations, and dev tools in one local-first app. + Snippets, notes, HTTP requests, calculations, drawings, and dev tools in one local-first app.
Your data stays on your machine as plain Markdown files.

@@ -75,7 +75,7 @@ massCode is free and open source. But building and maintaining a quality tool ta ## About -Originally a snippet manager, massCode now brings together snippets, notes, HTTP requests, calculations, and developer tools in one desktop app, so everyday work stays in one place. +Originally a snippet manager, massCode now brings together snippets, notes, HTTP requests, calculations, drawings, and developer tools in one desktop app, so everyday work stays in one place. ## Features @@ -93,8 +93,11 @@ Use massCode as a focused snippet workspace with multi-level folders, tags, and Write longer markdown documents right next to your snippets: project docs, drafts, research notes, and personal knowledge bases. +- Task notes with status, priority, due dates, and Today / Upcoming / Completed views +- Completed task cleanup and auto-clean scheduling - Dashboard with activity overview, recent notes, top linked notes, and a notes graph preview - Editor, Live Preview, and Preview modes +- Editable markdown tables with row and column controls - Fullscreen notes graph for exploring internal links between notes - Integrated [Mermaid](https://mermaid-js.github.io/mermaid/#) diagrams - Mind maps generated from markdown heading structure @@ -121,12 +124,21 @@ A calculator-style notebook for natural-language calculations, conversions, and - Variables, functions, and aggregates for quick iterative calculations - Finance calculations (compound interest, ROI, loan repayment) +### Drawings + +Sketch diagrams, wireframes, and whiteboard ideas on an [Excalidraw](https://excalidraw.com)-powered canvas next to your snippets and notes. + +- Searchable list of drawings with keyboard navigation +- Pan and zoom remembered per drawing, with fit-to-content to recenter +- Export drawings as images +- Embed drawings directly in your markdown notes + ### Developer Tools Built-in utilities for the small tasks that usually send you to a browser tab: - **Compare:** JSON Diff -- **Text:** Case Converter, Slug Generator, URL Parser +- **Text:** Case Converter, Slug Generator, URL Parser, Line Break Normalizer - **Crypto:** Hash/HMAC Generator, Password Generator, UUID - **Encoders:** URL, Base64, JSON to TOML/XML/YAML, Color Converter - **Generators:** JSON Generator, Lorem Ipsum @@ -140,6 +152,25 @@ Jump to spaces, snippets, notes, HTTP requests, and common actions from anywhere - Run create actions for snippets, notes, HTTP requests, and folders - Scope search to a space with `@` or open command mode with `>` +### Imports + +Bring existing work into your vault with preview-first imports for snippets, notes, and API collections. + +- Import snippets from VS Code, Raycast, SnippetsLab, and public GitHub Gists +- Import markdown notes from Obsidian folders +- Import HTTP collections from OpenAPI, Postman, and Bruno +- Review detected items, folders, tags, environments, and warnings before writing anything + +### Browser Clipper + +Save web content from Chrome, Firefox, or Safari into the local massCode app. + +- Install the Chrome version from the [Chrome Web Store](https://chromewebstore.google.com/detail/masscode-clipper/fkaaogdifollkhjbfoabbiocecehaaii) +- Save selected code as snippets +- Save selected text or readable page content as notes +- Save pages or links as HTTP `GET` requests +- Connect through the local Integration API with an API token + ## Markdown Vault Your snippets, notes, and HTTP requests live as plain `.md` files on disk with frontmatter metadata, so the data stays readable and portable. @@ -195,24 +226,6 @@ pnpm dev -## Troubleshooting - -
-macOS: "massCode is damaged and can't be opened" - -This is caused by macOS security settings. Fix it with: - -**Option 1: System Settings (macOS 13+)** -1. Open **System Settings** -> **Privacy & Security** -2. Find "massCode" and click **Allow Anyway** - -**Option 2: Terminal** -```bash -sudo xattr -r -d com.apple.quarantine /Applications/massCode.app -``` - -
- ## Follow - News and updates on [X](https://x.com/anton_reshetov) diff --git a/docs/website/.vitepress/components/features/TheFeatures.vue b/docs/website/.vitepress/components/features/TheFeatures.vue index 50bd41407..baf6c12c6 100644 --- a/docs/website/.vitepress/components/features/TheFeatures.vue +++ b/docs/website/.vitepress/components/features/TheFeatures.vue @@ -18,10 +18,10 @@ import FeaturesItem from './FeaturesItem.vue' polished screenshots when you need to share code. - Keep technical notes, docs, and drafts next to your snippets. Write in - Markdown with live preview, folders, tags, Mermaid diagrams, mindmaps - generated from headings, and fullscreen presentation mode for sharing your - work. + Keep technical notes, task notes, docs, and drafts next to your snippets. + Write in Markdown with live preview, add status, priority, and due dates + to task notes, organize with folders and tags, and use Mermaid diagrams, + mindmaps, or fullscreen presentation mode when you need more structure. Save and send API requests without leaving your workspace. Organize @@ -36,6 +36,15 @@ import FeaturesItem from './FeaturesItem.vue' $500 invested $1,500 returned for currencies, time zones, unit conversion, finance, and date math. + + Sketch diagrams, wireframes, and whiteboard ideas on an + + Excalidraw + + -powered canvas. Keep a searchable list of drawings beside your snippets + and notes, recenter with fit-to-content, export them as images, and embed + drawings directly in your Markdown notes. + Handle the small developer tasks that usually send you to a browser tab. Convert, encode, hash, and generate data right inside massCode with tools @@ -90,11 +99,7 @@ import FeaturesItem from './FeaturesItem.vue' grid-column: span 3; } - .features > :nth-child(n + 3):nth-child(-n + 5) { - grid-column: span 2; - } - - .features > :nth-child(n + 6) { + .features > :nth-child(n + 3) { grid-column: span 3; } } @@ -104,15 +109,11 @@ import FeaturesItem from './FeaturesItem.vue' grid-template-columns: repeat(12, minmax(0, 1fr)); } - .features > :nth-child(-n + 2) { + .features > :nth-child(-n + 6) { grid-column: span 6; } - .features > :nth-child(n + 3):nth-child(-n + 5) { - grid-column: span 4; - } - - .features > :nth-child(n + 6) { + .features > :nth-child(n + 7) { grid-column: span 3; } } diff --git a/docs/website/.vitepress/components/global/AssetsDownload.vue b/docs/website/.vitepress/components/global/AssetsDownload.vue index b13e15d81..e21ca0d0b 100644 --- a/docs/website/.vitepress/components/global/AssetsDownload.vue +++ b/docs/website/.vitepress/components/global/AssetsDownload.vue @@ -21,18 +21,6 @@ import assets from '../../_data/assets.json'

macOS 10.13+

-

Opening unsigned macOS builds

- -

- The macOS app is not code-signed. If macOS says that massCode is damaged or - cannot be opened, allow it in System Settings > - Privacy & Security, or remove the quarantine attribute: -

- -
-
sudo xattr -r -d com.apple.quarantine /Applications/massCode.app
-
-

Windows

diff --git a/docs/website/.vitepress/config.mts b/docs/website/.vitepress/config.mts index 46276a15e..c89421000 100644 --- a/docs/website/.vitepress/config.mts +++ b/docs/website/.vitepress/config.mts @@ -3,7 +3,7 @@ import { version } from './_data/assets.json' const siteUrl = 'https://masscode.io' const siteTitle = 'massCode' -const description = 'Free, open-source developer workspace with code snippets, markdown notes, math notebook, and built-in dev tools.' +const description = 'Free, open-source developer workspace with code snippets, markdown notes, math notebook, drawings, and built-in dev tools.' const ogImage = `${siteUrl}/og-image.png` const gsv = 'h-rU1tSutO83wOyvi4syrk_XTvgennlUPkL6fMmq5cI' @@ -83,6 +83,7 @@ export default defineConfig({ nav: [ { text: 'Documentation', link: '/documentation/' }, { text: 'Compare', link: '/compare/' }, + { text: 'Blog', link: '/blog/' }, { text: 'Sponsor', link: '/sponsor/' }, { text: 'Donate', link: '/donate/' }, { @@ -95,6 +96,15 @@ export default defineConfig({ ], sidebar: { + '/blog/': [ + { + text: 'Blog', + items: [ + { text: 'Overview', link: '/blog/' }, + { text: 'How to Organize Code Snippets', link: '/blog/how-to-organize-code-snippets' }, + ], + }, + ], '/compare/': [ { text: 'Compare', @@ -104,8 +114,19 @@ export default defineConfig({ { text: 'massCode vs Cacher', link: '/compare/cacher' }, { text: 'massCode vs SnippetsLab', link: '/compare/snippetslab' }, { text: 'massCode vs Raycast Snippets', link: '/compare/raycast' }, + { text: 'massCode vs GitHub Gist', link: '/compare/github-gist' }, + { text: 'massCode vs Postman', link: '/compare/postman' }, + { text: 'massCode vs Todoist', link: '/compare/todoist' }, + { text: 'massCode vs TickTick', link: '/compare/ticktick' }, + { text: 'massCode vs Things 3', link: '/compare/things' }, + { text: 'massCode vs Apple Reminders', link: '/compare/apple-reminders' }, + { text: 'massCode vs Obsidian Tasks', link: '/compare/obsidian-tasks' }, + { text: 'Best Code Snippet Managers', link: '/compare/best-code-snippet-managers' }, + { text: 'Code Snippet Manager for Mac', link: '/compare/code-snippet-manager-for-mac' }, + { text: 'Code Snippet Manager for Windows', link: '/compare/code-snippet-manager-for-windows' }, { text: 'Best Open-Source Snippet Manager', link: '/compare/best-open-source' }, { text: 'Local-First Alternative to Pieces / Cacher', link: '/compare/local-first' }, + { text: 'Markdown-First Task Manager', link: '/compare/markdown-first-tasks' }, ], }, ], @@ -115,30 +136,33 @@ export default defineConfig({ items: [ { text: 'Overview', link: '/documentation/' }, { text: 'Command Palette', link: '/documentation/command-palette' }, + { text: 'Search', link: '/documentation/search' }, { text: 'Storage', link: '/documentation/storage' }, { text: 'Sync', link: '/documentation/sync' }, { text: 'Imports', link: '/documentation/imports' }, - { text: 'Themes', link: '/documentation/themes' }, + { text: 'Clipper', link: '/documentation/clipper' }, + { text: 'Appearance', link: '/documentation/themes' }, ], }, { text: 'Code', items: [ - { text: 'Library', link: '/documentation/code/library' }, - { text: 'Folders', link: '/documentation/code/folders' }, - { text: 'Tags', link: '/documentation/code/tags' }, + { text: 'Overview', link: '/documentation/code/' }, { text: 'Snippets', link: '/documentation/code/snippets' }, { text: 'Fragments', link: '/documentation/code/fragments' }, { text: 'Description', link: '/documentation/code/description' }, - { text: 'Search', link: '/documentation/code/search' }, + { text: 'Library', link: '/documentation/code/library' }, + { text: 'Folders', link: '/documentation/code/folders' }, + { text: 'Tags', link: '/documentation/code/tags' }, ], }, { text: 'Notes', items: [ - { text: 'Notes', link: '/documentation/notes/' }, + { text: 'Overview', link: '/documentation/notes/' }, { text: 'Dashboard', link: '/documentation/notes/dashboard' }, { text: 'Library', link: '/documentation/notes/library' }, + { text: 'Tasks', link: '/documentation/notes/tasks' }, { text: 'Folders', link: '/documentation/notes/folders' }, { text: 'Tags', link: '/documentation/notes/tags' }, { text: 'Internal Links', link: '/documentation/notes/internal-links' }, @@ -147,13 +171,12 @@ export default defineConfig({ { text: 'Mermaid', link: '/documentation/notes/mermaid' }, { text: 'Mindmap', link: '/documentation/notes/mindmap' }, { text: 'Presentation', link: '/documentation/notes/presentation' }, - { text: 'Search', link: '/documentation/notes/search' }, ], }, { text: 'HTTP', items: [ - { text: 'HTTP Client', link: '/documentation/http/' }, + { text: 'Overview', link: '/documentation/http/' }, { text: 'Requests', link: '/documentation/http/requests' }, { text: 'Environments', link: '/documentation/http/environments' }, { text: 'Importing Collections', link: '/documentation/http/importing' }, @@ -162,7 +185,13 @@ export default defineConfig({ { text: 'Math', items: [ - { text: 'Math Notebook', link: '/documentation/math/' }, + { text: 'Overview', link: '/documentation/math/' }, + ], + }, + { + text: 'Drawings', + items: [ + { text: 'Overview', link: '/documentation/drawings/' }, ], }, { diff --git a/docs/website/blog/how-to-organize-code-snippets.md b/docs/website/blog/how-to-organize-code-snippets.md new file mode 100644 index 000000000..dedd60579 --- /dev/null +++ b/docs/website/blog/how-to-organize-code-snippets.md @@ -0,0 +1,109 @@ +--- +title: How to Organize Code Snippets (Without Losing Them) +description: "A simple, durable system for organizing code snippets so you can actually find them later — where to store them, how to structure folders and tags, how to name them, and which tools make it stick." +--- + +# How to Organize Code Snippets (Without Losing Them) + +Every developer accumulates snippets: the regex you got right after three tries, the Docker config that finally worked, the SQL query you will absolutely need again. The problem is rarely saving them. The problem is finding them six months later. + +If your snippets are scattered across Slack messages to yourself, random `.txt` files, browser bookmarks, and a Notes app, this guide is a way out. It is a simple system you can apply in any tool, plus what to look for if you want a dedicated one. + +## Why snippets get lost + +Most "snippet systems" fail for the same few reasons: + +- **Too many homes.** A snippet in Slack, one in a Gist, one in a desktop note. When you need it, you do not know where to look. +- **No search.** A folder of files you have to open one by one is not a library. +- **Organized for saving, not for finding.** Filing everything under the language it is written in feels tidy but matches how you *store*, not how you *search*. You rarely think "show me my Python"; you think "where's that retry-with-backoff thing." +- **No context.** A snippet with no title, no description, and no note about where it came from is a puzzle later. + +Fix those four things and the rest is easy. + +## A simple system that lasts + +### 1. Pick one home + +The single highest-impact decision is to keep all snippets in **one place**. One library you trust, that you open by reflex. It does not matter much which tool — it matters enormously that there is only one. Consolidate what you already have scattered around before you add anything new. + +### 2. Organize for retrieval, not by language + +Structure your library around **how you will look for things**, not around the syntax. Two mechanisms cover almost everything: + +- **A few broad folders** by area of work — for example `frontend`, `backend`, `devops`, `db`, and `shell`. Keep the list short. If you have thirty folders, you have a second search problem. Nested folders help here: in massCode, selecting a folder also shows snippets from its subfolders, so a shallow `db/` can still hold `db/migrations` without hiding anything. +- **Tags for the cross-cutting stuff** — `regex`, `auth`, `docker`, `pagination`, `snippet-i-always-forget`. Tags are how you find the same idea across folders. + +Folders answer "what kind of work," tags answer "what is this about." Language can be a tag too, but it is rarely the thing you actually search by. + +### 3. Name snippets for your future self + +The title is your main search hit. Write it as what the snippet *does*, in the words you would type when looking for it: + +- Bad: `regex2`, `Untitled`, `test` +- Good: `Validate email (RFC-ish) regex`, `Debounce a function`, `Postgres: kill idle connections` + +If you can imagine searching for it, name it that. Don't stuff the language into the title — that is what folders and tags are for. And if you keep the same idea in several languages, you don't need separate entries at all: tools with multi-tab snippets (in massCode, [fragments](/documentation/code/fragments)) let you hold the JS, TS, and Python versions as tabs under one `Debounce` entry. + +### 4. Add just enough context + +You do not need documentation. You need one line so the snippet is not a mystery later: + +- What it does or when to use it. +- Where it came from, if it matters (a Stack Overflow link, the project, the gotcha it solves). +- Anything non-obvious ("only works on Node 18+"). + +A good snippet manager gives this its own home instead of forcing it into the code as a comment. In massCode it is a dedicated [description](/documentation/code/description) field beside the snippet, and that text is indexed by search too — so a note like "retry with backoff" makes the snippet findable even when the code itself never says it. Thirty seconds now saves ten minutes later. + +### 5. Keep it close to where you work + +A library you have to context-switch into is a library you stop using. The best setup is one you can reach in a keystroke. In massCode that is the [Command Palette](/documentation/command-palette) (Cmd/Ctrl+P): jump to any snippet by title, or narrow the search with `@code`, a `#tag`, or a `/folder`. The less friction between "I need that snippet" and "it's pasted," the more the system survives a busy day. + +### 6. Own your data + +Snippets are a long-term asset — you want them in five years, across machine changes and tool changes. Prefer a tool that stores them as **plain files you control** (and can read without the app), or at least one with a clean export. Avoid formats that trap your library inside a service you cannot leave. + +## A starter structure you can copy + +```text +frontend/ # components, hooks, CSS tricks +backend/ # handlers, middleware, auth +db/ # queries, migrations, schema bits +devops/ # docker, CI, deploy +shell/ # one-liners, git, system +``` + +Tags layered on top: `regex`, `auth`, `docker`, `git`, `performance`, `gotcha`. + +Leave room for triage. The fastest way to keep a library clean is to let yourself dump first and file later, then sweep the unsorted pile into the right folder once a week. Some tools give you this for free: massCode drops every new snippet into an [Inbox](/documentation/code/library) until you move it into a folder — the dump-now-file-later workflow, without a holding folder you have to invent and remember to empty. + +## What to use + +You can run this system in almost anything, with trade-offs: + +- **Plain files + your editor** — total control, no search UI, manual organization. +- **GitHub Gist** — great for sharing, but a flat list with weak private search. See [massCode vs GitHub Gist](/compare/github-gist). +- **A dedicated snippet manager** — folders, tags, full-text search, and editor integration built in. + +If you want a dedicated tool, the things that make this system stick are: one searchable home, real folders and tags, fast access, and your data as files you own. [massCode](/download/) is a free, open-source, local-first option built around exactly that. Snippets live as plain Markdown files on your disk; you get nested folders, multiple tags per snippet, an Inbox for triage, a dedicated description field, and [fragments](/documentation/code/fragments) for keeping language variants together. Its list search is full-text — it looks inside snippet names, descriptions, and the contents of every fragment, not just titles — and you can import existing libraries from VS Code, Raycast, SnippetsLab, and public GitHub Gists. For a broader look at the options, see [Best code snippet managers](/compare/best-code-snippet-managers). + +## Frequently asked questions + +### What is the best way to organize code snippets? + +Keep them all in one searchable place, organize around how you will look for them (a few broad folders plus tags) rather than by language, name each snippet as what it does, and add one line of context. Then keep the library somewhere you can reach in a keystroke. + +### Should I organize snippets by language or by topic? + +By topic and use case, with language as a secondary tag if useful. You almost never search "show me all my Go"; you search for the thing the snippet does. Folders by area of work plus tags for cross-cutting topics retrieve far better than language folders. + +### Where should I store my code snippets? + +Somewhere with full-text search and, ideally, local files you own. Plain-text or Markdown storage means your library outlives any single app. Cloud-only tools are convenient but check that you can export. See [Best code snippet managers](/compare/best-code-snippet-managers) for a comparison. + +### How do I stop my snippet library from becoming a mess? + +Give yourself a triage zone — an inbox or "unsorted" area — and dump snippets there fast, then file them into folders once a week. Removing the friction at save time is what keeps people saving; the weekly sweep keeps the rest organized. In massCode, new snippets land in the Inbox by default, so the dump-now-file-later habit is built in. + +## Takeaway + +Organizing snippets is not about a perfect taxonomy. It is four habits: one home, organize for retrieval, name and add context, keep it close. Pick a tool that supports those and stays out of your way. If you want one that stores everything as plain files you own, [try massCode](/download/). diff --git a/docs/website/blog/index.md b/docs/website/blog/index.md new file mode 100644 index 000000000..5f41aef8a --- /dev/null +++ b/docs/website/blog/index.md @@ -0,0 +1,12 @@ +--- +title: massCode Blog +description: "Practical articles on organizing code snippets, working local-first, and getting more out of your developer workspace." +--- + +# Blog + +Practical writing on snippets, notes, and a local-first developer workflow. + +## Articles + +- [How to organize code snippets (without losing them)](/blog/how-to-organize-code-snippets) — a simple, durable system for saving and finding the code you reuse. diff --git a/docs/website/compare/apple-reminders.md b/docs/website/compare/apple-reminders.md new file mode 100644 index 000000000..351ec7b56 --- /dev/null +++ b/docs/website/compare/apple-reminders.md @@ -0,0 +1,77 @@ +--- +title: massCode vs Apple Reminders +description: "An honest comparison between massCode tasks and Apple Reminders. Cross-platform open-source developer workspace vs free Apple-built-in task manager with iCloud sync." +--- + +# massCode vs Apple Reminders + +[Apple Reminders](https://www.apple.com/ios/reminders/) ships free with every Mac, iPhone, iPad, Apple Watch, and Vision Pro. It is the default task manager for most Apple users. massCode is a free, open-source, cross-platform developer workspace where [tasks are notes](/documentation/notes/tasks) — plain Markdown files in a vault on your own disk. + +If you are entirely on Apple devices and want a zero-friction default task app, Reminders is the more natural fit. If you work across operating systems, or you want tasks to live as markdown next to your snippets and notes, massCode is the more natural fit. + +## At a glance + +| | massCode | Apple Reminders | +| --- | --- | --- | +| License | Open source (AGPL v3) | Proprietary, built in to Apple OSes | +| Pricing | Free | Free | +| Data location | Local Markdown Vault on your disk | Local on each device, synced via iCloud | +| Sync | iCloud, Dropbox, Google Drive, Syncthing, Git — your choice | iCloud only | +| Native platforms | macOS, Windows, Linux | macOS, iPadOS, iOS, watchOS, visionOS | +| Web access | No web app | View-only web access at [icloud.com/reminders](https://www.icloud.com/reminders) | +| Windows / Linux native app | Yes | No | +| Task model | Notes with `status`, `priority`, and `due` properties | Reminders with subtasks, sections, tags, smart lists | +| Statuses | Todo, In Progress, Done, Blocked | Open / completed | +| Priority | None, Low, Medium, High | None, Low, Medium, High | +| Due dates and times | Calendar picker (date) | Date and time | +| Location-based reminders | No | Yes | +| Recurring tasks | No | Yes | +| Subtasks | Markdown checklist inside the note body | First-class subtasks | +| Shared lists | No | Yes, via iCloud | +| Voice input | No | Siri | +| Account required | No | Apple ID for iCloud sync | + +Sources: Apple's [Reminders user guide](https://support.apple.com/guide/reminders/welcome/mac) and the [iCloud Reminders web app](https://www.icloud.com/reminders). + +## Where Apple Reminders fits better + +Reminders is a strong choice when the goal is "a task manager that just works on all my Apple devices, for free." + +- **You are on Apple devices.** Reminders is preinstalled on macOS, iPadOS, iOS, watchOS, and visionOS with seamless iCloud sync. +- **You want zero setup.** No download, no account creation beyond an Apple ID, no separate app to maintain. +- **You want location-based reminders.** "Remind me when I get home," "Remind me when I leave the office" are first-class. +- **You want time-based notifications.** Reminders fires alerts at the right moment, with snooze and repeat. +- **You want Siri.** Capturing a task by voice is one of Reminders' strongest features. +- **You want shared lists.** Shared family lists, shopping lists, project lists work across iCloud accounts. +- **You want recurring tasks.** Daily, weekly, custom — supported natively. + +## Where massCode fits better + +massCode is a strong choice when Reminders' Apple-only constraint is a problem, or when the "task" is really a working note that needs a structured status. + +- **You work across operating systems.** massCode runs natively on macOS, Windows, and Linux from the same vault. Reminders has no native Windows or Linux app — only view-only web access through iCloud.com. +- **You want plain Markdown files on disk.** Every task is a `.md` file with frontmatter in your [Markdown Vault](/documentation/storage). Reminders data lives in Apple's database and is accessed through Apple's apps. +- **You want one workspace, not five apps.** Tasks share the app with [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). +- **You want sync without Apple.** Point [iCloud, Dropbox, Google Drive, Syncthing, or Git](/documentation/sync) at your vault. iCloud is one option among many, not the only one. +- **You want a richer note body per task.** A massCode task is a full markdown note with code blocks, mermaid, mindmap, internal links — not just a notes field. +- **You want In Progress and Blocked statuses.** Reminders is binary (open / completed); massCode has Todo, In Progress, Done, Blocked. + +## Honest trade-offs + +- **No mobile app in massCode.** Reminders is on every Apple device; massCode is desktop-only. +- **No reminders or notifications in massCode.** Despite the name, "reminders" — alerts, snooze, location triggers, Siri capture — are not part of massCode. +- **No recurring tasks in massCode.** A task has a single `due` date. +- **No first-class subtasks in massCode.** You can write a markdown checklist inside the body, but those checklist items are not separate task entities. +- **No shared lists.** Reminders has iCloud-shared lists; massCode does not. +- **No voice input.** Siri-style capture does not exist in massCode. + +## Who should pick which + +- Pick **Apple Reminders** if you are Apple-only and want a free, preinstalled task manager with reminders, recurring tasks, location triggers, Siri, and shared lists. +- Pick **massCode** if you need a cross-platform workspace where tasks are plain markdown next to your snippets and notes, with a richer status model and sync your way. + +## Using both + +You can keep Reminders for life logistics — groceries, family lists, time-and-location alerts — and use massCode tasks for technical work-in-progress, where the "task" is really a working note with code, links, and context. + +[Download massCode](/download/) and try it on a few in-flight work notes. diff --git a/docs/website/compare/best-code-snippet-managers.md b/docs/website/compare/best-code-snippet-managers.md new file mode 100644 index 000000000..6903a1c30 --- /dev/null +++ b/docs/website/compare/best-code-snippet-managers.md @@ -0,0 +1,180 @@ +--- +title: Best Code Snippet Managers (2026) +description: "An honest, up-to-date guide to the best code snippet managers — massCode, SnippetsLab, Pieces, Cacher, Lepton, GitHub Gist, and more. Compare them on license, storage, platforms, price, and scope, then pick the right one for your workflow." +--- + +# Best Code Snippet Managers + +A code snippet manager is where the small, reusable pieces of your work live: the regex you always forget, the boilerplate config, the shell one-liner, the query you wrote once and will need again. The right one saves those snippets in seconds and gives them back the moment you need them. The wrong one becomes another place you forget to look. + +This guide compares the best code snippet managers available today. Every tool here is a reasonable choice for someone — the goal is to help you match a tool to how you actually work, not to crown a single winner. + +## How we evaluated them + +Star count and marketing aside, a snippet manager is worth adopting when it handles the boring parts well. We looked at: + +- **Storage and ownership.** Are your snippets plain files on your disk, or rows in someone else's database? Can you read them without the app? +- **License.** Open source you can audit and fork, or proprietary. +- **Platforms.** macOS only, or also Windows and Linux. +- **Price.** Free, one-time purchase, or subscription. +- **Account requirement.** Does it work offline without signing in? +- **Search.** Full-text search is the feature you use most and notice least. +- **Imports.** Can it pull in your existing library, or do you start from scratch? +- **Scope.** Snippets only, or notes, HTTP requests, and other developer tools alongside them. + +## At a glance + +| Tool | License | Platforms | Storage | Account | Scope | +| --- | --- | --- | --- | --- | --- | +| [massCode](#masscode) | Open source (AGPL v3) | macOS, Windows, Linux | Local Markdown files | No | Snippets + notes, HTTP, math, drawings, tools | +| [SnippetsLab](#snippetslab) | Proprietary | macOS only | Local library | No | Snippets | +| [Pieces](#pieces) | Proprietary | macOS, Windows, Linux + IDE/browser | On-device / local | Optional | Snippets + AI companion | +| [Cacher](#cacher) | Proprietary | macOS, Windows, Linux | Cloud | Yes | Snippets, team libraries | +| [Lepton](#lepton) | Open source (MIT) | macOS, Windows, Linux | GitHub Gist | GitHub | Snippets (unmaintained) | +| [GitHub Gist](#github-gist) | Proprietary (service) | Web + API | Cloud | Yes | Snippets | +| [Raycast Snippets](#raycast-snippets) | Proprietary | macOS, iOS | App database | Optional | Text expansion | +| [VS Code snippets](#vs-code-snippets) | Open source (editor) | Anywhere VS Code runs | JSON in editor config | No | Snippets in-editor | + +Details and trade-offs for each are below. + +## The best code snippet managers + +### massCode + +[massCode](https://github.com/massCodeIO/massCode) is a free, open-source, local-first developer workspace. Snippets sit alongside notes, HTTP requests, math sheets, drawings, and dev tools, and everything is stored as plain Markdown files in a [Markdown Vault](/documentation/storage) on your own disk. + +- **License:** [AGPL v3](https://github.com/massCodeIO/massCode/blob/master/LICENSE), source on [GitHub](https://github.com/massCodeIO/massCode) +- **Platforms:** macOS, Windows, Linux +- **Storage:** Local `.md` files you can read, edit, and back up without the app +- **Account:** None required, no telemetry login +- **Search:** Full-text across snippets and notes (HTTP requests by name and URL) +- **Imports:** VS Code snippets JSON, Raycast snippets JSON, SnippetsLab JSON, public GitHub Gist URLs, and Obsidian markdown folders +- **Sync:** Bring your own — iCloud, Dropbox, Google Drive, Syncthing, or [Git](/documentation/sync) + +**Best for:** developers who want their snippets to stay as plain files they own, on every platform, for free, and who appreciate having notes, HTTP requests, math, and drawings in the same window. [Download massCode](/download/) to try it. + +### SnippetsLab + +[SnippetsLab](https://www.renfei.org/snippets-lab/) is a long-standing, polished, macOS-native snippet manager. It has a refined interface, a built-in assistant for the macOS menu bar, and deep integration with the Apple ecosystem. As of 2026 its own site states it is "FREE for everyone — no ads, no in-app purchases, and no subscriptions." + +- **License:** Proprietary +- **Platforms:** macOS only (macOS 13.5 or later) +- **Price:** Free +- **Storage:** Local library +- **Scope:** Snippets, focused and well executed + +**Best for:** developers who live entirely on macOS and want a native, carefully designed snippet app at no cost. If you also use Windows or Linux, it cannot follow you there. See the full [massCode vs SnippetsLab](/compare/snippetslab) comparison. + +### Pieces + +[Pieces](https://pieces.app) is an AI-first snippet manager built around an on-device AI companion, long-term memory of your work, and tight integration with editors and browsers. Its site emphasizes that "Pieces runs on-device" and processes data locally. + +- **License:** Proprietary +- **Platforms:** macOS, Windows, Linux, plus IDE and browser integrations +- **Storage:** On-device / local processing +- **Scope:** Snippets enriched with AI, long-term memory, multiple LLMs + +**Best for:** developers who want an AI assistant that remembers their work and answers questions about it. If you want a focused, file-based workspace without AI, it is more than you need. See [massCode vs Pieces](/compare/pieces). + +### Cacher + +[Cacher](https://www.cacher.io) is a cloud-based snippet organizer aimed at individuals and teams, with shared libraries, labels, and editor integrations. + +- **License:** Proprietary +- **Platforms:** macOS, Windows, Linux, plus a VS Code extension +- **Storage:** Cloud +- **Scope:** Personal and team snippet libraries + +**Best for:** teams that want a hosted, shared snippet library with centralized access. If you prefer your snippets on local disk with no account, it is the opposite model. See [massCode vs Cacher](/compare/cacher). + +### Lepton + +[Lepton](https://github.com/hackjutsu/Lepton) is a lean, open-source snippet manager powered by GitHub Gist. Your snippets are stored as Gists, so they round-trip with GitHub, including GitHub Enterprise. + +- **License:** Open source (MIT) +- **Platforms:** macOS, Windows, Linux +- **Storage:** GitHub Gist (cloud) +- **Scope:** Snippets only +- **Maintenance:** Largely inactive — its last tagged release (v1.10.0) dates to 2021, so treat it as stable-but-unmaintained + +**Best for:** developers who want their snippet library to *be* their GitHub Gists and are comfortable with a project that is no longer actively developed. You trade local-file ownership for Gist sync and a narrower feature set. + +### GitHub Gist + +[GitHub Gist](https://gist.github.com) is not a dedicated app, but plenty of developers use it as one. It is free, instantly shareable, and versioned with Git. + +- **License:** Proprietary service +- **Platforms:** Web and API, plus many third-party clients +- **Storage:** Cloud (your GitHub account) +- **Scope:** Snippets as Gists + +**Best for:** quick, shareable, public or secret snippets you already manage on GitHub. As a primary library it lacks folders, rich organization, and offline-first storage. If you outgrow it, massCode can [import your Gists](/documentation/code/library) by URL — see [massCode vs GitHub Gist](/compare/github-gist). + +### Raycast Snippets + +[Raycast Snippets](https://www.raycast.com) is a feature of the Raycast launcher for macOS. It is best as a global text expander you trigger from anywhere, rather than a long-form code library. + +- **License:** Proprietary +- **Platforms:** macOS, iOS +- **Storage:** Raycast database, optional cloud sync on Pro +- **Scope:** Short text expansion + +**Best for:** system-wide text expansion of short snippets. Many developers pair it with a dedicated library; massCode even ships a [Raycast extension](https://www.raycast.com/antonreshetov/masscode) so you can search your massCode snippets from Raycast. See [massCode vs Raycast Snippets](/compare/raycast). + +### VS Code snippets + +VS Code has a built-in snippets feature: JSON files that expand by prefix as you type. It is free, already installed, and great for templated code you insert while writing. + +- **License:** Open source editor +- **Platforms:** Anywhere VS Code runs +- **Storage:** JSON in your editor config +- **Scope:** In-editor expansion + +**Best for:** templated boilerplate you insert by prefix inside VS Code. It is not a searchable library for snippets you collect and revisit. massCode imports VS Code snippets JSON, so the two can coexist. + +## How to choose the right snippet manager + +Work through these questions before installing anything: + +- **Where should my snippets live?** Plain files on your disk, a hosted cloud account, or GitHub Gists? +- **Which platforms do I use?** macOS only opens more options; cross-platform narrows the field to a few. +- **Do I want it to work offline with no login?** If yes, rule out account-required cloud tools. +- **Will I import an existing library?** Check that your current format (VS Code JSON, Gists, SnippetsLab export) is supported. +- **Snippets only, or more?** If your day also includes notes, HTTP requests, and quick math, a workspace beats a single-purpose app. +- **Free, one-time, or subscription?** Decide what you are willing to pay before you get attached. + +## Recommendations by use case + +- **You want plain files you own, on every platform, for free** — and notes, HTTP, math, and drawings in the same app: choose **massCode**. +- **You are macOS-only and want a polished native app:** choose **SnippetsLab**. +- **You want an AI copilot over your snippets:** choose **Pieces**. +- **Your team needs a shared, hosted snippet library:** choose **Cacher**. +- **You want your library to be GitHub Gists:** choose **Lepton** or **GitHub Gist** directly. +- **You want system-wide text expansion:** choose **Raycast Snippets**. +- **You only insert templated code inside VS Code:** the built-in **VS Code snippets** are enough. + +## Frequently asked questions + +### What is a code snippet manager? + +A code snippet manager is an app for saving, organizing, and quickly retrieving reusable pieces of code and text — regexes, config blocks, commands, queries, and boilerplate. Good ones add folders or tags, full-text search, syntax highlighting, and imports so your existing snippets come along. + +### What is the best free code snippet manager? + +For a free, cross-platform, open-source option, [massCode](/download/) stores every snippet as a plain Markdown file on your disk with no account required. Lepton (MIT) is also free but stores snippets as GitHub Gists. VS Code's built-in snippets are free for in-editor expansion. + +### What is the best open-source code snippet manager? + +The main open-source options are massCode (AGPL v3, local Markdown files, cross-platform) and Lepton (MIT, GitHub Gist-backed). For a deeper look at licenses and trade-offs, see [Best open-source snippet manager](/compare/best-open-source). + +### Where are my code snippets actually stored? + +It depends on the tool. massCode stores them as plain `.md` files on your local disk. SnippetsLab keeps a local library. Cacher and GitHub Gist store them in the cloud. Lepton stores them as GitHub Gists. If reading your snippets without the app matters to you, prefer a tool with documented local file storage. + +### Can I move my snippets between snippet managers? + +Often, yes. massCode imports VS Code snippets JSON, Raycast snippets JSON, SnippetsLab JSON, public GitHub Gist URLs, and Obsidian markdown folders, so migrating an existing library is usually a few clicks rather than a manual copy. + +## Try massCode + +If your answers point to local files, cross-platform, no account, free, open-source, and more than just snippets, massCode is the closest match. [Download massCode](/download/) or browse the [comparisons](/compare/) to see how it stacks up against the tool you use today. diff --git a/docs/website/compare/best-open-source.md b/docs/website/compare/best-open-source.md index aad9b62c1..81b516b4e 100644 --- a/docs/website/compare/best-open-source.md +++ b/docs/website/compare/best-open-source.md @@ -32,7 +32,7 @@ A pure snippet manager is enough for many people. But the day-to-day workflow of - **Storage:** Local [Markdown Vault](/documentation/storage) — every snippet and note is a plain `.md` file with frontmatter, watched in real time - **Account required:** None - **Platforms:** macOS, Windows, Linux -- **Scope:** [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), and [Tools](/documentation/tools/) in one app +- **Scope:** [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/) in one app - **Imports:** VS Code snippets JSON, Raycast snippets JSON, SnippetsLab JSON, public GitHub Gist URLs, and Obsidian markdown folders - **Sync:** Bring your own — [iCloud, Dropbox, Google Drive, Syncthing, or Git](/documentation/sync) - **Active development:** Continuously released on [GitHub Releases](https://github.com/massCodeIO/massCode/releases) diff --git a/docs/website/compare/cacher.md b/docs/website/compare/cacher.md index 21b51e244..7548c0f5a 100644 --- a/docs/website/compare/cacher.md +++ b/docs/website/compare/cacher.md @@ -44,7 +44,7 @@ Cacher is a strong choice when team sharing or GitHub Gist round-trip are non-ne massCode is a strong choice when you value local control, a single combined workspace, and zero cost. - **You want plain files, not a database in the cloud.** Every snippet and note in massCode is a Markdown file in your [Markdown Vault](/documentation/storage). You can read, edit, and back it up with any tool. -- **You want one app for snippets, notes, HTTP, and math.** massCode includes [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), and [Tools](/documentation/tools/). Cacher focuses on snippets. +- **You want one app for snippets, notes, HTTP, and math.** massCode includes [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). Cacher focuses on snippets. - **You want to bring existing snippets into local storage.** massCode imports public GitHub Gist URLs, VS Code snippets JSON, Raycast snippets JSON, SnippetsLab JSON, and Obsidian markdown folders. - **You want sync on your terms.** Point [iCloud, Dropbox, Google Drive, Syncthing, or a Git repo](/documentation/sync) at your vault. No account, no second bill. - **You want to keep working when the network is gone.** massCode is local-first by design. @@ -72,3 +72,21 @@ To move from Cacher to massCode at the file level: 4. If you previously relied on Cacher for team sharing, point your massCode vault at a shared Git repository so teammates can pull and push changes. [Download massCode](/download/) and try it on a copy of your snippets first. + +## Frequently asked questions + +### Is massCode a good Cacher alternative? + +Yes, if you prefer local-first over cloud. Cacher is a hosted, account-based service with team libraries. massCode is a free, open-source app that keeps your snippets as plain Markdown files on your own disk, working fully offline with no account. + +### Can I use massCode in a team like Cacher? + +Sharing in massCode happens at the file level: point your [Markdown Vault](/documentation/storage) at a shared Git repository or cloud folder so teammates can pull and push. That suits small teams, but it is not a managed, role-based team workspace like Cacher's. + +### Can I import my Cacher snippets into massCode? + +There is no direct Cacher importer. The practical path is to export snippets through GitHub Gists and import the public Gist URLs in [Code](/documentation/code/library), then adjust folders, tags, and languages. + +### Is massCode free? + +Yes, massCode is free and open source under AGPL v3. Cacher is a commercial cloud service, so the cost and hosting model are the main differences to weigh. diff --git a/docs/website/compare/code-snippet-manager-for-mac.md b/docs/website/compare/code-snippet-manager-for-mac.md new file mode 100644 index 000000000..93e4a5e58 --- /dev/null +++ b/docs/website/compare/code-snippet-manager-for-mac.md @@ -0,0 +1,58 @@ +--- +title: Code Snippet Manager for Mac +description: "What to look for in a code snippet manager on macOS, and why massCode is a strong choice — a free, open-source, local-first app that stores snippets as plain Markdown files and follows you to Windows and Linux too." +--- + +# Code Snippet Manager for Mac + +macOS has more good snippet managers than any other platform, which is both a blessing and a trap. Some are macOS-only and beautiful; some are cross-platform and practical. The right pick depends on whether you live entirely on a Mac or move between machines — and on whether you want your snippets locked to one app or kept as files you own. + +This page covers what matters in a snippet manager on the Mac, and where [massCode](/download/) fits. + +## What to look for on macOS + +- **Native, not a web tab.** A real Mac app with proper keyboard shortcuts and a menu-bar presence beats a browser tab you forget to open. +- **Local storage you own.** Snippets should live as files on your disk, readable without the app, not trapped in a database or a cloud account. +- **Fast search and real organization.** Folders, tags, and full-text search are what turn a pile of snippets into a library. +- **Mac-only or cross-platform?** A macOS-only app can feel more native, but if you ever touch a Windows or Linux machine, your library cannot follow. Decide this before you commit. +- **No mandatory account.** It should work offline, immediately, without signing in. + +## massCode on macOS + +[massCode](https://github.com/massCodeIO/massCode) is a free, open-source, local-first developer workspace that runs natively on macOS (Apple Silicon and Intel), and also on Windows and Linux — so your library is never tied to one operating system. + +- **Local Markdown Vault.** Every snippet and note is a plain `.md` file with frontmatter on your disk. Read, edit, and back it up with any tool. See [Storage](/documentation/storage). +- **Real organization.** Nested [folders](/documentation/code/folders), multiple [tags](/documentation/code/tags) per snippet, and [fragments](/documentation/code/fragments) — tabs inside one snippet for several languages or versions. +- **Full-text search** across snippet names, descriptions, and the contents of every fragment, plus your notes. +- **Keyboard-first.** Open the [Command Palette](/documentation/command-palette) with Cmd+P to jump to any snippet, or scope with `@code`, a `#tag`, or a `/folder`. +- **Plays well with the Mac.** massCode ships a [Raycast extension](https://www.raycast.com/antonreshetov/masscode), so you can search your massCode snippets straight from Raycast. +- **Yours to keep.** [AGPL v3](https://github.com/massCodeIO/massCode/blob/master/LICENSE), no account, no telemetry login. Sync the vault yourself with iCloud, Dropbox, or [any service you trust](/documentation/sync). +- **Easy to adopt.** Import from VS Code snippets, Raycast snippets, SnippetsLab, public GitHub Gists, and Obsidian. + +## Other macOS options + +The Mac has strong alternatives, and the honest answer is that some fit certain workflows better: + +- **SnippetsLab** — a polished, macOS-only native app, free as of 2026. Great if you never leave the Apple ecosystem. See [massCode vs SnippetsLab](/compare/snippetslab). +- **Raycast Snippets** — best as a system-wide text expander from the Raycast launcher, rather than a long-form library. See [massCode vs Raycast Snippets](/compare/raycast). +- **Pieces** — an AI-first option if you want a copilot over your snippets. See [massCode vs Pieces](/compare/pieces). + +For the full side-by-side, see [Best code snippet managers](/compare/best-code-snippet-managers). + +## Frequently asked questions + +### What is the best code snippet manager for Mac? + +It depends on whether you stay on macOS. If you want a polished macOS-only app, SnippetsLab is a strong, free choice. If you want a free, local-first library that also works on Windows and Linux and stores snippets as plain files you own, [massCode](/download/) is the better fit. + +### Is there a free snippet manager for Mac? + +Yes. massCode is free and open source, and SnippetsLab is free as of 2026. massCode additionally keeps your data as plain Markdown files and runs on Windows and Linux too. + +### Do I have to choose a macOS-only app? + +No, and it is worth thinking twice before you do. A macOS-only app cannot follow you to another OS. A cross-platform, local-first tool like massCode keeps the same library available everywhere and stored as files you control. + +## Try massCode on your Mac + +[Download massCode](/download/) for macOS — it is free, open source, and stores your snippets as plain files you own, on the Mac and everywhere else you work. diff --git a/docs/website/compare/code-snippet-manager-for-windows.md b/docs/website/compare/code-snippet-manager-for-windows.md new file mode 100644 index 000000000..ef1e1482b --- /dev/null +++ b/docs/website/compare/code-snippet-manager-for-windows.md @@ -0,0 +1,58 @@ +--- +title: Code Snippet Manager for Windows +description: "What to look for in a code snippet manager on Windows, and why massCode is a strong choice — a free, open-source, local-first app that stores snippets as plain Markdown files and runs natively on Windows, macOS, and Linux." +--- + +# Code Snippet Manager for Windows + +Windows developers get a shorter list of good snippet managers than Mac users do. Several of the most polished apps — SnippetsLab, for example — are macOS-only, so a lot of "best snippet manager" advice simply does not apply on Windows. The good news: the strongest local-first options are cross-platform, and they run natively on Windows. + +This page covers what to look for on Windows and where [massCode](/download/) fits. + +## What to look for on Windows + +- **Actually runs on Windows.** Check first — many recommendations are Mac-only. You want a native Windows build, not a workaround. +- **Local storage you own.** Snippets should be files on your disk, readable without the app, not locked in a database or a cloud account. +- **Fast search and real organization.** Folders, tags, and full-text search are what make a snippet library usable as it grows. +- **No mandatory account.** It should work offline and immediately, without signing in. +- **Cross-platform, ideally.** If you ever use a Mac or Linux box, a tool that runs everywhere keeps one library instead of several. + +## massCode on Windows + +[massCode](https://github.com/massCodeIO/massCode) is a free, open-source, local-first developer workspace with a native Windows build (and macOS and Linux too). It is one of the few local-first snippet managers that treats Windows as a first-class platform. + +- **Local Markdown Vault.** Every snippet and note is a plain `.md` file with frontmatter on your disk. Read, edit, and back it up with any tool. See [Storage](/documentation/storage). +- **Real organization.** Nested [folders](/documentation/code/folders), multiple [tags](/documentation/code/tags) per snippet, and [fragments](/documentation/code/fragments) — tabs inside one snippet for several languages or versions. +- **Full-text search** across snippet names, descriptions, and the contents of every fragment, plus your notes. +- **Keyboard-first.** Open the [Command Palette](/documentation/command-palette) with Ctrl+P to jump to any snippet, or scope with `@code`, a `#tag`, or a `/folder`. +- **Yours to keep.** [AGPL v3](https://github.com/massCodeIO/massCode/blob/master/LICENSE), no account, no telemetry login. Sync the vault yourself with OneDrive, Dropbox, Google Drive, or [any service you trust](/documentation/sync). +- **Easy to adopt.** Import from VS Code snippets, Raycast snippets, SnippetsLab, public GitHub Gists, and Obsidian. + +## Other Windows options + +The field is narrower than on macOS, but you do have choices: + +- **Pieces** — cross-platform and AI-first, if you want a copilot over your snippets. See [massCode vs Pieces](/compare/pieces). +- **Cacher** — cross-platform and cloud-based, good for hosted team libraries if you are comfortable with an account. See [massCode vs Cacher](/compare/cacher). +- **VS Code snippets** — built into the editor and available on Windows, fine for templated code you insert by prefix, but not a searchable library. +- **SnippetsLab** — frequently recommended, but **macOS-only**, so it is not an option on Windows. + +For the full side-by-side, see [Best code snippet managers](/compare/best-code-snippet-managers). + +## Frequently asked questions + +### What is the best code snippet manager for Windows? + +For a free, local-first library that stores snippets as plain files you own and runs natively on Windows (and macOS and Linux), [massCode](/download/) is a strong choice. If you want AI features, Pieces is cross-platform; for a hosted team library, Cacher works on Windows. + +### Is there a free snippet manager for Windows? + +Yes. massCode is free and open source, runs natively on Windows, and stores your data as plain Markdown files on your disk. VS Code's built-in snippets are also free for in-editor use. + +### Why are so many snippet managers Mac-only? + +Several popular ones (such as SnippetsLab) are built specifically for the Apple ecosystem. That is why Windows developers should check platform support first and lean toward cross-platform, local-first tools that treat Windows as a first-class target. + +## Try massCode on Windows + +[Download massCode](/download/) for Windows — it is free, open source, and stores your snippets as plain files you own, on Windows and everywhere else you work. diff --git a/docs/website/compare/github-gist.md b/docs/website/compare/github-gist.md new file mode 100644 index 000000000..259a895eb --- /dev/null +++ b/docs/website/compare/github-gist.md @@ -0,0 +1,100 @@ +--- +title: massCode vs GitHub Gist +description: "An honest comparison between massCode and GitHub Gist as a snippet manager. Local-first Markdown workspace with folders and full-text search vs cloud-hosted Git-backed gists. When each one is the better choice, and how to import your Gists into massCode." +--- + +# massCode vs GitHub Gist + +[GitHub Gist](https://gist.github.com) is the snippet tool millions of developers already have, because they already have a GitHub account. It is free, instantly shareable, and every gist is a real Git repository. Many people use it as their de facto snippet manager. + +massCode is a free, open-source, local-first developer workspace where snippets, notes, HTTP requests, math, drawings, and dev tools live as plain Markdown files on your own disk. + +The two solve overlapping problems from opposite directions. Gist is a cloud service optimized for sharing individual snippets. massCode is a local library optimized for organizing and searching the snippets you keep. If you mainly share one-off snippets with other people, Gist is the natural fit. If you want a private, organized, searchable library you own, massCode is the natural fit. Many developers use both. + +## At a glance + +| | massCode | GitHub Gist | +| --- | --- | --- | +| Type | Desktop app / local library | Cloud service | +| License | Open source (AGPL v3) | Proprietary service | +| Pricing | Free | Free | +| Data location | Local Markdown Vault on your disk | GitHub's servers | +| Account required | No | Yes (GitHub account) | +| Works offline | Yes | No (needs github.com) | +| Organization | Folders, tags, fragments | None (a flat list of gists) | +| Search | Full-text across snippets and notes (HTTP by name and URL) | Public gists only; secret gists searchable only by you when signed in | +| Privacy | Files stay on your machine | Public, or "secret" (unlisted, not private) | +| Version history | File-level (via your own Git/sync if you want it) | Built in — every gist is a Git repository | +| Sharing | File-level (Git, shared folder) | A core strength — share or embed any gist by URL | +| Scope | Snippets, notes, HTTP, math, drawings, tools | Snippets only | + +Sources: [GitHub Docs — Creating gists](https://docs.github.com/en/get-started/writing-on-github/editing-and-sharing-content-with-gists/creating-gists). + +## What GitHub Gist is genuinely good at + +Gist earns its popularity. It is worth being clear about where it wins: + +- **Zero setup.** If you have a GitHub account, you already have Gist. Nothing to install. +- **Sharing and embedding.** Paste a snippet, get a URL, send it or embed it in a blog post. This is the thing Gist does better than almost anything else. +- **Real Git under the hood.** "Every gist is a Git repository, which means that it can be forked and cloned." You get full commit history and diffs for free. +- **Collaboration.** Gists can be forked and commented on, which makes them good for quick public collaboration. + +If your main job is *publishing* snippets for other people to see, Gist is hard to beat. + +## Where Gist falls short as a snippet library + +The trouble starts when you try to use Gist as the place you *keep and organize* your own snippets: + +- **No folders, no tags.** Gists are a flat list. There is no built-in way to group them into folders or organize them with tags, so a growing library gets hard to navigate. +- **Weak search.** Public gists are searchable, but "secret gists don't show up in Discover and are not searchable unless you are logged in and are the author." There is no fast full-text search across your whole library the way a dedicated app provides. +- **"Secret" is not private.** GitHub is explicit: "Secret gists aren't private. If you send the URL of a secret gist to a friend, they'll be able to see it." Anyone who discovers the URL can read it. As of November 2025, GitHub also reports secrets found in unlisted gists to secret-scanning partners. For anything sensitive, a gist is the wrong place. +- **Online only.** Gist needs github.com. There is no offline-first local copy you browse without a connection. +- **Your data lives on someone else's server.** It is convenient, but it is not a local file you own and can read without the service. +- **Snippets only.** Gist does not cover notes, HTTP requests, math, or the rest of a developer's day. + +## How massCode is different + +massCode is built to be the library, not the publishing channel: + +- **Local-first.** Every snippet and note is a plain `.md` file in a [Markdown Vault](/documentation/storage) on your disk. You can read, edit, and back it up without the app, and it works fully offline. +- **Real organization.** [Folders](/documentation/code/folders), tags, and multi-tab [fragments](/documentation/code/fragments) for snippets that belong together. +- **Full-text search** across snippets and notes (HTTP requests are searched by name and URL), instantly and locally. +- **Private by default.** Nothing leaves your machine unless you choose a sync service. +- **More than snippets.** [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/) in one app. +- **No account.** It works without registering for anything. + +The trade-off is honest: massCode does not give you Gist's one-click public sharing and embedding. If publishing snippets to the web is your main need, keep using Gist for that. + +## Migrating from GitHub Gist to massCode + +You do not have to choose blindly. massCode can import a public GitHub Gist directly by URL, so you can pull existing snippets into an organized, local library and try it without retyping anything. Paste the Gist URL into massCode's import flow and the snippet lands in your vault as a Markdown file you can then file into folders and tag. See [the Code library docs](/documentation/code/library) for import details. + +A common setup: keep massCode as your private, searchable library, and publish to Gist only the specific snippets you want to share. + +## When each one is the better choice + +- **Choose GitHub Gist** if your primary need is sharing or embedding individual snippets, you want zero setup, and you are comfortable with cloud storage tied to your GitHub account. +- **Choose massCode** if you want a private, organized, searchable library of your own snippets, stored as plain files on your disk, that works offline and also handles notes, HTTP, and math. +- **Use both** if, like many developers, you want a local library for everything and Gist for the handful of snippets you publish. + +## Frequently asked questions + +### Is GitHub Gist a good snippet manager? + +For *sharing* snippets, yes — it is excellent. For *organizing and searching your own growing library*, it is limited: there are no folders or tags, search is weak for your private gists, and it is online-only. Many developers outgrow Gist as a primary library and move to a dedicated app while still using Gist for sharing. + +### Are secret gists private? + +No. Per GitHub, "Secret gists aren't private" — they are unlisted, meaning anyone with the URL can view them. For anything sensitive, use a private repository or a local-first tool like massCode instead. + +### What is a good GitHub Gist alternative? + +If you want your snippets organized and searchable on your own disk, [massCode](/download/) is a free, open-source, local-first alternative that can import your existing public Gists by URL. For other options, see [Best code snippet managers](/compare/best-code-snippet-managers). + +### Can I import my GitHub Gists into massCode? + +Yes. massCode imports public GitHub Gist URLs, so you can bring existing snippets into a local, organized library. See [the Code library docs](/documentation/code/library). + +## Try massCode + +If you have been using Gist as a snippet library and feel the lack of folders, search, and privacy, a local-first workspace is the natural next step. [Download massCode](/download/) and import a Gist URL to see how it feels to own your library. diff --git a/docs/website/compare/index.md b/docs/website/compare/index.md index f501af2d6..076db3adf 100644 --- a/docs/website/compare/index.md +++ b/docs/website/compare/index.md @@ -1,25 +1,39 @@ --- title: Compare massCode -description: "Honest comparisons between massCode and other snippet managers and developer workspaces — Pieces, Cacher, SnippetsLab, Raycast Snippets, and more." +description: "Honest comparisons between massCode and other snippet managers, note apps, and task managers — Pieces, Cacher, SnippetsLab, Raycast, Todoist, TickTick, Things 3, Apple Reminders, Obsidian Tasks, and more." --- # Compare massCode -massCode is a free, open-source, local-first developer workspace. Snippets, markdown notes, HTTP requests, calculations, and dev tools live in one app, with your data stored as plain Markdown files on disk. +massCode is a free, open-source, local-first developer workspace. Snippets, markdown notes, tasks, HTTP requests, calculations, drawings, and dev tools live in one app, with your data stored as plain Markdown files on disk. This section helps you decide whether massCode fits your workflow, or whether another tool is a better match. The comparisons are written to be honest, not aggressive: every tool here has cases where it is the right answer. -## Versus specific tools +## Snippet managers and developer workspaces - [massCode vs Pieces](/compare/pieces) — local-first workspace vs AI-first cloud snippet manager - [massCode vs Cacher](/compare/cacher) — open-source local app vs team-oriented cloud SaaS - [massCode vs SnippetsLab](/compare/snippetslab) — cross-platform vs macOS-only - [massCode vs Raycast Snippets](/compare/raycast) — full snippet workspace vs launcher text expansion +- [massCode vs GitHub Gist](/compare/github-gist) — local-first organized library vs cloud Git-backed gists +- [massCode vs Postman](/compare/postman) — lightweight local-first API client vs full cloud API platform + +## Task managers + +- [massCode vs Todoist](/compare/todoist) — markdown tasks inside a dev workspace vs cloud task manager +- [massCode vs TickTick](/compare/ticktick) — markdown tasks vs cloud task manager with Pomodoro and habits +- [massCode vs Things 3](/compare/things) — cross-platform open-source vs premium Apple-only +- [massCode vs Apple Reminders](/compare/apple-reminders) — cross-platform dev workspace vs free Apple built-in +- [massCode vs Obsidian Tasks](/compare/obsidian-tasks) — task-as-note with frontmatter vs checkbox-with-emoji in an Obsidian vault ## Category and positioning pages +- [Best code snippet managers](/compare/best-code-snippet-managers) — the leading snippet managers compared on storage, license, platforms, price, and scope +- [Code snippet manager for Mac](/compare/code-snippet-manager-for-mac) — what to look for on macOS, and how massCode fits +- [Code snippet manager for Windows](/compare/code-snippet-manager-for-windows) — the best local-first options on Windows - [Best open-source snippet manager](/compare/best-open-source) — what to look for, and how the open-source options stack up - [Local-first alternative to Pieces and Cacher](/compare/local-first) — keeping snippets and notes on your own disk +- [Markdown-first task manager for developers](/compare/markdown-first-tasks) — why tasks belong as plain `.md` files next to your notes and snippets ## Quick positioning @@ -27,14 +41,18 @@ This section helps you decide whether massCode fits your workflow, or whether an - If you need **AI assistants and copilots** built into your snippet flow, look at Pieces. - If you need a **native macOS-only** snippet app and stay on Apple devices, look at SnippetsLab. - If you need a **launcher with text expansion**, look at Raycast Snippets. -- If you need a **free, open-source, cross-platform, local-first workspace** that goes beyond snippets — snippets, notes, HTTP requests, math, and dev tools in one app — that is where massCode fits. +- If you need a **full-featured cross-device task manager** with reminders, recurring tasks, mobile apps, and AI assists, look at Todoist or TickTick. +- If you want a **premium Apple-only task manager** with a polished native UI, look at Things 3. +- If you only need a **free Apple-built-in task app**, Apple Reminders is already on your devices. +- If your notes already live in **Obsidian** and you want tasks scattered through that vault, look at the Obsidian Tasks plugin. +- If you need a **free, open-source, cross-platform, local-first workspace** that goes beyond snippets — snippets, notes, tasks, HTTP requests, math, drawings, and dev tools in one app — that is where massCode fits. ## What you get with massCode - Free and open source under [AGPL v3](https://github.com/massCodeIO/massCode/blob/master/LICENSE) - Local-first [Markdown Vault](/documentation/storage) — your data stays as plain `.md` files on disk - Cross-platform: macOS, Windows, Linux -- More than snippets: [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Tools](/documentation/tools/) +- More than snippets: [Code](/documentation/code/library), [Notes](/documentation/notes/), [Tasks](/documentation/notes/tasks), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), [Tools](/documentation/tools/) - Built-in imports for VS Code snippets JSON, Raycast snippets JSON, SnippetsLab JSON, public GitHub Gist URLs, and Obsidian markdown folders - Sync via any service you already trust — iCloud, Dropbox, Google Drive, Syncthing, or [Git](/documentation/sync) - No account, no telemetry-required login, no vendor lock-in diff --git a/docs/website/compare/markdown-first-tasks.md b/docs/website/compare/markdown-first-tasks.md new file mode 100644 index 000000000..127850101 --- /dev/null +++ b/docs/website/compare/markdown-first-tasks.md @@ -0,0 +1,91 @@ +--- +title: Markdown-First Task Manager for Developers +description: "Why a developer's tasks belong as plain Markdown files next to snippets and notes — and how massCode tasks compare to Todoist, TickTick, Things 3, Apple Reminders, and Obsidian Tasks." +--- + +# Markdown-First Task Manager for Developers + +Most task managers store your work in a cloud database you cannot read without the app. For everyday personal logistics that is fine. For engineering work it is awkward: a task is rarely just "buy milk" — it is "investigate this bug, here is the failing test, here is the code, here is the link to the ticket, due next Monday." That note belongs next to the code, not in a separate silo. + +A **markdown-first** approach keeps the task as a plain `.md` file on your disk. The body of the file is the work-in-progress: code blocks, mermaid diagrams, internal links, checklists. The frontmatter carries the structured bits: status, priority, due date. If the app disappears tomorrow, the files are still readable. + +[massCode](https://github.com/massCodeIO/massCode) treats [tasks as notes](/documentation/notes/tasks) with that frontmatter. The [Obsidian Tasks](https://github.com/obsidian-tasks-group/obsidian-tasks) plugin takes a different markdown-first route, encoding metadata as inline emojis inside checklist lines. Everything else in this guide stores tasks in a proprietary database. + +## What "markdown-first" actually means + +Markdown-first is a stricter bar than "exports to markdown": + +- **The task lives on your disk as a plain `.md` file** with human-readable frontmatter — no proprietary database, no binary blob. +- **The app works fully offline by default.** No login wall, no degraded mode. +- **Sync is optional and your choice of provider.** iCloud, Dropbox, Google Drive, Syncthing, Git — whatever you already trust. +- **The format outlives the app.** If the vendor disappears, your tasks are still grep-able files. + +A cloud SaaS that exports tasks to CSV is not markdown-first. A local app with a SQLite store is closer, but still not markdown-first if you cannot read the data without the app. + +## Why developers want markdown-first tasks + +- **A task and its context are the same file.** No copy-paste between Slack, Notion, Jira, and your local notes. The bug investigation, the code snippet, the failing test, the link — all in one `.md` file with a status and a due date. +- **Diffable in Git.** A markdown vault works with the same review and history tooling as your code. +- **Vendor durability.** Snippet managers and task apps come and go. Plain Markdown survives. +- **Sync control.** Most developers already pay for or self-host one sync solution. Adding another vendor on top is duplication. +- **Privacy and compliance.** Engineering tasks often include real code and real customer context. Plain files on your disk are easier to keep on your disk. + +## How the popular options stack up + +| | Storage | License | Platforms | Recurring | Mobile | Best at | +| --- | --- | --- | --- | --- | --- | --- | +| **massCode** | Plain `.md` in your vault | AGPL v3, free | macOS, Windows, Linux | No | No | Developer workspace with tasks alongside snippets and HTTP | +| **Obsidian Tasks** | Plain `.md` in your Obsidian vault | MIT, free (host: Obsidian) | macOS, Windows, Linux, iOS, Android | Yes | Yes | Tasks scattered through an Obsidian knowledge base | +| **Todoist** | Cloud database | Proprietary | macOS, Windows, Linux, iOS, Android, web, more | Yes | Yes | Cross-device personal and team task manager | +| **TickTick** | Cloud database | Proprietary | macOS, Windows, Linux, iOS, Android, web, more | Yes | Yes | All-in-one tasks + Pomodoro + habits | +| **Things 3** | Local + Things Cloud | Proprietary | Apple only | Yes | Yes | Best-in-class native Apple task manager | +| **Apple Reminders** | iCloud | Proprietary, built-in | Apple only (view-only web) | Yes | Yes | Free default for Apple users with location and Siri | + +The two markdown-first options work very differently: + +- **Obsidian Tasks**: a task is a checklist line (`- [ ] Do thing 📅 2026-06-01 ⏫ 🔁 every week`) inside any note in your Obsidian vault. Metadata is encoded with inline emojis. Views are built with a custom query language. Mobile is supported through the Obsidian apps. +- **massCode**: a task is a whole note with frontmatter properties (`type: task`, `status: todo`, `priority: high`, `due: 2026-06-01`). The note body is the work-in-progress. Views are first-class: **Tasks**, **Today**, **Upcoming**, **Completed**. Desktop only. + +Neither is "better" — they fit different workflows. + +## When to pick which + +- **Pick massCode** if you want a standalone, cross-platform developer workspace where tasks are first-class notes that live next to snippets, HTTP requests, math sheets, and dev tools — all stored as plain Markdown on your own disk. +- **Pick Obsidian Tasks** if your knowledge base is already in Obsidian and you want tasks distributed through that graph, with recurring rules and a powerful query language. +- **Pick a cloud task manager** (Todoist, TickTick, Things, Reminders) if you primarily need reminders, recurring tasks, mobile apps, and shared lists, and the loss of plain-text durability is acceptable. + +## What you get with massCode tasks + +- A task is a `.md` note in your [Markdown Vault](/documentation/storage) with `status`, `priority`, and `due` in frontmatter +- Four statuses: Todo, In Progress, Done, Blocked +- Three priority levels plus None +- Dedicated views: **Tasks**, **Today**, **Upcoming**, **Completed** +- Same markdown editor as regular notes — checklists, code blocks, mermaid, mindmap, internal links +- Folders, tags, favorites, and search apply to tasks like any other note +- A [Notes Graph](/documentation/notes/dashboard) visualizes how notes (including tasks) connect through internal links +- Lives in the same app as [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/) +- Free and open source under [AGPL v3](https://github.com/massCodeIO/massCode/blob/master/LICENSE) +- Cross-platform: macOS, Windows, Linux +- No account, no telemetry-required login, sync via the service you already trust + +## Honest limits + +A markdown-first task list is not a replacement for a full task manager. Be aware: + +- **No reminders or notifications in massCode.** Due dates are visible in the list and the Today / Upcoming views, but the app does not push alerts. +- **No recurring tasks in massCode.** A task has a single `due` date. +- **No first-class subtasks in massCode.** Checklists inside the body are markdown, not separate entities. +- **No mobile in massCode.** Desktop only. +- **No team workspace in massCode.** Sharing happens at the file layer. + +These are the price of markdown-first storage. If they are dealbreakers, a cloud task manager is the better fit. + +## Related comparisons + +- [massCode vs Todoist](/compare/todoist) +- [massCode vs TickTick](/compare/ticktick) +- [massCode vs Things 3](/compare/things) +- [massCode vs Apple Reminders](/compare/apple-reminders) +- [massCode vs Obsidian Tasks](/compare/obsidian-tasks) + +[Download massCode](/download/) and try it on a few in-flight work notes. diff --git a/docs/website/compare/obsidian-tasks.md b/docs/website/compare/obsidian-tasks.md new file mode 100644 index 000000000..60909e2d6 --- /dev/null +++ b/docs/website/compare/obsidian-tasks.md @@ -0,0 +1,78 @@ +--- +title: massCode vs Obsidian Tasks +description: "An honest comparison between massCode tasks and the Obsidian Tasks plugin. Markdown-first developer workspace vs Obsidian-vault checkbox queries with emoji metadata." +--- + +# massCode vs Obsidian Tasks + +The [Obsidian Tasks](https://github.com/obsidian-tasks-group/obsidian-tasks) plugin and massCode are the closest peers in this comparison set. Both store tasks as plain Markdown on your disk. They differ in how a "task" is modeled and what surrounds it. + +- **Obsidian Tasks** turns standard markdown checkboxes (`- [ ]`) into queryable tasks. Metadata — due date, priority, recurrence — is added inline through emojis (📅, ⏫, 🔁). Tasks can live in any note across your vault and are surfaced through query blocks. The plugin is MIT-licensed and free, and runs inside [Obsidian](https://obsidian.md), which is free for personal use. +- **massCode** treats tasks as [notes with structured properties](/documentation/notes/tasks). A note has a `type: task` flag with `status`, `priority`, and `due` properties in its frontmatter. The whole note is the task; checklists inside the body are sub-items of that note. massCode is AGPL v3 and free. + +If you already live in Obsidian for note-taking and want tasks dispersed across that vault, Obsidian Tasks is the more natural fit. If you want a developer workspace where tasks share the app with snippets, HTTP requests, and dev tools, massCode is the more natural fit. + +## At a glance + +| | massCode | Obsidian Tasks plugin | +| --- | --- | --- | +| License | AGPL v3 | MIT | +| Pricing | Free | Free; Obsidian is free for personal use, $50/year commercial license encouraged | +| Host application | Standalone app | Plugin inside Obsidian | +| Data location | Local Markdown Vault | Local Obsidian vault | +| Platforms | macOS, Windows, Linux | Obsidian on macOS, Windows, Linux, iOS, Android | +| Task unit | One note = one task | One checklist line in any note = one task | +| Statuses | Todo, In Progress, Done, Blocked | Customizable status characters (default: open / done) | +| Priority | None, Low, Medium, High | Priority via emoji (🔺 highest → ⏬ lowest) | +| Due dates | Calendar picker | Inline emoji shorthand (📅 YYYY-MM-DD) | +| Recurring tasks | No | Yes (🔁) | +| Subtasks | Markdown checklist inside the note body | Checklist items nest under each other | +| Filtering / views | Tasks, Today, Upcoming, Completed | Query blocks with a custom query language | +| Beyond tasks | Snippets, HTTP, math, tools, mermaid, mindmap, presentation | Whatever the Obsidian ecosystem provides | +| Sync | iCloud, Dropbox, Google Drive, Syncthing, Git | File sync of choice, or paid Obsidian Sync | + +Sources: [Obsidian Tasks repository](https://github.com/obsidian-tasks-group/obsidian-tasks), [Obsidian pricing](https://obsidian.md/pricing), [Obsidian download](https://obsidian.md/download). + +## Where Obsidian Tasks fits better + +Obsidian Tasks is a strong choice when your notes are already in Obsidian and you want tasks scattered through that knowledge graph. + +- **You already use Obsidian.** Tasks live wherever they are written, inside any note in your vault. You do not need a separate "tasks list." +- **You want recurring tasks.** The plugin supports recurrences natively through the 🔁 shorthand. +- **You want fully customizable statuses.** You can define your own status characters and meanings beyond open/done. +- **You want a powerful query language.** Query blocks let you assemble views of tasks from across the vault — by tag, folder, due date, priority, or status — in any note. +- **You want mobile.** Obsidian has iOS and Android apps, so the plugin works on phones and tablets. +- **You already pay for Obsidian Sync** or have set up Obsidian sync the way you like. + +## Where massCode fits better + +massCode is a strong choice when "task" means "a piece of in-flight engineering work" more than "a checklist line in my zettelkasten." + +- **A task is a whole note.** You can give it a title, a markdown body with code snippets, mermaid diagrams, mindmaps, internal links, and one structured status / priority / due. See [Notes Tasks](/documentation/notes/tasks). +- **One app for snippets, notes, HTTP, math, and tools.** Tasks share the app with [Code](/documentation/code/library), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). You do not need to assemble a plugin stack. +- **Structured frontmatter, not emoji shorthand.** Status, priority, and due are stored as fields in the note's frontmatter, not as inline emojis in the body. +- **Dedicated task views.** **Tasks**, **Today**, **Upcoming**, and **Completed** are first-class navigation, not query blocks you have to write. +- **No host application to install.** massCode is standalone — it does not require Obsidian. +- **AGPL source on [GitHub](https://github.com/massCodeIO/massCode).** Free for personal and commercial use under the AGPL. + +## Honest trade-offs + +- **No queries across the vault.** massCode has fixed views (Tasks, Today, Upcoming, Completed) plus folders, tags, and search. There is no equivalent to Obsidian Tasks' query language. +- **No recurring tasks.** Obsidian Tasks has 🔁; massCode does not. +- **No first-class subtasks.** A markdown checklist inside the task body is just markdown — those items are not separate task entities. +- **No mobile.** Obsidian runs on iOS and Android; massCode is desktop-only. +- **No customizable status set.** massCode has a fixed `Todo / In Progress / Done / Blocked`; Obsidian Tasks lets you define your own. +- **Smaller plugin ecosystem.** massCode ships a [Notes Graph](/documentation/notes/dashboard), [internal links](/documentation/notes/internal-links), [mindmap](/documentation/notes/mindmap), [mermaid](/documentation/notes/mermaid), and a [presentation mode](/documentation/notes/presentation) out of the box, but it does not have Obsidian's community plugin ecosystem (Dataview, Canvas, and the rest). + +## Who should pick which + +- Pick **Obsidian Tasks** if your knowledge base already lives in Obsidian, and you want tasks distributed throughout that graph with recurring rules, custom statuses, and powerful queries. +- Pick **massCode** if you want a standalone developer workspace where tasks are first-class notes with structured properties, and they live alongside your snippets and HTTP requests. + +## Using both + +These tools coexist well. You can keep deep, long-form knowledge work in Obsidian with the Tasks plugin, and use massCode for engineering work-in-progress — short-lived tasks that are really "a note with a status, a priority, and a due date" plus a code snippet or HTTP request. + +If you want to start moving Obsidian markdown into massCode, the Notes space has a built-in import for Obsidian markdown folders. See [Storage](/documentation/storage). + +[Download massCode](/download/) and try it on a few in-flight work notes. diff --git a/docs/website/compare/pieces.md b/docs/website/compare/pieces.md index 9f7a9c988..77ab64ff3 100644 --- a/docs/website/compare/pieces.md +++ b/docs/website/compare/pieces.md @@ -43,7 +43,7 @@ Pieces is a strong choice when AI is the point, not a side feature. massCode is a strong choice when your priority is owning your data, staying on your own machine, and consolidating several developer tools into one workspace. - **You want plain Markdown files on disk.** massCode stores everything in a [Markdown Vault](/documentation/storage). Each snippet and note is a `.md` file with frontmatter — readable in any editor, easy to back up. -- **You want one workspace, not five apps.** massCode includes [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), and [Tools](/documentation/tools/). Pieces focuses on AI-augmented snippets and memory. +- **You want one workspace, not five apps.** massCode includes [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). Pieces focuses on AI-augmented snippets and memory. - **You want a migration path into plain files.** massCode can import VS Code snippets JSON, Raycast snippets JSON, SnippetsLab JSON, public GitHub Gist URLs, and Obsidian markdown folders into your local vault. - **You want full transparency.** The source is on [GitHub](https://github.com/massCodeIO/massCode) under AGPL v3 — read, audit, and self-build it. - **You want sync without a vendor.** Point [iCloud, Dropbox, Google Drive, Syncthing, or a Git repo](/documentation/sync) at your vault directory. There is no required account. @@ -72,3 +72,21 @@ If you want to consolidate into massCode: 4. Move longer pieces of context into [Notes](/documentation/notes/) and link between them. [Download massCode](/download/) and try it on a copy of your data. + +## Frequently asked questions + +### Is massCode a good Pieces alternative? + +Yes, if you want a focused, local-first workspace rather than an AI copilot. Pieces is built around AI and long-term memory of your work. massCode is a free, open-source app that stores snippets, notes, HTTP requests, and math as plain Markdown files on your disk, with no AI layer in the way. + +### Does massCode have AI features? + +No. massCode has no built-in AI. It is a deliberately focused workspace for keeping and finding your own snippets and notes. If an AI copilot over your snippets is what you want, Pieces is the better fit. + +### Can I import my Pieces snippets into massCode? + +There is no direct Pieces importer. The practical path is to export snippets into a format massCode reads — for example, public GitHub Gist URLs — and import those, or recreate the rest manually under [Code](/documentation/code/library). massCode also imports VS Code, Raycast, and SnippetsLab snippets. + +### Is my data local with massCode? + +Yes. Every snippet and note is a plain `.md` file in a local [Markdown Vault](/documentation/storage), with no account required. You choose whether and how to sync it. diff --git a/docs/website/compare/postman.md b/docs/website/compare/postman.md new file mode 100644 index 000000000..ca8b8353f --- /dev/null +++ b/docs/website/compare/postman.md @@ -0,0 +1,110 @@ +--- +title: massCode vs Postman +description: "An honest comparison between massCode and Postman. A full cloud-first API platform vs a lightweight, local-first API client that stores requests as plain files on your disk — and can import your Postman collections." +--- + +# massCode vs Postman + +[Postman](https://www.postman.com) and massCode both let you build and send HTTP requests, but they are not the same kind of tool. Postman is a full API development platform — collections, environments, mock servers, monitors, automated test runs, and team collaboration, built around a cloud account. massCode is a free, open-source developer workspace whose [HTTP space](/documentation/http/) is a lightweight, local-first API client that lives next to your snippets and notes and stores everything as plain files on your disk. + +If you need an end-to-end API platform for a team, Postman is the more capable tool. If you want a fast, local API client for saving and sending requests during development — with no account and your data as files you own — massCode is the more natural fit. And because massCode imports Postman collections, you do not have to start over. + +## At a glance + +| | massCode | Postman | +| --- | --- | --- | +| Category | Local-first developer workspace with an HTTP client | Full cloud-first API platform | +| License | Open source (AGPL v3) | Proprietary | +| Pricing | Free | Free tier (limited); paid plans from ~$14/user/mo (2026) | +| Data location | Local Markdown Vault on your disk | Cloud workspace; local-only via the lightweight client | +| Account | Not required | Optional — full features and sync need an account; a no-sign-in lightweight client exists | +| Works offline | Yes, fully | Lightweight client works offline; synced workspaces need the cloud | +| Platforms | macOS, Windows, Linux | macOS, Windows, Linux, web, CLI | +| Request features | Method, URL, params, headers, body, auth, markdown description | Full request editor with extensive auth and protocol support | +| Environments | Yes, {{variables}} | Yes, with broader scoping | +| Protocols | HTTP | HTTP, WebSocket, gRPC, GraphQL, and more | +| Mock servers / monitors / collection runner | No | Yes | +| Automated tests / scripting | No | Yes (JavaScript test scripts, Newman/CLI) | +| Team collaboration | File-level (shared Git or folder) | Built-in shared workspaces and governance | +| Imports | OpenAPI, Postman Collection v2.1, Postman Environment, Bruno | Postman formats, OpenAPI, and more | +| Other workspaces | Snippets, notes, math, drawings, tools | API-focused | + +Pricing and plan limits per [Postman pricing](https://www.postman.com/pricing/); account and offline behavior per [Postman docs](https://learning.postman.com/docs/getting-started/basics/using-api-client/). + +## Where Postman fits better + +Postman is a platform, and for platform work it is hard to match: + +- **Automated testing and CI.** JavaScript test scripts, the collection runner, and the Newman CLI let you run API tests in pipelines. +- **More protocols.** Beyond HTTP, Postman handles WebSocket, gRPC, and GraphQL in one place. +- **Mock servers and monitors.** Stand up mock endpoints and schedule uptime/behavior checks. +- **Team collaboration and governance.** Shared workspaces, role-based access, and API governance for organizations. + +If your work is API design, testing, and collaboration at team scale, Postman is built for exactly that. + +## Where massCode fits better + +massCode is not trying to be a platform. It is trying to be the fast, local place your requests live: + +- **Local-first, plain files.** Every request is stored in your [Markdown Vault](/documentation/storage) on disk, readable and Git-friendly, with no cloud dependency. +- **No account.** Open the app and send a request — nothing to sign into. +- **Free and open source.** [AGPL v3](https://github.com/massCodeIO/massCode/blob/master/LICENSE), with no per-seat pricing or free-tier caps. +- **Next to everything else.** Requests sit beside your [snippets](/documentation/code/library), [notes](/documentation/notes/), and [math](/documentation/math/), so API work stays in the same window as the rest of your day. +- **The essentials, done simply.** Method, URL, params, headers, body, auth, a markdown description per request, [environments](/documentation/http/environments) with {{variables}}, response preview, and copy as raw HTTP or cURL. +- **Almost nothing to relearn.** Postman has grown into a large platform, and developers who just want to send a request — especially when they come back to it after months away — often have to re-find their way around the interface. massCode's HTTP space is a short, flat set of panels. There is no platform to navigate, so picking it back up after a break takes seconds, not a tour. + +If you mainly save requests by project and fire them during development, this is less to manage than a full platform. + +## Honest trade-offs + +- **massCode is a lightweight client, not a platform.** No mock servers, monitors, collection runner, or scripted tests. If you need those, Postman wins. +- **HTTP only.** massCode's client handles HTTP requests; it does not cover WebSocket, gRPC, or GraphQL. +- **Plain-text storage.** Requests live as plain files, so do not store real passwords, tokens, or keys in requests or environments if your vault is synced, shared, or committed to Git. Use an external secret manager. +- **Newer and smaller.** The HTTP space arrived in massCode 5.3 and is intentionally focused, not feature-complete against a decade-old platform. + +## Who should pick which + +- Pick **Postman** if you need automated API testing, mock servers, multiple protocols, or managed team collaboration and governance. +- Pick **massCode** if you want a free, local-first API client for saving and sending requests, stored as plain files with no account, inside a workspace that also holds your snippets and notes. +- **Use both** if it suits you: Postman for heavy testing and team work, massCode as the quick local client next to your code. + +## Migrating from Postman to massCode + +You can bring your existing Postman work into massCode: + +1. In Postman, export a collection as **Collection v2.1 (JSON)**, and export your environment as JSON. +2. In massCode, open the [HTTP](/documentation/http/) space and choose import. +3. Select the exported Postman Collection and Environment files. massCode shows a preview with item counts, folders, and warnings for anything that has no equivalent (such as unsupported auth or scripts). +4. Import to write the requests into your Markdown Vault, then organize them into folders. + +See [Importing HTTP Collections](/documentation/http/importing) for the full workflow. [Download massCode](/download/) and try importing a collection on a copy first. + +## Frequently asked questions + +### Is massCode a good Postman alternative? + +For lightweight, local API work, yes. massCode is a free, open-source, local-first client that saves requests as plain files with no account. It is not a replacement for Postman's platform features — automated testing, mock servers, multiple protocols, and team governance — so it suits individual development more than large-scale API testing. + +### Is Postman too complicated for simple API testing? + +Postman is a full API platform, so its interface carries a lot of surface area — workspaces, collections, environments, mocks, monitors, runners, and more. If you only need to save and send a few requests, that breadth can feel like a lot to navigate, and returning users often have to reorient themselves. massCode's HTTP client is deliberately minimal, which makes it quicker for that narrower job. For heavy testing and automation, Postman's depth is the point. + +### Does massCode need an account like Postman? + +massCode never requires an account; your data stays in local files. Postman can be used without signing in through its lightweight API client, but full features and sync are tied to a Postman account. + +### Can I import my Postman collections into massCode? + +Yes. massCode imports Postman Collection v2.1 JSON and Postman Environment JSON, with a preview before anything is written to your vault. See [Importing HTTP Collections](/documentation/http/importing). + +### Does massCode support automated API testing? + +No. massCode's HTTP client is for building and sending requests and saving them by project. It does not include scripted tests, a collection runner, or monitors. If you need those, use Postman or a dedicated testing tool. + +### Is massCode free? + +Yes, massCode is free and open source under AGPL v3, with no per-seat pricing. As of 2026, Postman's free plan is limited to a single user with a monthly cap on collection runs, and team features require a paid plan. + +## Try massCode + +If you want a local-first API client that stores requests as plain files you own — and keeps them next to your snippets and notes — [download massCode](/download/) and import a Postman collection to see how it feels. diff --git a/docs/website/compare/raycast.md b/docs/website/compare/raycast.md index 45bb9ecdc..7626dc40e 100644 --- a/docs/website/compare/raycast.md +++ b/docs/website/compare/raycast.md @@ -53,7 +53,7 @@ massCode is a strong choice when your snippets are real code, not text shortcuts - **You want plain Markdown files on disk.** Snippets and notes live as `.md` files in a [Markdown Vault](/documentation/storage). Your library is portable, scriptable, and not locked behind another vendor's data store. - **You want to promote Raycast snippets into a larger library.** Export Raycast snippets JSON, preview it in massCode, then import it into Code for folders, tags, fragments, and long-term storage. - **You work on Windows or Linux.** massCode is a first-class app on macOS, Windows, and Linux. Raycast Snippets is macOS and iOS. -- **You want one workspace beyond snippets.** massCode adds [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), and [Tools](/documentation/tools/) in the same app. +- **You want one workspace beyond snippets.** massCode adds [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/) in the same app. - **You want sync without paying for it.** Point [iCloud, Dropbox, Google Drive, Syncthing, or Git](/documentation/sync) at your vault. Cloud sync in Raycast is a Pro feature. ## Honest trade-offs diff --git a/docs/website/compare/snippetslab.md b/docs/website/compare/snippetslab.md index e29e13b8e..32123c04e 100644 --- a/docs/website/compare/snippetslab.md +++ b/docs/website/compare/snippetslab.md @@ -45,7 +45,7 @@ SnippetsLab is a strong choice if you stay on macOS and want a single-purpose Ma massCode is a strong choice when you cross platforms or need more than snippets. - **You work on Windows or Linux too.** massCode runs on macOS, Windows, and Linux. Your snippets travel with you across all three. -- **You want one workspace, not five apps.** massCode includes [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), and [Tools](/documentation/tools/). SnippetsLab focuses on snippets. +- **You want one workspace, not five apps.** massCode includes [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). SnippetsLab focuses on snippets. - **You want plain Markdown files on disk.** massCode's [Markdown Vault](/documentation/storage) keeps everything as `.md` files with frontmatter — readable in any editor, easy to back up, trivial to put in Git. - **You want a direct import path from SnippetsLab.** Export SnippetsLab JSON, preview it in massCode, then import snippets, folders, tags, descriptions, and fragments into Code. - **You want sync that is not tied to iCloud.** massCode lets you point [iCloud, Dropbox, Google Drive, Syncthing, or Git](/documentation/sync) at the vault. That matters if you mix macOS with Windows or Linux machines. @@ -72,3 +72,21 @@ To move from SnippetsLab to massCode: 4. Move longer documentation into [Notes](/documentation/notes/) and link between notes and snippets. [Download massCode](/download/) and run it alongside SnippetsLab while you migrate. + +## Frequently asked questions + +### Is massCode a good SnippetsLab alternative? + +Yes, especially if you are not macOS-only. SnippetsLab is a polished, native, macOS-only app. massCode is a free, open-source, local-first workspace that runs on macOS, Windows, and Linux and stores snippets as plain Markdown files you own. + +### Can I import my SnippetsLab snippets into massCode? + +Yes. Export your library from SnippetsLab as JSON, then import it in massCode's [Code](/documentation/code/library) space. The import brings over snippets, folders, tags, descriptions, and fragments, with a preview before anything is written to your vault. + +### Is SnippetsLab or massCode better on Mac? + +If you only ever use a Mac and want the most native feel with automatic language detection and iCloud sync, SnippetsLab is excellent. If you want the same library on Windows or Linux too, or a single workspace that also covers notes, HTTP, and math, choose massCode. See also [Code snippet manager for Mac](/compare/code-snippet-manager-for-mac). + +### Is massCode free like SnippetsLab? + +Both are free. massCode is additionally open source under AGPL v3, and keeps your data as plain files rather than inside the app. diff --git a/docs/website/compare/things.md b/docs/website/compare/things.md new file mode 100644 index 000000000..754038061 --- /dev/null +++ b/docs/website/compare/things.md @@ -0,0 +1,72 @@ +--- +title: massCode vs Things 3 +description: "An honest comparison between massCode tasks and Things 3 by Cultured Code. Free cross-platform open-source workspace vs premium Apple-only task manager with one-time per-platform pricing." +--- + +# massCode vs Things 3 + +[Things 3](https://culturedcode.com/things/) by Cultured Code is a premium, Apple-only task manager with a polished native UI, free Things Cloud sync, and a one-time purchase per platform. massCode is a free, open-source, cross-platform developer workspace where [tasks are notes](/documentation/notes/tasks) — plain Markdown files in a vault on your own disk. + +If you live entirely on Apple devices and want a beautifully designed dedicated task manager, Things 3 is the more natural fit. If you work across macOS, Windows, and Linux, or you want your tasks to live as markdown next to your snippets and notes, massCode is the more natural fit. + +## At a glance + +| | massCode | Things 3 | +| --- | --- | --- | +| License | Open source (AGPL v3) | Proprietary | +| Pricing | Free | One-time per platform: Mac $49.99, iPad $19.99, iPhone + Watch $9.99, Vision Pro $29.99 | +| Data location | Local Markdown Vault on your disk | Local on each device, synced via Things Cloud | +| Sync | iCloud, Dropbox, Google Drive, Syncthing, Git — your choice | Things Cloud, free with the apps | +| Platforms | macOS, Windows, Linux | macOS, iPadOS, iOS, watchOS, visionOS — Apple only | +| Task model | Notes with `status`, `priority`, and `due` properties | Areas → Projects → To-dos with Headings and Checklists | +| Statuses | Todo, In Progress, Done, Blocked | Open / completed / canceled | +| Priority | None, Low, Medium, High | No numeric priority; uses Today / This Evening / When | +| Due dates | Yes, calendar picker | Yes, plus deadlines | +| Reminders | No | Yes | +| Recurring tasks | No | Yes | +| Subtasks | Markdown checklist inside the note body | Checklist items inside a to-do; structure for grouping via Projects | +| Notes inside a task | Full markdown editor with code, mermaid, mindmap, internal links | Markdown notes field | +| Mobile app | No | Yes | +| Account required | No | A free Things Cloud account is needed for sync | + +Sources for Things 3 platform pricing: [App Store listings](https://apps.apple.com/us/app/things-3/id904237743) for Mac, iPad, iPhone, and Vision Pro. Things Cloud sync is free with the apps — see [Cultured Code's pricing page](https://culturedcode.com/things/pricing/) and [Things Cloud support](https://culturedcode.com/things/support/articles/2803586/). + +## Where Things 3 fits better + +Things 3 is a strong choice when "best-in-class native task manager on Apple devices" is what you want. + +- **You are all-in on Apple.** Things runs natively on Mac, iPad, iPhone, Apple Watch, and Vision Pro, with deep integration of widgets, Shortcuts, and system features. +- **You want a polished, opinionated workflow.** Areas, Projects, Today, This Evening, Upcoming, Anytime, and Someday are designed around a specific way of planning. Many users find that opinionated structure is the point. +- **You want a one-time purchase.** No subscription. You pay per platform once and get future updates for free. +- **You want free sync without a third-party account.** Things Cloud is included. +- **You want reminders and recurring tasks.** Both are first-class. + +## Where massCode fits better + +massCode is a strong choice when Things' Apple-only constraint is a problem, or when you want your tasks to live with your notes and snippets. + +- **You work across operating systems.** massCode runs on macOS, Windows, and Linux from the same vault. +- **You want plain Markdown files on disk.** Every task is a `.md` file with frontmatter in your [Markdown Vault](/documentation/storage) — not stored in a proprietary database. +- **You want one workspace, not five apps.** Tasks share the app with [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). +- **You want sync your way.** Point [iCloud, Dropbox, Google Drive, Syncthing, or Git](/documentation/sync) at your vault. No required account, no required cloud. +- **You want free and open source.** AGPL v3 source on [GitHub](https://github.com/massCodeIO/massCode). + +## Honest trade-offs + +- **No mobile app in massCode.** Things runs on iPhone, iPad, Apple Watch, and Vision Pro; massCode is desktop-only. +- **No reminders or notifications in massCode.** Due dates are surfaced in the list and the **Today** / **Upcoming** views, but the app does not push reminders. +- **No recurring tasks in massCode.** A task has a single `due` date. +- **No first-class subtasks in massCode.** You can write a markdown checklist inside the task body, but those checklist items are not separate task entities. +- **No equivalent of Areas / This Evening / Anytime / Someday.** massCode's task views are simpler: **Tasks**, **Today**, **Upcoming**, **Completed**. +- **No award-winning native polish.** Things wins design awards for a reason; massCode is a developer-utility app, not a productivity flagship. + +## Who should pick which + +- Pick **Things 3** if you are an Apple-only user who wants a beautifully designed, opinionated task manager with reminders, recurring tasks, and free cloud sync. +- Pick **massCode** if you need a cross-platform, free, open-source workspace, and you want your tasks to live as plain markdown next to your snippets, notes, and HTTP requests. + +## Using both + +If you already use Things 3 for personal task management, you can keep it. massCode is not designed to replace a dedicated task manager. Use it where the "task" is really a working note — a piece of code, a research thread, a bug investigation, a meeting follow-up — that you want to track with a status, a priority, and a due date. + +[Download massCode](/download/) and try it on a few in-flight work notes. diff --git a/docs/website/compare/ticktick.md b/docs/website/compare/ticktick.md new file mode 100644 index 000000000..a7c4e4e97 --- /dev/null +++ b/docs/website/compare/ticktick.md @@ -0,0 +1,74 @@ +--- +title: massCode vs TickTick +description: "An honest comparison between massCode tasks and TickTick. Local-first markdown tasks inside a developer workspace vs cloud-first task manager with Pomodoro, habits, calendar, and Eisenhower matrix." +--- + +# massCode vs TickTick + +[TickTick](https://ticktick.com) is a cross-platform, cloud-first task manager that bundles a task list with a Pomodoro timer, habit tracker, calendar, Kanban, Timeline, and Eisenhower Matrix views. massCode is a free, open-source, local-first developer workspace where [tasks are notes](/documentation/notes/tasks) with a few structured properties — they live in the same Markdown Vault as your snippets and notes. + +If you want a single task app that also handles habits, time-boxing, and reviews on every device, TickTick is the more natural fit. If you want your tasks to live as plain Markdown next to your developer notes and snippets, massCode is the more natural fit. + +## At a glance + +| | massCode | TickTick | +| --- | --- | --- | +| License | Open source (AGPL v3) | Proprietary | +| Pricing | Free | Free; Premium $3.99/mo or $35.99/year | +| Data location | Local Markdown Vault on your disk | Cloud sync across devices | +| Platforms | macOS, Windows, Linux | macOS, Windows, Linux, web, iOS, Android, Apple Watch, Wear OS, browser extensions | +| Task model | Notes with `status`, `priority`, and `due` properties | Lists, sections, tasks, subtasks, tags | +| Statuses | Todo, In Progress, Done, Blocked | Open / completed | +| Priority | None, Low, Medium, High | Four priority levels | +| Due dates | Yes, calendar picker | Yes, with natural-language parsing | +| Reminders / notifications | No | Yes, including "annoying alert" repeats | +| Recurring tasks | No | Yes (weekly, monthly, yearly, custom) | +| Subtasks | Markdown checklist inside the note body | First-class subtasks | +| Views | Tasks, Today, Upcoming, Completed | List, Calendar, Kanban, Timeline, Eisenhower Matrix | +| Built-in extras | Mermaid, mindmap, presentation, internal links, code snippets, HTTP, math, tools | Pomodoro timer, habit tracker, sticky note widget, countdowns | +| Mobile app | No | Yes (iOS, Android, wearables) | +| Collaboration | File-level (shared folder, Git) | Built-in list and task sharing | +| Account required | No | Yes | + +Sources for TickTick features and pricing: [ticktick.com](https://ticktick.com) and current public pricing pages. + +## Where TickTick fits better + +TickTick is a strong choice when you want one app that covers tasks, habits, and time-boxing on every device. + +- **You want a cross-device task manager.** TickTick runs on macOS, Windows, Linux, the web, iOS, Android, Apple Watch, and Wear OS, with real-time cloud sync. +- **You want a rich task model.** Lists, sections, subtasks, tags, four priority levels, recurring tasks with custom rules, and natural-language quick add. +- **You want a built-in Pomodoro and habit tracker.** TickTick ships a focus timer and a habit tracker with statistics in the same app as your tasks. +- **You want many planning views.** List, Calendar (yearly, monthly, weekly, daily, agenda), Kanban, Timeline, and the Eisenhower Matrix are all built in. +- **You want strong reminders.** Reminders can repeat until you complete the task, which suits people who routinely miss notifications. +- **You want shared lists.** TickTick supports task sharing, assignment, and shared lists out of the box. + +## Where massCode fits better + +massCode is a strong choice when tasks should live with the rest of your developer notes, not in a separate app. + +- **You want tasks to live with the work.** A massCode task is a note. The same markdown editor holds checklists, code snippets, mermaid diagrams, links, and meeting notes. See [Notes Tasks](/documentation/notes/tasks). +- **You want plain Markdown files on disk.** Every task is a `.md` file with frontmatter in your [Markdown Vault](/documentation/storage) — readable, diffable, easy to back up. +- **You want one workspace, not five apps.** Tasks share the app with [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). +- **You want sync without a vendor.** Point [iCloud, Dropbox, Google Drive, Syncthing, or Git](/documentation/sync) at your vault. No required account. +- **You want full transparency.** The source is on [GitHub](https://github.com/massCodeIO/massCode) under AGPL v3. + +## Honest trade-offs + +- **No mobile app in massCode.** TickTick runs on phones, tablets, and watches; massCode is a desktop workspace for macOS, Windows, and Linux. +- **No reminders or notifications in massCode.** Due dates are visible in the list and the **Today** / **Upcoming** views, but the app does not push you a notification. +- **No recurring tasks in massCode.** A task has a single `due` date. +- **No first-class subtasks in massCode.** You can write a markdown checklist inside the body, but those checklist items are not separate task entities. +- **No Pomodoro or habit tracker.** TickTick bundles those; massCode does not. +- **No built-in team workspace.** Sharing happens at the file layer through a shared Git repo or shared cloud folder. + +## Who should pick which + +- Pick **TickTick** if you want a cross-device task manager with rich scheduling, recurring tasks, Pomodoro, habit tracking, multiple views, and shared lists. +- Pick **massCode** if you want a developer workspace where tasks are markdown notes with a status, a priority, and a due date — next to the snippets, notes, and HTTP requests you already keep there. + +## Using both + +You can keep TickTick for everyday personal logistics — groceries, habits, calendar reminders — and use massCode tasks for technical work-in-progress where the "task" is really a working note with code, links, and context. + +[Download massCode](/download/) and try it on a few in-flight notes. diff --git a/docs/website/compare/todoist.md b/docs/website/compare/todoist.md new file mode 100644 index 000000000..16bc2d8de --- /dev/null +++ b/docs/website/compare/todoist.md @@ -0,0 +1,75 @@ +--- +title: massCode vs Todoist +description: "An honest comparison between massCode tasks and Todoist. Local-first markdown tasks inside a developer workspace vs cloud-first task manager with rich projects, labels, and AI assists." +--- + +# massCode vs Todoist + +[Todoist](https://todoist.com) and massCode both let you capture and complete tasks, but they belong to different categories. Todoist is a cloud-first, cross-device task manager with projects, labels, filters, and AI assists. massCode is a local-first developer workspace where [tasks are notes](/documentation/notes/tasks) with a few structured properties — they live in the same Markdown Vault as the rest of your snippets, notes, and HTTP requests. + +If you need a dedicated task manager for personal and team productivity, with mobile apps, reminders, and integrations, Todoist is the more natural fit. If you want your tasks to live next to your code snippets and notes as plain Markdown files on your own disk, massCode is the more natural fit. + +## At a glance + +| | massCode | Todoist | +| --- | --- | --- | +| License | Open source (AGPL v3) | Proprietary | +| Pricing | Free | Free; Pro $5/mo (annual) or $7/mo; Business $8/user/mo (annual) or $10/user/mo | +| Data location | Local Markdown Vault on your disk | Cloud sync across devices | +| Platforms | macOS, Windows, Linux | macOS, Windows, Linux, iOS, Android, web, browser extensions, wearables | +| Task model | Notes with `status`, `priority`, and `due` properties | Projects, sections, tasks, subtasks, labels, descriptions | +| Statuses | Todo, In Progress, Done, Blocked | Open / completed | +| Priority | None, Low, Medium, High | P1–P4 | +| Due dates | Yes, calendar picker | Yes, with natural-language parsing | +| Reminders / notifications | No | Yes (Pro for custom reminders) | +| Recurring tasks | No | Yes | +| Subtasks | Markdown checklist inside the note body | First-class subtasks | +| Views | Tasks, Today, Upcoming, Completed | List, Calendar, Board, Today, Upcoming, custom filters | +| Mobile app | No | Yes (iOS, Android, wearables) | +| Collaboration | File-level (shared folder, Git) | Built-in shared projects and team workspaces | +| AI features | None built in | Task Assist, Filter Assist, Email Assist, Ramble | +| Integrations | VS Code and Raycast extensions | 80+ integrations | +| Account required | No | Yes | + +Sources for Todoist features and pricing: [todoist.com/features](https://todoist.com/features) and [todoist.com/pricing](https://todoist.com/pricing). + +## Where Todoist fits better + +Todoist is a strong choice when tasks are the product, not a side feature of your notes app. + +- **You want a dedicated cross-device task manager.** Todoist runs on macOS, Windows, Linux, iOS, Android, the web, browser extensions, and wearables, with cloud sync between all of them. +- **You want a rich task model.** Projects, sections, subtasks, labels, descriptions, four priority levels, and recurring tasks give you a deeper structure than a markdown note with a status. +- **You want natural-language quick add and reminders.** Quick Add parses dates and recurrences from plain text. Reminders surface tasks at the right moment. +- **You want managed collaboration.** Shared projects, the Business plan with team workspaces, roles, and centralized billing are built in. +- **You want AI in the task flow.** Todoist offers Task Assist, Filter Assist, Email Assist, and the Ramble voice/text capture feature. +- **You want lots of integrations.** Calendars, email, Slack, IFTTT, Zapier and many other services connect into Todoist. + +## Where massCode fits better + +massCode tasks are a strong choice when the task is closer to a working note than to a standalone to-do. + +- **You want tasks to live with the work.** A massCode task is a note. The same markdown editor holds checklists, code snippets, links, meeting notes, mermaid diagrams, and internal links to other notes. See [Notes Tasks](/documentation/notes/tasks). +- **You want plain Markdown files on disk.** Every task is a `.md` file with frontmatter in your [Markdown Vault](/documentation/storage) — readable in any editor, diffable in Git, easy to back up. +- **You want one workspace, not five apps.** Tasks share the app with [Code](/documentation/code/library), [Notes](/documentation/notes/), [HTTP](/documentation/http/), [Math](/documentation/math/), [Drawings](/documentation/drawings/), and [Tools](/documentation/tools/). +- **You want sync without a vendor.** Point [iCloud, Dropbox, Google Drive, Syncthing, or Git](/documentation/sync) at your vault. No required account. +- **You want full transparency.** The source is on [GitHub](https://github.com/massCodeIO/massCode) under AGPL v3. + +## Honest trade-offs + +- **No mobile app in massCode.** Todoist runs everywhere; massCode is a desktop workspace for macOS, Windows, and Linux. +- **No reminders or notifications in massCode.** Due dates are shown in the list and in the **Today** / **Upcoming** views, but the app does not push you a reminder. +- **No recurring tasks in massCode.** A task has a single `due` date. +- **No first-class subtasks in massCode.** You can write a markdown checklist inside the task body, but those checklist items are not separate task entities with their own status, priority, or due date. +- **No built-in team workspace.** Sharing happens at the file layer through a shared Git repo or shared cloud folder. +- **No AI assists.** massCode does not ship AI features. + +## Who should pick which + +- Pick **Todoist** if tasks are your main workflow: you want a cross-device task manager with rich projects, labels, reminders, recurring tasks, AI assists, and team collaboration. +- Pick **massCode** if tasks are part of a developer workflow that already lives in markdown — you want them next to your snippets and notes, stored as plain files you control. + +## Using both + +Many developers run both: Todoist for personal logistics and team task lifecycles, massCode for technical work-in-progress where a task is really "a note with a status, a priority, and a due date." massCode's tasks do not try to replace a full task manager — they add structure to the notes you are already writing. + +[Download massCode](/download/) and try it on a few of your in-flight work notes. diff --git a/docs/website/documentation/clipper.md b/docs/website/documentation/clipper.md new file mode 100644 index 000000000..c24e5347a --- /dev/null +++ b/docs/website/documentation/clipper.md @@ -0,0 +1,136 @@ +--- +title: Clipper +description: "Set up massCode Clipper locally to save selected text, pages, and links from Chrome, Firefox, or Safari into Code, Notes, and HTTP." +--- + +# Clipper + +massCode Clipper saves web content from your browser into the local massCode app. Use it to send selected code to Code, selected text or readable page content to Notes, and pages or links to HTTP as `GET` requests. + +

+massCode Clipper popup +

+ +Install the Chrome version from the [Chrome Web Store](https://chromewebstore.google.com/detail/masscode-clipper/fkaaogdifollkhjbfoabbiocecehaaii). For Firefox, Safari, or local testing, download a build from this page or install the Clipper from a local repository build. + +## Enable The Local API + +The Clipper talks to massCode through the local Integration API. + +1. Open massCode. +2. Open **Preferences**. +3. Go to **API**. +4. Keep the **API Port** value. The default is `4321`. +5. Enable **API integrations**. +6. Click **Generate token**. +7. Copy the generated token. The full token is shown only after generation. + +If you change the API port, reload the app before using the new port from the browser extension. + +## Download The Clipper + +Download the archive for your browser when you want to install the Clipper locally: + +- [Chrome build](/clipper/downloads/clipper-chrome.zip) +- [Firefox build](/clipper/downloads/clipper-firefox.zip) +- [Safari build](/clipper/downloads/clipper-safari.zip) + +Unzip the archive before loading it into the browser. Browsers do not load these local development builds directly from the ZIP file. + +## Build The Clipper Locally + +Run the build from the repository root: + +```bash +pnpm integrations:clipper:build +``` + +This creates browser-specific builds: + +- `integrations/clipper/dist/chrome` +- `integrations/clipper/dist/firefox` +- `integrations/clipper/dist/safari` + +After installing the extension in a browser, open the Clipper popup, set the API port, paste the API token, and click **Save settings**. + +## Chrome + +Install the published Chrome extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/masscode-clipper/fkaaogdifollkhjbfoabbiocecehaaii). + +If you downloaded the archive, unzip `clipper-chrome.zip` first. + +If you are building from the repository, build the Chrome target: + +```bash +pnpm integrations:clipper:build:chrome +``` + +2. Open `chrome://extensions`. +3. Enable **Developer mode**. +4. Click **Load unpacked**. +5. Select the unzipped Chrome build folder, or `integrations/clipper/dist/chrome` when building locally. + +After rebuilding, return to `chrome://extensions` and reload the unpacked extension. + +## Firefox + +Firefox does not load an unpacked extension by selecting the `dist` folder. + +If you downloaded the archive, unzip `clipper-firefox.zip` first. + +If you are building from the repository, build the Firefox target: + +```bash +pnpm integrations:clipper:build:firefox +``` + +Then: + +1. Open `about:debugging#/runtime/this-firefox`. +2. Click **Load Temporary Add-on**. +3. Select `manifest.json` inside the unzipped Firefox build folder, or `integrations/clipper/dist/firefox/manifest.json` when building locally. + +The temporary extension stays installed until Firefox restarts. Use **Reload** on `about:debugging` after rebuilding. + +## Safari + +If you downloaded the archive, unzip `clipper-safari.zip` first. + +If you are building from the repository, build the Safari target: + +```bash +pnpm integrations:clipper:build:safari +``` + +Then: + +1. Open Safari. +2. Choose **Safari** > **Settings**. +3. Open **Advanced** and enable **Show features for web developers** or **Show Develop menu in menu bar**. +4. Open the **Developer** tab. +5. Enable **Allow unsigned extensions**. +6. Click **Add Temporary Extension...**. +7. Select the unzipped Safari build folder, or `integrations/clipper/dist/safari` when building locally. +8. Enable the extension in Safari's **Extensions** settings if Safari does not enable it automatically. + +Safari removes temporary extensions after 24 hours or when you quit Safari. Safari also resets **Allow unsigned extensions** when you quit the browser, so enable it again before the next local test session. + +## Capture Content + +Use the Clipper popup when you want to review or edit the item name before saving. + +- **Code** saves selected text as a snippet. Select text on the page first. +- **Notes** saves selected text when available, otherwise it saves the readable page content. +- **HTTP** saves the current page URL as a `GET` request. + +You can also right-click the page, selected text, or a link and choose **Save to massCode**. Context menu saves use the stored API port and token, then save to the target you choose in the menu. + +## Troubleshooting + +If the popup says it cannot read the active page, try a regular website tab. Browsers block extensions from reading internal pages such as browser settings, extension pages, and some store pages. + +If saving fails, check that massCode is running, **API integrations** are enabled, the extension API port matches Preferences, and the token in the popup is the latest generated token. + + diff --git a/docs/website/documentation/code/folders.md b/docs/website/documentation/code/folders.md index 3bc408aac..807d5ab31 100644 --- a/docs/website/documentation/code/folders.md +++ b/docs/website/documentation/code/folders.md @@ -33,14 +33,23 @@ Set a default language when most snippets in a folder use the same syntax. Right-click the folder and choose **"Default Language"**. -## Setting a Custom Icon +## Setting a Folder Icon -Custom icons help large libraries stay easier to scan. +Folder icons help large libraries stay easier to scan. Right-click a folder and choose **Set Icon**. Use the **Icons** tab to search the built-in Material and Lucide icon collections. -Right-click the folder and choose **"Set Custom Icon"**. +To restore the default folder icon, right-click the folder and choose **Remove Icon**. -::: info -To set the default icon, click the **Reset** button in the icon selection dialog window. -::: +### Emoji and Uploaded Images + + + +The icon picker also supports: + +- **Emoji** - search the Unicode emoji catalog. Emoji appearance follows your operating system. +- **Upload** - choose or drop a JPG or PNG image up to 10 MB, review the preview, then click **Use image**. + +Uploaded images are cropped from the center, resized to 128×128, and saved as PNG. + +Uploaded icons are stored as a hidden `.icon.png` file inside the folder, so they stay with the folder when you back up or sync your Markdown Vault. diff --git a/docs/website/documentation/code/index.md b/docs/website/documentation/code/index.md new file mode 100644 index 000000000..2f66b0347 --- /dev/null +++ b/docs/website/documentation/code/index.md @@ -0,0 +1,77 @@ +--- +title: Code +description: "Use the Code space in massCode to save, organize, search, edit, preview, and format reusable code snippets." +--- + +# Code + +Code is the snippet library space inside massCode. Use it to keep reusable examples, commands, templates, configuration fragments, and reference code close to your everyday development work. + +Access Code from the **Code** icon in the Space rail. The layout has three columns: the [Library](/documentation/code/library) on the left, the snippet list in the middle, and the editor on the right. + + + +## When to use Code + +Use Code when you want a searchable local library for small pieces of reusable code. + +- save commands, examples, templates, and configuration snippets +- keep implementation notes next to the code they explain +- organize snippets by project, topic, or language +- store related files or variants inside one snippet +- format supported languages with Prettier +- preview small HTML/CSS ideas or visualize JSON payloads + +## Creating a Snippet + +- Select **"File"** > **"New Snippet"** from the menu bar. +- Click the **"+"** button next to search in the second column. +- Press Cmd+N on macOS or Ctrl+N on Windows or Linux. + +The new snippet is created in the currently selected folder, or in **"Inbox"** if no folder is selected. + +## Working with Snippets + +### Snippets + +[Snippets](/documentation/code/snippets) are the main items in Code. Use them for code you reuse across projects, reference examples, terminal commands, configuration files, or short implementation notes. + +### Fragments + +[Fragments](/documentation/code/fragments) are tabs inside a snippet. Use them when one snippet needs multiple related files, versions, or languages in one place. + +### Descriptions + +[Descriptions](/documentation/code/description) store usage notes, caveats, links, and extra context next to the snippet. + +## Organizing and Finding + +### Library + +The [Library](/documentation/code/library) gives you system views for Inbox, Favorites, All Snippets, and Trash. + +### Folders and Tags + +[Folders](/documentation/code/folders) give snippets a primary structure by project, topic, language, or workflow. [Tags](/documentation/code/tags) add another way to group snippets across folders. + +Folders can have a default language, so new snippets inside that folder start with the syntax mode you use most often there. + +### Search + +[Search](/documentation/search) finds snippets by title or content and narrows the current folder, tag, or library view when one is selected. + +## Editor Tools + +The Code editor is built for editing and reusing snippets: + +- Language selection for syntax highlighting +- Copying the whole snippet to the clipboard +- Prettier formatting for supported languages +- HTML and CSS preview for quick experiments +- JSON Visualizer for nested JSON snippets + +See [Snippets](/documentation/code/snippets) for the full snippet workflow. + + diff --git a/docs/website/documentation/code/search.md b/docs/website/documentation/code/search.md deleted file mode 100644 index 2cdb9f612..000000000 --- a/docs/website/documentation/code/search.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Code Search -description: "Search the entire massCode snippet library by title or content to quickly find saved code across folders." ---- - -# Search - -Search helps you find snippets by title or content across your entire library. Use it when you remember part of the code but not where you saved it. - -- Select **"File"** > **"Find"** from the menu bar. -- Click the search field at the top of the second column. -- Press Cmd+F on macOS or Ctrl+F on Windows or Linux. diff --git a/docs/website/documentation/code/snippets.md b/docs/website/documentation/code/snippets.md index 1bfe12f3f..5b82254a4 100644 --- a/docs/website/documentation/code/snippets.md +++ b/docs/website/documentation/code/snippets.md @@ -67,6 +67,8 @@ Use one of these methods: - XML - YAML +Use **Editor** > **Normalize Line Breaks** to remove hard terminal wraps from the selection or the whole snippet. + ## Real-time Render HTML & CSS diff --git a/docs/website/documentation/command-palette.md b/docs/website/documentation/command-palette.md index 911f54eb1..cff7b3f96 100644 --- a/docs/website/documentation/command-palette.md +++ b/docs/website/documentation/command-palette.md @@ -26,6 +26,8 @@ The palette searches by title across searchable content: Search results are ranked with your recent usage, so items you open often move higher over time. +Palette search also supports space scopes. In massCode 5.6 and later, it can also use tag and folder filters. See [Search](/documentation/search#search-from-the-command-palette) for the full search workflow. + ## Run Commands Commands create new content or open app-level actions without switching spaces first. @@ -51,19 +53,6 @@ Use command mode when you want to run an action instead of opening existing cont Press Esc to leave command mode and return to the full palette. -## Search In A Space - -Use space mode when you want to search only one part of massCode. - -1. Open the palette. -2. Type `@`. -3. Select a space, such as **Code**, **Notes**, or **HTTP**. -4. Type your search query. - -You can also type the scope directly, for example `@code auth` or `@notes release`. - -Press Esc to leave the active scope and return to the full palette. - ## Create From Search If a search has no exact match, the palette can create a new item from the query. @@ -79,7 +68,9 @@ When create fallbacks are shown, press Shift+Enter to run the first c Open the actions panel for the selected result with Right Arrow or Cmd+K on macOS and Ctrl+K on Windows and Linux. -Actions depend on the selected result. For example, you can copy a title, copy snippet content, copy an HTTP request URL, or run the selected command. +Actions depend on the selected result. For example, you can copy a title, copy snippet content, duplicate a snippet or HTTP request, copy an HTTP request URL, or run the selected command. + +You can also duplicate a note while preserving its content, description, properties, and tags. Use Left Arrow or Esc to close the actions panel. diff --git a/docs/website/documentation/drawings/index.md b/docs/website/documentation/drawings/index.md new file mode 100644 index 000000000..22d63a399 --- /dev/null +++ b/docs/website/documentation/drawings/index.md @@ -0,0 +1,74 @@ +--- +title: Drawings +description: "Sketch diagrams and visual notes in massCode with an Excalidraw-powered canvas, a searchable drawing list, image export, and drawings you can embed in notes." +--- + +# Drawings + + + +Drawings is the visual canvas space inside massCode, powered by Excalidraw. Use it for diagrams, sketches, flows, and whiteboard-style notes that are easier to draw than to write. + +Access Drawings from the **Drawings** icon in the Space rail. The layout uses two columns: the drawing list on the left and the canvas on the right. + + + +## When to use Drawings + +Use Drawings when a visual is clearer than text and you want it stored next to your snippets and notes. + +- sketch architecture and flow diagrams +- draft UI wireframes and quick mockups +- capture whiteboard ideas during planning +- annotate screenshots and images +- illustrate notes with embedded drawings + +## Creating a Drawing + +- Click the **plus** button next to **Drawing List** in the sidebar header. + +A new drawing is created and its name becomes editable immediately, so you can type a title and press Enter. + +## Managing Drawings + +The drawing list supports the same actions from a double-click, an inline input, or the right-click context menu: + +- **Rename** - double-click a drawing, or choose **Rename** from the context menu. +- **Duplicate** - copy a drawing with all of its content. +- **Export image** - save the drawing as an image file. +- **Copy Link for Note** - copy a link you can paste into a note (see [Embedding in Notes](#embedding-in-notes)). +- **Delete** - remove the drawing. With a drawing selected, you can also press Delete. + +### Searching + +Use the search field above the list to filter drawings by name. + +- Type to filter the list as you go. +- Press Arrow Down / Arrow Up to move through the results, which open in the canvas as you go. +- Press Esc to clear the query. + +## The Canvas + +The canvas is the full Excalidraw editor with its drawing tools, shapes, text, and styling controls. Your changes autosave as you draw. + +- **Pan and zoom** are remembered per drawing, so each one reopens where you left it. +- **Fit to content** - the button in the canvas footer, next to the zoom controls, recenters and zooms the viewport so every element fits. Use it to jump back to your work after panning away. + +## Embedding in Notes + +Drawings can be embedded directly in your markdown notes. + +1. In the drawing list, open the context menu and choose **Copy Link for Note**. +2. Paste the link into a note. The drawing renders inline in the preview. + + + +From an embedded drawing you can open it in the Drawings space, and use the back and forward controls to move through your navigation history. + +## Storage + +Drawings are stored in your vault under the `drawings` folder as `.excalidraw` files, so they are backed up and synced together with the rest of your massCode data. + + diff --git a/docs/website/documentation/http/index.md b/docs/website/documentation/http/index.md index d67d6694d..b95bddefb 100644 --- a/docs/website/documentation/http/index.md +++ b/docs/website/documentation/http/index.md @@ -42,6 +42,14 @@ Importing creates HTTP folders, requests, and environments from external collect HTTP requests are organized in folders. Selecting a folder shows its requests and selects the first request in that folder. Folders support nesting, drag and drop ordering, inline rename, and custom folder icons. +Right-click a folder and choose **Set Icon** to select a built-in Material or Lucide icon. Choose **Remove Icon** to restore the default folder icon. + +### Emoji and Uploaded Images + + + +The icon picker also supports platform-native emoji and uploaded JPG or PNG images. Uploaded images can be previewed before applying and are cropped from the center and resized to 128×128. + ## Storage HTTP data is stored in your vault under the `http` folder. Requests are markdown files with YAML frontmatter, so they can be backed up and synced together with the rest of your massCode data. diff --git a/docs/website/documentation/index.md b/docs/website/documentation/index.md index ac6b7f755..e8abb7654 100644 --- a/docs/website/documentation/index.md +++ b/docs/website/documentation/index.md @@ -9,27 +9,37 @@ massCode is a free and open source developer workspace for code snippets, markdo Switch between Spaces using the rail on the left side of the app, or use the [Command Palette](/documentation/command-palette) to jump to spaces, content, and actions from the keyboard. +Use [Search](/documentation/search) to narrow the current list in Code, Notes, or HTTP, or to build filtered searches from the Command Palette. + ## Code -Use Code to build a reusable snippet library across projects and languages. The three-column layout keeps organization, search, and editing in one place: Library on the left, snippet list in the middle, editor on the right. +Use [Code](/documentation/code/) to build a reusable snippet library across projects and languages. The three-column layout keeps organization, search, and editing in one place: Library on the left, snippet list in the middle, editor on the right. ## Notes -Use Notes for longer markdown documents that do not fit well into snippets: project notes, drafts, technical docs, meeting notes, and personal knowledge bases. It uses the same three-column layout as Code and adds a Notes Dashboard, live preview, a notes graph, mindmaps, and fullscreen presentation mode. +Use [Notes](/documentation/notes/) for longer markdown documents that do not fit well into snippets: project notes, [task notes](/documentation/notes/tasks) with status, priority, due dates, cleanup controls, drafts, technical docs, meeting notes, and personal knowledge bases. It uses the same three-column layout as Code and adds a Notes Dashboard, live preview with editable tables, a notes graph, mindmaps, and fullscreen presentation mode. ## HTTP -Use HTTP as a lightweight API client inside massCode. Store requests in folders, import collections from OpenAPI, Postman, or Bruno, switch environments, preview the outgoing request as raw HTTP or cURL, send it from the editor, and inspect the response body and headers without leaving your workspace. +Use [HTTP](/documentation/http/) as a lightweight API client inside massCode. Store requests in folders, import collections from OpenAPI, Postman, or Bruno, switch environments, preview the outgoing request as raw HTTP or cURL, send it from the editor, and inspect the response body and headers without leaving your workspace. ## Math -Math is a calculator-style notebook for quick development math without leaving massCode. Use it for currency conversion, unit conversion, date math, finance, and natural-language calculations with instant results on every line. +[Math](/documentation/math/) is a calculator-style notebook for quick development math without leaving massCode. Use it for currency conversion, unit conversion, date math, finance, and natural-language calculations with instant results on every line. + +## Drawings + +Use [Drawings](/documentation/drawings/) for diagrams, sketches, and whiteboard-style visuals on an Excalidraw-powered canvas. Keep a searchable list of drawings next to your snippets and notes, export them as images, and embed them directly in markdown notes. ## Tools -Tools covers the small one-off tasks that usually send you to a browser tab: JSON comparison, encoders, decoders, generators, hash utilities, and text converters. Categories are listed on the left, and the active tool opens on the right. +[Tools](/documentation/tools/) covers the small one-off tasks that usually send you to a browser tab: JSON comparison, encoders, decoders, generators, hash utilities, and text converters. Categories are listed on the left, and the active tool opens on the right. + +## Clipper + +Use [Clipper](/documentation/clipper) to save selected text, readable page content, and links from your browser into Code, Notes, or HTTP. Install the Chrome version from the [Chrome Web Store](https://chromewebstore.google.com/detail/masscode-clipper/fkaaogdifollkhjbfoabbiocecehaaii), or use a local build for other browsers. ## General Settings diff --git a/docs/website/documentation/notes/folders.md b/docs/website/documentation/notes/folders.md index 43eabed26..96c2221ca 100644 --- a/docs/website/documentation/notes/folders.md +++ b/docs/website/documentation/notes/folders.md @@ -21,11 +21,20 @@ Drag one folder onto another to create a nested structure. Right-click the folder and choose **"Rename"** or **"Delete"**. -## Custom Icons +## Folder Icons -Custom icons make larger note libraries easier to scan. +Folder icons make larger note libraries easier to scan. Right-click a folder and choose **Set Icon**. -Right-click the folder and choose **"Set Custom Icon"**. +Use the **Icons** tab to search the built-in Material and Lucide collections. Choose **Remove Icon** from the folder context menu to restore the default icon. + +### Emoji and Uploaded Images + + + +- **Emoji** uses the system emoji style for your platform. +- **Upload** accepts JPG or PNG images up to 10 MB and shows a preview before applying the image. + +Uploaded images are cropped from the center, resized to 128×128, and stored as a hidden `.icon.png` file inside the folder. ## Moving Notes diff --git a/docs/website/documentation/notes/images.md b/docs/website/documentation/notes/images.md index f2eb57915..51605a323 100644 --- a/docs/website/documentation/notes/images.md +++ b/docs/website/documentation/notes/images.md @@ -7,7 +7,7 @@ description: "Embed images in massCode Notes with standard markdown syntax, past Use images when a screenshot, diagram, or visual reference belongs next to the note text. -Images are rendered directly inside your note from standard markdown image syntax. For local images, paste an image from the clipboard or drag an image file into the editor. massCode saves the file to `notes/assets` in your vault and inserts the markdown for you. +Images are rendered directly inside your note from standard markdown image syntax. For local images, paste an image from the clipboard or drag an image file into the editor. massCode stores the file in the vault and inserts the markdown for you. ```md ![image-name](masscode://notes-asset/generated-file-name.png) @@ -19,7 +19,23 @@ You can also use a remote image URL: ![Remote screenshot](https://example.com/screenshot.png) ``` +The `masscode://notes-asset/` URL is resolved by massCode. Other Markdown apps may not display these local images directly. + +## Managed Storage + + + +Starting with massCode 5.9, newly pasted or dropped images are saved to `notes/.masscode/assets`. When you copy or synchronize Notes between devices, include the whole vault, including the hidden `notes/.masscode` directory. Copying only Markdown files leaves their local images behind. + +::: warning Compatibility +Vaults with images in the managed `notes/.masscode/assets` path require a massCode version that supports this layout. Older versions may not display these images. +::: + +::: info Automatic migration +When a note references an image from the legacy `notes/assets` directory, massCode migrates that referenced file to managed storage and updates the note automatically. Unreferenced files are left in place. +::: + - Paste or drag images in **Editor** and **Live Preview** modes. - View images in **Live Preview** or **Preview** mode. - Click an image block in editable modes to reveal and edit its markdown source. -- Supported pasted and dropped formats: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, and `bmp`. +- New pasted and dropped images can use `png`, `jpg`, or `jpeg`. diff --git a/docs/website/documentation/notes/index.md b/docs/website/documentation/notes/index.md index 7ec6d6ff9..2970b8f0b 100644 --- a/docs/website/documentation/notes/index.md +++ b/docs/website/documentation/notes/index.md @@ -1,6 +1,6 @@ --- title: Markdown Notes -description: "Write markdown notes in massCode with a Notes Dashboard, live preview, a three-column layout, and features for diagrams, mind maps, and presentations." +description: "Write markdown notes in massCode with a Notes Dashboard, tasks, live preview, a three-column layout, and features for diagrams, mind maps, and presentations." --- # Notes @@ -19,6 +19,7 @@ Use Notes when you want to keep markdown documents close to your snippets and da - write technical documentation and reference material - keep research notes or project logs +- track tasks with status, priority, and due dates - prepare presentations from markdown - turn note outlines into mind maps @@ -27,6 +28,10 @@ Use Notes when you want to keep markdown documents close to your snippets and da - Select **"File"** > **"New Note"** from the menu bar. - Press Cmd+N on macOS or Ctrl+N on Windows or Linux. +See [Tasks](/documentation/notes/tasks) to create task notes with structured status, priority, and due date properties. + +To duplicate an existing note, right-click it in the Notes list and choose **Duplicate**. The copy stays in the same folder and its title is selected for renaming. + ## Dashboard @@ -43,7 +48,7 @@ See [Dashboard](/documentation/notes/dashboard) for the full walkthrough. ## Editor Modes -Switch between three editor modes using the controls at the top of the editor. +Switch between three editor modes using the mode selector in the bottom-left of the editor. You can also use **Editor** > **Mode** from the menu bar when a note is focused. - **Editor** - raw markdown editing. - **Live Preview** - split view with source and rendered preview side by side. @@ -56,13 +61,45 @@ The editor is built on CodeMirror 6 and includes: - Syntax highlighting for fenced code blocks - Smart list indentation with automatic ordered list renumbering - Tab / Shift-Tab indentation +- Right-click formatting menu in editable modes - Inline markdown formatting shortcuts in editable modes -- Table navigation between cells +- Editable markdown tables in Live Preview +- One-click copying for fenced code blocks in Live Preview and Preview +- Line break normalization for hard-wrapped terminal text from **Editor** > **Normalize Line Breaks** - [Internal links](/documentation/notes/internal-links) to notes and snippets +- [Task notes](/documentation/notes/tasks) with status, priority, due dates, and smart views - [Mermaid diagram](/documentation/notes/mermaid) support - [Image embedding](/documentation/notes/images) - [Callout blocks](/documentation/notes/callouts) +## Editor Context Menu + + + +Right-click in **Live Preview** to apply markdown formatting or insert a table, callout, horizontal rule, or code block. The context menu is disabled in **Preview** mode because the rendered note is read-only. + +Notes editor context menu + +- **Format** adds bold, italic, strikethrough, highlight, inline code, links, or removes formatting from the current selection. +- **Paragraph** switches the current line between body text, headings, bullet lists, numbered lists, task lists, and quotes. +- **Insert** adds tables, callouts, horizontal rules, and fenced code blocks. + +## Editable Tables + + + +In **Live Preview**, markdown tables render as interactive table blocks while the note still stays plain Markdown in the vault. + +Editable markdown table in Notes + +- Click a table cell to edit it in place. +- Press Tab and Shift+Tab to move between cells. +- Press Enter to move to the cell below. When the cursor is in the last body row, massCode adds a new row. +- Hover the table edge to add a column to the right or a row below. +- Right-click an active table cell and use the **Table** submenu in the context menu to insert or delete rows and columns, or set column alignment. +- Drag row and column handles to reorder the table. +- Paste tabular data from a spreadsheet or TSV source to create or fill table cells. + ## Formatting Shortcuts The following shortcuts work in **Editor** and **Live Preview** modes: @@ -84,6 +121,7 @@ Customize the editor appearance and behavior in preferences: - **Indent size** - **Limit width** - toggle to constrain the editor width - **Line numbers** - toggle to show or hide line numbers +- **Wrap table cells** - wrap long cell content so wide tables stay within the editor diff --git a/docs/website/documentation/search.md b/docs/website/documentation/search.md new file mode 100644 index 000000000..1b2a34cfb --- /dev/null +++ b/docs/website/documentation/search.md @@ -0,0 +1,111 @@ +--- +title: Search +description: "Search snippets, notes, HTTP requests, spaces, and commands in massCode with scoped list search and Command Palette filters." +--- + +# Search + +massCode has two search flows: + +- Use the search field above a list to narrow the current space. +- Use the Command Palette to search across the app and add filters with the keyboard. + +## Search the current list + +The search field above a list searches the items in that space: + +- Code searches snippets. +- Notes searches notes. +- HTTP searches requests. + +Click the search field at the top of the list, or press Cmd+F on macOS and Ctrl+F on Windows and Linux. + +List search respects the current sidebar context. If you select a folder, tag, or library view first, the search field narrows that selection instead of searching the whole space. + +Examples: + +- Select a Code tag, then search to find matching snippets inside that tag. +- Select a Notes folder, then search to find matching notes in that folder. +- Open Favorites, Trash, Tasks, Today, or Upcoming in Notes, then search inside that view. +- Select an HTTP folder, then search for matching requests inside that folder. + +Clearing the search text keeps the same sidebar selection active. + +## Search from the Command Palette + +Open the Command Palette with Cmd+P on macOS or Ctrl+P on Windows and Linux. + +The palette can search: + +- snippets +- notes +- HTTP requests +- spaces +- commands +- recently opened items + +Results are ranked with recent usage, so items you open often move higher over time. + +## Scope the palette to a space + +Use `@` to search in one space. + +1. Open the palette. +2. Type `@`. +3. Select **Code**, **Notes**, or **HTTP**. +4. Type your search query. + +You can also type the scope directly: + +- `@code auth` +- `@notes release` +- `@http webhook` + +Press Esc to leave the active scope and return to the full palette. + +## Add filters in the palette + + + +Use filter tokens when you want to narrow Command Palette search by folder or tag. + +### Tags + +Type `#` to show tag suggestions. Select a tag to add it as a chip. + +Examples: + +- `@code #vue composable` +- `@notes #backend migration` + +Tags are available in Code and Notes. + +### Folders + +Type `/` to show folder suggestions. Select a folder to add it as a chip. + +Examples: + +- `@code /Shell docker` +- `@notes /Development backend` +- `@http /Webhooks stripe` + +Nested folders can be selected by path, such as `/Work/API`. + +## Work with search chips + +The palette shows active scope and filters as chips in the input. + +- Click the `x` on a chip to remove that scope or filter. +- Press Backspace in an empty input to remove one chip at a time. +- Removing a filter recalculates the search without clearing the remaining query. + +You can combine a space, a folder, a tag, and text query when the combination applies to that space. + +Example: + +```text +Notes /Development #backend migration +``` + +This searches Notes for `migration` inside the Development folder with the backend tag. diff --git a/docs/website/documentation/sync.md b/docs/website/documentation/sync.md index a6dda8e82..5470a0dbd 100644 --- a/docs/website/documentation/sync.md +++ b/docs/website/documentation/sync.md @@ -22,3 +22,23 @@ This approach works well if you want: - full control over where your data lives massCode watches the vault directory in real time, so changes made outside the app are picked up automatically. + +## Offloaded (online-only) files + + + +Cloud services can free up disk space by keeping only file placeholders on your device: the file is visible on disk, but its content stays in the cloud until it is read. This happens with iCloud Drive (Optimize Mac Storage), OneDrive (Files On-Demand), Google Drive (online-only), and Dropbox (online-only). + +massCode handles offloaded vault files without freezing: + +- Snippets, notes, and HTTP requests whose files are offloaded still appear in lists right away, marked with a cloud icon. +- Their content is downloaded in the background. Overall progress is shown at the bottom of the space rail. +- Opening an offloaded item moves it to the front of the download queue; the content appears as soon as it is fetched. +- The app never overwrites a file whose content has not been downloaded yet, so no data is lost. + +For the smoothest experience, tell your cloud service to keep the vault folder downloaded: + +- **iCloud Drive (macOS 15+)**: right-click the vault folder and choose **Keep Downloaded**. On macOS 14 and earlier, disable **Optimize Mac Storage** in iCloud settings. +- **OneDrive**: right-click the vault folder and choose **Always keep on this device**. +- **Google Drive**: mark the vault folder as **Available offline**. +- **Dropbox**: set the vault folder to **Make available offline**. diff --git a/docs/website/documentation/themes.md b/docs/website/documentation/themes.md index 518a0e9d0..1689badd8 100644 --- a/docs/website/documentation/themes.md +++ b/docs/website/documentation/themes.md @@ -1,9 +1,23 @@ --- -title: Custom Themes -description: "Create and customize massCode themes to change app colors and editor syntax highlighting with live JSON-based updates." +title: Appearance and Custom Themes +description: "Configure the macOS Dock counter and customize massCode themes for the app UI and editor syntax highlighting." --- -# Custom Themes +# Appearance and Custom Themes + +## macOS Dock Icon Counter + + + +On macOS, open **Settings** > **Appearance** and use **Dock Icon Counter** to show one of these counts on the massCode Dock icon: + +- items in the Code Inbox +- items in the Notes Inbox +- tasks due today + +Choose **Don't Show** to disable the counter. If macOS blocks the badge, allow notifications and badges for massCode in System Settings. + +## Custom Themes @@ -17,4 +31,4 @@ Custom themes let you adapt massCode to your workflow and visual preferences. Yo ## Creating a Theme -Create a new theme from **Settings → Themes**. massCode generates a Rose Pine-based JSON template that you can use as a starting point and edit in real time. +Create a new theme from **Settings** > **Appearance**. massCode generates a Rose Pine-based JSON template that you can use as a starting point and edit in real time. diff --git a/docs/website/documentation/tools/index.md b/docs/website/documentation/tools/index.md index d4d5b6520..1766f233d 100644 --- a/docs/website/documentation/tools/index.md +++ b/docs/website/documentation/tools/index.md @@ -31,6 +31,12 @@ Turn free-form text into a URL-friendly slug. Split a URL into its protocol, host, path, query, and hash parts. +### Line Break Normalizer + + + +Remove hard wraps from copied terminal output while preserving Markdown lists, tables, quotes, commands, and code blocks. The same normalization is available from the **Editor** menu in Code and Notes. + ## Cryptography & Security ### Hash Generators diff --git a/docs/website/donate/index.md b/docs/website/donate/index.md index 67914f532..c5bc90387 100644 --- a/docs/website/donate/index.md +++ b/docs/website/donate/index.md @@ -5,8 +5,10 @@ description: "Support massCode development through Open Collective, Gumroad, or # Support massCode development -::: info -Everyone who donated will get a special version without the "Unsponsored" label in the lower-left corner of the space rail, as well as a pop-up notification of the donation. +::: info Supporter license +After donating, email [reshetov.art@gmail.com](mailto:reshetov.art@gmail.com) with proof of your donation and I will send you a supporter license key. Enter it in **Preferences > Supporter** to unlock supporter status and remove the "Unsponsored" label from the lower-left corner of the space rail. Your email is the license identifier. + +This applies to past donations too. If you have donated before, email me with proof and I will issue your key. ::: massCode is free and open source. But building and maintaining a quality tool takes time. Your support helps keep development going and new features coming. diff --git a/docs/website/index.md b/docs/website/index.md index 5a3f72dab..4d9e541a5 100644 --- a/docs/website/index.md +++ b/docs/website/index.md @@ -1,7 +1,7 @@ --- layout: home titleTemplate: "A free and open source developer workspace" -description: "Free, open-source developer workspace for code snippets, markdown notes, calculations, and built-in dev tools. Works offline and stores data locally." +description: "Free, open-source developer workspace for code snippets, markdown notes, calculations, drawings, and built-in dev tools. Works offline and stores data locally." hero: image: src: /logo.png @@ -9,7 +9,7 @@ hero: name: massCode text: Your developer workspace tagline: | - Manage code snippets, write markdown notes, send HTTP requests, solve calculations, and use built-in developer tools. Free, open source, and works offline. + Manage code snippets, write markdown notes, send HTTP requests, solve calculations, sketch diagrams, and use built-in developer tools. Free, open source, and works offline. actions: - theme: brand text: Download Free diff --git a/docs/website/public/clipper.png b/docs/website/public/clipper.png new file mode 100644 index 000000000..629e17e1d Binary files /dev/null and b/docs/website/public/clipper.png differ diff --git a/docs/website/public/code.png b/docs/website/public/code.png new file mode 100644 index 000000000..3c5ad00e9 Binary files /dev/null and b/docs/website/public/code.png differ diff --git a/docs/website/public/drawings.png b/docs/website/public/drawings.png new file mode 100644 index 000000000..71bb0b873 Binary files /dev/null and b/docs/website/public/drawings.png differ diff --git a/docs/website/public/notes-context-menu.png b/docs/website/public/notes-context-menu.png new file mode 100644 index 000000000..2455104a5 Binary files /dev/null and b/docs/website/public/notes-context-menu.png differ diff --git a/docs/website/public/notes-drawings.png b/docs/website/public/notes-drawings.png new file mode 100644 index 000000000..5e8e93f20 Binary files /dev/null and b/docs/website/public/notes-drawings.png differ diff --git a/docs/website/public/notes-table.png b/docs/website/public/notes-table.png new file mode 100644 index 000000000..1ff184faa Binary files /dev/null and b/docs/website/public/notes-table.png differ diff --git a/docs/website/public/task.png b/docs/website/public/task.png new file mode 100644 index 000000000..ed881c5e2 Binary files /dev/null and b/docs/website/public/task.png differ diff --git a/docs/website/sponsor/index.md b/docs/website/sponsor/index.md index e1bdc0867..463c1822e 100644 --- a/docs/website/sponsor/index.md +++ b/docs/website/sponsor/index.md @@ -32,7 +32,7 @@ const activeDevelopmentYears = `${githubStats.activeDevelopmentYears}+` **Support massCode and be recognized across the project website, GitHub repository, release notes, and community updates.** -massCode is a free, cross-platform developer workspace for code snippets, markdown notes, HTTP requests, calculations, and built-in developer tools. It runs natively on macOS, Windows, and Linux, and is built for developers who want practical tools without accounts, cloud lock-in, or surveillance. +massCode is a free, cross-platform developer workspace for code snippets, markdown notes, HTTP requests, calculations, drawings, and built-in developer tools. It runs natively on macOS, Windows, and Linux, and is built for developers who want practical tools without accounts, cloud lock-in, or surveillance. /captures/` or +`http://127.0.0.1:/captures/`. + +## Token storage + +The Integration API token is stored in browser extension local storage. massCode +stores only a hash of the token in app preferences. Revoke the token from +massCode Preferences -> API to stop integration access. + +## Browser permissions + +Permissions are used to read the active tab on user action, create context menu +entries, run the page extractor, and save extension settings. + +## Store publishing + +Browser stores may require a public URL version of this notice before +publishing. diff --git a/integrations/clipper/README.md b/integrations/clipper/README.md new file mode 100644 index 000000000..03575dbe3 --- /dev/null +++ b/integrations/clipper/README.md @@ -0,0 +1,98 @@ +# massCode Clipper + +Browser extension client for saving web content into massCode. + +## Setup + +1. Open massCode preferences. +2. Go to API. +3. Enable API integrations and generate an API token. +4. Build the extension: + +```bash +pnpm integrations:clipper:build +``` + +5. Open `chrome://extensions`. +6. Enable Developer mode. +7. Load `integrations/clipper/dist/chrome` as an unpacked extension. +8. Paste the API token into the extension popup. + +## Browser targets + +The Clipper uses shared source code with browser-specific manifests: + +```bash +pnpm integrations:clipper:build:chrome +pnpm integrations:clipper:build:firefox +pnpm integrations:clipper:build:safari +``` + +Production outputs are written to: + +- `dist/chrome` +- `dist/firefox` +- `dist/safari` + +Store packages are written to `builds/`: + +```bash +pnpm integrations:clipper:package +pnpm integrations:clipper:package:chrome +pnpm integrations:clipper:package:firefox +pnpm integrations:clipper:package:safari +``` + +`package.json` is the version source of truth. Browser manifests are copied +from `manifests/` during build, and the output `manifest.json` receives the +package version automatically. + +## Local Firefox loading + +Firefox does not load an unpacked extension by selecting the `dist` folder. + +1. Build the Firefox target: + +```bash +pnpm integrations:clipper:build:firefox +``` + +2. Open `about:debugging#/runtime/this-firefox`. +3. Click `Load Temporary Add-on`. +4. Select `integrations/clipper/dist/firefox/manifest.json`. + +The extension stays installed until Firefox restarts. Use `Reload` on +`about:debugging` after rebuilding. + +## Local Safari loading + +1. Build the Safari target: + +```bash +pnpm integrations:clipper:build:safari +``` + +2. Open Safari settings and enable developer features. +3. In Safari developer settings, enable unsigned extensions. +4. Use `Add Temporary Extension...`. +5. Select `integrations/clipper/dist/safari`. + +Safari temporary extensions are removed after Safari exits or after the browser +temporary extension timeout expires. Store distribution requires a Safari Web +Extension app package through Xcode and App Store Connect. + +## Privacy + +See `PRIVACY.md` for the Clipper privacy note used for store review. + +## Scope + +The extension talks to the local massCode integration API and does not read or +write the vault directly. + +Supported MVP captures: + +- selected text to Code; +- selected text to Notes; +- current page to Notes; +- current page or selected link to HTTP as `GET`. diff --git a/integrations/clipper/manifests/chrome.json b/integrations/clipper/manifests/chrome.json new file mode 100644 index 000000000..f582d063a --- /dev/null +++ b/integrations/clipper/manifests/chrome.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 3, + "name": "massCode Clipper", + "description": "Save browser content to massCode.", + "version": "0.1.0", + "action": { + "default_popup": "popup.html", + "default_title": "Save to massCode", + "default_icon": { + "16": "logo16.png", + "32": "logo32.png" + } + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "icons": { + "16": "logo16.png", + "32": "logo32.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"], + "web_accessible_resources": [ + { + "resources": ["pageExtractor.js", "assets/*.js"], + "matches": [""] + } + ] +} diff --git a/integrations/clipper/manifests/firefox.json b/integrations/clipper/manifests/firefox.json new file mode 100644 index 000000000..4779390f0 --- /dev/null +++ b/integrations/clipper/manifests/firefox.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "massCode Clipper", + "description": "Save browser content to massCode.", + "version": "0.1.0", + "action": { + "default_popup": "popup.html", + "default_title": "Save to massCode", + "default_icon": { + "16": "logo16.png", + "32": "logo32.png" + } + }, + "background": { + "scripts": ["background.js"], + "type": "module" + }, + "icons": { + "16": "logo16.png", + "32": "logo32.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"], + "optional_host_permissions": [""], + "web_accessible_resources": [ + { + "resources": ["pageExtractor.js", "assets/*.js"], + "matches": [""], + "extension_ids": ["*"] + } + ], + "browser_specific_settings": { + "gecko": { + "id": "clipper@masscode.io", + "strict_min_version": "113.0" + } + } +} diff --git a/integrations/clipper/manifests/safari.json b/integrations/clipper/manifests/safari.json new file mode 100644 index 000000000..11c96e8cc --- /dev/null +++ b/integrations/clipper/manifests/safari.json @@ -0,0 +1,33 @@ +{ + "manifest_version": 3, + "name": "massCode Clipper", + "description": "Save browser content to massCode.", + "version": "0.1.0", + "action": { + "default_popup": "popup.html", + "default_title": "Save to massCode", + "default_icon": { + "16": "logo16.png", + "32": "logo32.png" + } + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "icons": { + "16": "logo16.png", + "32": "logo32.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"], + "web_accessible_resources": [ + { + "resources": ["pageExtractor.js", "assets/*.js"], + "matches": [""], + "extension_ids": ["*"] + } + ] +} diff --git a/integrations/clipper/package.json b/integrations/clipper/package.json new file mode 100644 index 000000000..8d87f1b5c --- /dev/null +++ b/integrations/clipper/package.json @@ -0,0 +1,24 @@ +{ + "name": "@masscode/clipper", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "build": "pnpm build:chrome && pnpm build:firefox && pnpm build:safari", + "build:chrome": "vite build --config vite.config.ts --mode chrome", + "build:firefox": "vite build --config vite.config.ts --mode firefox", + "build:safari": "vite build --config vite.config.ts --mode safari", + "package": "pnpm build && node scripts/package.mjs", + "package:chrome": "pnpm build:chrome && node scripts/package.mjs chrome", + "package:firefox": "pnpm build:firefox && node scripts/package.mjs firefox", + "package:safari": "pnpm build:safari && node scripts/package.mjs safari", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "defuddle": "0.18.1" + }, + "devDependencies": { + "typescript": "5.7.3", + "vite": "6.1.1" + } +} diff --git a/integrations/clipper/pnpm-lock.yaml b/integrations/clipper/pnpm-lock.yaml new file mode 100644 index 000000000..f37cec655 --- /dev/null +++ b/integrations/clipper/pnpm-lock.yaml @@ -0,0 +1,814 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + defuddle: + specifier: 0.18.1 + version: 0.18.1 + devDependencies: + typescript: + specifier: 5.7.3 + version: 5.7.3 + vite: + specifier: 6.1.1 + version: 6.1.1 + +packages: + + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + defuddle@0.18.1: + resolution: {integrity: sha512-AvFPFOsoDjt5xUOA1QxzafSSzJ5dqEIC63yO72tHYtSjj1DYY/XM0XTPUCsHkm5A2f1X9ulBvoSVFJrd4s2ckA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + mathml-to-latex@1.5.0: + resolution: {integrity: sha512-rrWn0eEvcEcdMM4xfHcSGIy+i01DX9byOdXTLWg+w1iJ6O6ohP5UXY1dVzNUZLhzfl3EGcRekWLhY7JT5Omaew==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + temml@0.13.2: + resolution: {integrity: sha512-n8fDRSsLscq9nh9j6z+FgkCvFMT0IJm6GCgwfzh+7AHT3Sfb4jFTQlsA6hVcF2dYYr3b66oDBVES95RfoukyrA==} + engines: {node: '>=18.13.0'} + + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + vite@6.1.1: + resolution: {integrity: sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.24.2': + optional: true + + '@esbuild/android-arm64@0.24.2': + optional: true + + '@esbuild/android-arm@0.24.2': + optional: true + + '@esbuild/android-x64@0.24.2': + optional: true + + '@esbuild/darwin-arm64@0.24.2': + optional: true + + '@esbuild/darwin-x64@0.24.2': + optional: true + + '@esbuild/freebsd-arm64@0.24.2': + optional: true + + '@esbuild/freebsd-x64@0.24.2': + optional: true + + '@esbuild/linux-arm64@0.24.2': + optional: true + + '@esbuild/linux-arm@0.24.2': + optional: true + + '@esbuild/linux-ia32@0.24.2': + optional: true + + '@esbuild/linux-loong64@0.24.2': + optional: true + + '@esbuild/linux-mips64el@0.24.2': + optional: true + + '@esbuild/linux-ppc64@0.24.2': + optional: true + + '@esbuild/linux-riscv64@0.24.2': + optional: true + + '@esbuild/linux-s390x@0.24.2': + optional: true + + '@esbuild/linux-x64@0.24.2': + optional: true + + '@esbuild/netbsd-arm64@0.24.2': + optional: true + + '@esbuild/netbsd-x64@0.24.2': + optional: true + + '@esbuild/openbsd-arm64@0.24.2': + optional: true + + '@esbuild/openbsd-x64@0.24.2': + optional: true + + '@esbuild/sunos-x64@0.24.2': + optional: true + + '@esbuild/win32-arm64@0.24.2': + optional: true + + '@esbuild/win32-ia32@0.24.2': + optional: true + + '@esbuild/win32-x64@0.24.2': + optional: true + + '@mixmark-io/domino@2.2.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@types/estree@1.0.8': {} + + '@xmldom/xmldom@0.8.13': + optional: true + + boolbase@1.0.0: + optional: true + + commander@12.1.0: {} + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + optional: true + + css-what@6.2.2: + optional: true + + cssom@0.5.0: + optional: true + + defuddle@0.18.1: + dependencies: + commander: 12.1.0 + optionalDependencies: + linkedom: 0.18.12 + mathml-to-latex: 1.5.0 + temml: 0.13.2 + turndown: 7.2.4 + transitivePeerDependencies: + - canvas + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + optional: true + + domelementtype@2.3.0: + optional: true + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + optional: true + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + optional: true + + entities@4.5.0: + optional: true + + entities@7.0.1: + optional: true + + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + + fsevents@2.3.3: + optional: true + + html-escaper@3.0.3: + optional: true + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + optional: true + + linkedom@0.18.12: + dependencies: + css-select: 5.2.2 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + optional: true + + mathml-to-latex@1.5.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + optional: true + + nanoid@3.3.12: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + optional: true + + picocolors@1.1.1: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + temml@0.13.2: + optional: true + + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + optional: true + + typescript@5.7.3: {} + + uhyphen@0.2.0: + optional: true + + vite@6.1.1: + dependencies: + esbuild: 0.24.2 + postcss: 8.5.14 + rollup: 4.60.3 + optionalDependencies: + fsevents: 2.3.3 diff --git a/integrations/clipper/popup.html b/integrations/clipper/popup.html new file mode 100644 index 000000000..af183454b --- /dev/null +++ b/integrations/clipper/popup.html @@ -0,0 +1,192 @@ + + + + + + Save to massCode + + + +
+
+
+

Save to massCode

+

Save selected text or the current page.

+
+ +
+ + + +
+ +
+ Current page + Waiting for selection +
+
+ +
+
+ + + +
+ + + + +
+ +
+
+ + + Save + Esc + Close +
+ +
+ +

+
+ + diff --git a/integrations/clipper/public/logo128.png b/integrations/clipper/public/logo128.png new file mode 100644 index 000000000..91d0e6b31 Binary files /dev/null and b/integrations/clipper/public/logo128.png differ diff --git a/integrations/clipper/public/logo16.png b/integrations/clipper/public/logo16.png new file mode 100644 index 000000000..acb657855 Binary files /dev/null and b/integrations/clipper/public/logo16.png differ diff --git a/integrations/clipper/public/logo32.png b/integrations/clipper/public/logo32.png new file mode 100644 index 000000000..c3cfb7794 Binary files /dev/null and b/integrations/clipper/public/logo32.png differ diff --git a/integrations/clipper/public/logo48.png b/integrations/clipper/public/logo48.png new file mode 100644 index 000000000..617a5ece7 Binary files /dev/null and b/integrations/clipper/public/logo48.png differ diff --git a/integrations/clipper/scripts/package.mjs b/integrations/clipper/scripts/package.mjs new file mode 100644 index 000000000..c0e584c5e --- /dev/null +++ b/integrations/clipper/scripts/package.mjs @@ -0,0 +1,49 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, +} from 'node:fs' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const targets = ['chrome', 'firefox', 'safari'] +const requestedTargets = process.argv.slice(2) +const packageTargets = requestedTargets.length > 0 ? requestedTargets : targets +const buildsDir = resolve(root, 'builds') + +mkdirSync(buildsDir, { recursive: true }) + +for (const target of packageTargets) { + if (!targets.includes(target)) { + throw new Error(`Unsupported clipper package target: ${target}`) + } + + const targetDir = resolve(root, 'dist', target) + const manifestPath = resolve(targetDir, 'manifest.json') + + if (!existsSync(manifestPath)) { + throw new Error(`Build ${target} before packaging it.`) + } + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const version = manifest.version + const archiveName = `masscode-clipper-${version}-${target}.zip` + const archivePath = resolve(buildsDir, archiveName) + + rmSync(archivePath, { force: true }) + + const result = spawnSync('zip', ['-r', '-X', archivePath, '.'], { + cwd: targetDir, + stdio: 'inherit', + }) + + if (result.status !== 0) { + throw new Error(`Failed to package ${target}.`) + } + + console.log(`Created ${archivePath}`) +} diff --git a/integrations/clipper/src/api.ts b/integrations/clipper/src/api.ts new file mode 100644 index 000000000..cfcf3f6f0 --- /dev/null +++ b/integrations/clipper/src/api.ts @@ -0,0 +1,204 @@ +import type { + CaptureRequest, + CaptureResponse, + CaptureTarget, + ExtensionSettings, + PageCapturePayload, +} from './types' + +const DEFAULT_SETTINGS: ExtensionSettings = { + apiPort: 4321, + apiToken: '', + defaultTarget: 'notes', +} + +export async function getSettings(): Promise { + const stored = await chrome.storage.local.get(DEFAULT_SETTINGS) + + return { + apiPort: Number(stored.apiPort) || DEFAULT_SETTINGS.apiPort, + apiToken: + typeof stored.apiToken === 'string' + ? stored.apiToken + : DEFAULT_SETTINGS.apiToken, + defaultTarget: isCaptureTarget(stored.defaultTarget) + ? stored.defaultTarget + : DEFAULT_SETTINGS.defaultTarget, + } +} + +export async function saveSettings(settings: ExtensionSettings): Promise { + await chrome.storage.local.set(settings) +} + +export function buildCaptureRequest( + target: CaptureTarget, + payload: PageCapturePayload, + explicitName?: string, +): CaptureRequest { + const selectedText = payload.selectedText.trim() + const noteMarkdown = payload.selectedMarkdown ?? payload.pageMarkdown + const noteText = selectedText || payload.pageText || payload.pageTitle + const name = trimToValue(explicitName) + + if (target === 'http') { + const url = findFirstUrl(selectedText) ?? payload.url + + return { + contextLabel: payload.contextLabel, + method: 'GET', + name, + pageTitle: payload.pageTitle, + sourceTitle: payload.sourceTitle, + sourceUrl: payload.sourceUrl, + source: { + capturedAt: Date.now(), + title: payload.sourceTitle, + url: payload.sourceUrl, + }, + suggestedName: getHttpSuggestedName(url), + target, + url, + } + } + + return { + contextLabel: payload.contextLabel, + language: target === 'code' ? getCodeLanguage(payload) : undefined, + markdown: target === 'notes' ? noteMarkdown : undefined, + name, + pageTitle: payload.pageTitle, + sourceTitle: payload.sourceTitle, + sourceUrl: payload.sourceUrl, + source: { + capturedAt: Date.now(), + title: payload.sourceTitle, + url: payload.sourceUrl, + }, + suggestedName: payload.suggestedName ?? payload.contextLabel, + target, + text: target === 'notes' ? noteText : selectedText, + url: payload.url, + } +} + +export function getCaptureNameSuggestion( + target: CaptureTarget, + payload: PageCapturePayload, +): string { + if (target === 'http') { + const url = findFirstUrl(payload.selectedText.trim()) ?? payload.url + + return getHttpSuggestedName(url) + } + + return ( + payload.suggestedName + ?? payload.contextLabel + ?? payload.sourceTitle + ?? payload.pageTitle + ) +} + +export async function postCapture( + settings: ExtensionSettings, + request: CaptureRequest, +): Promise { + const response = await fetch( + `http://localhost:${settings.apiPort}/captures/`, + { + body: JSON.stringify(request), + headers: { + 'Authorization': `Bearer ${settings.apiToken}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }, + ) + + if (!response.ok) { + throw new Error(await getErrorMessage(response)) + } + + return (await response.json()) as CaptureResponse +} + +export function isCaptureTarget(value: unknown): value is CaptureTarget { + return value === 'code' || value === 'notes' || value === 'http' +} + +function findFirstUrl(text: string): string | undefined { + return text.match(/https?:\/\/\S+/)?.[0] +} + +export function getCodeLanguage(payload: PageCapturePayload): string { + const sourceName + = trimToValue(payload.contextLabel) + ?? trimToValue(payload.sourceTitle) + ?? trimToValue(payload.url) + const extension = sourceName?.match(/\.([a-z0-9]+)(?:$|[\s?#])/i)?.[1] + + if (!extension) { + return 'plain_text' + } + + const languages: Record = { + c: 'c_cpp', + cc: 'c_cpp', + cpp: 'c_cpp', + cs: 'csharp', + cjs: 'javascript', + css: 'css', + go: 'golang', + h: 'c_cpp', + hpp: 'c_cpp', + html: 'html', + java: 'java', + js: 'javascript', + json: 'json', + jsx: 'jsx', + md: 'markdown', + mjs: 'javascript', + php: 'php', + py: 'python', + rs: 'rust', + scss: 'scss', + sh: 'sh', + ts: 'typescript', + tsx: 'tsx', + vue: 'vue', + yaml: 'yaml', + yml: 'yaml', + } + + return languages[extension.toLowerCase()] ?? 'plain_text' +} + +function trimToValue(value?: string): string | undefined { + const trimmed = value?.trim() + + return trimmed || undefined +} + +function getHttpSuggestedName(url: string): string { + try { + const parsedUrl = new URL(url) + const path + = parsedUrl.pathname === '/' ? parsedUrl.hostname : parsedUrl.pathname + + return `GET ${path}` + } + catch { + return 'GET request' + } +} + +async function getErrorMessage(response: Response): Promise { + try { + const data = (await response.json()) as { message?: string } + return data.message ?? `massCode API error: ${response.status}` + } + catch { + return `massCode API error: ${response.status}` + } +} diff --git a/integrations/clipper/src/background.ts b/integrations/clipper/src/background.ts new file mode 100644 index 000000000..4f1723b61 --- /dev/null +++ b/integrations/clipper/src/background.ts @@ -0,0 +1,109 @@ +import type { CaptureTarget, PageCapturePayload } from './types' +import { buildCaptureRequest, getSettings, postCapture } from './api' +import { getPageCaptureFromPage } from './pageCapture' + +const MENU_ROOT_ID = 'masscode-capture' +const MENU_TARGETS: Array<{ + contexts: chrome.contextMenus.ContextType[] + id: CaptureTarget + title: string +}> = [ + { contexts: ['selection'], id: 'code', title: 'Code' }, + { contexts: ['selection', 'page'], id: 'notes', title: 'Note' }, + { + contexts: ['selection', 'page', 'link'], + id: 'http', + title: 'HTTP Request', + }, +] + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + contexts: ['selection', 'page', 'link'], + id: MENU_ROOT_ID, + title: 'Save to massCode', + }) + + MENU_TARGETS.forEach((target) => { + chrome.contextMenus.create({ + contexts: target.contexts, + id: target.id, + parentId: MENU_ROOT_ID, + title: target.title, + }) + }) + }) +}) + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (!isCaptureTarget(info.menuItemId) || !tab?.id) { + return + } + + void captureFromTab(tab.id, info.menuItemId, { + linkUrl: info.linkUrl, + selectionText: info.selectionText, + }) +}) + +async function captureFromTab( + tabId: number, + target: CaptureTarget, + context: { linkUrl?: string, selectionText?: string }, +): Promise { + try { + const settings = await getSettings() + if (!settings.apiToken.trim()) { + throw new Error('Set the massCode API token in the extension popup.') + } + + const payload = await getPageCapture(tabId) + const request = buildCaptureRequest(target, { + ...payload, + selectedText: payload.selectedText || context.selectionText?.trim() || '', + url: context.linkUrl || payload.url, + }) + + const result = await postCapture(settings, request) + await notify( + 'Saved to massCode', + `Created ${result.target} item #${result.id}.`, + ) + } + catch (error) { + await notify('massCode capture failed', getErrorMessage(error)) + } +} + +async function getPageCapture(tabId: number): Promise { + const [response] = await chrome.scripting.executeScript({ + func: getPageCaptureFromPage, + target: { tabId }, + }) + + if (!response.result) { + throw new Error('Could not read the active page.') + } + + return response.result +} + +async function notify(title: string, message: string): Promise { + await chrome.action.setBadgeText({ + text: title.startsWith('Saved') ? 'OK' : 'ERR', + }) + await chrome.action.setTitle({ title: `${title}: ${message}` }) + + setTimeout(() => { + void chrome.action.setBadgeText({ text: '' }) + }, 3000) +} + +function isCaptureTarget(value: string | number): value is CaptureTarget { + return value === 'code' || value === 'notes' || value === 'http' +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error' +} diff --git a/integrations/clipper/src/chrome.d.ts b/integrations/clipper/src/chrome.d.ts new file mode 100644 index 000000000..9768b3059 --- /dev/null +++ b/integrations/clipper/src/chrome.d.ts @@ -0,0 +1,68 @@ +declare namespace chrome { + namespace action { + const setBadgeText: (details: { text: string }) => Promise + const setTitle: (details: { title: string }) => Promise + } + + namespace contextMenus { + type ContextType = 'selection' | 'page' | 'link' + + interface OnClickData { + linkUrl?: string + menuItemId: number | string + selectionText?: string + } + + const create: (properties: { + contexts?: ContextType[] + id: string + parentId?: string + title: string + }) => void + const removeAll: (callback?: () => void) => void + + const onClicked: { + addListener: ( + callback: (info: OnClickData, tab?: tabs.Tab) => void, + ) => void + } + } + + namespace runtime { + const getURL: (path: string) => string + + const onInstalled: { + addListener: (callback: () => void) => void + } + } + + namespace scripting { + interface InjectionResult { + result?: T + } + + const executeScript: (options: { + func: () => Promise | T + target: { tabId: number } + }) => Promise>> + } + + namespace storage { + const local: { + get: (defaults: T) => Promise + set: (values: T) => Promise + } + } + + namespace tabs { + interface Tab { + favIconUrl?: string + id?: number + } + + const query: (queryInfo: { + active: boolean + currentWindow: boolean + }) => Promise + } +} diff --git a/integrations/clipper/src/pageCapture.ts b/integrations/clipper/src/pageCapture.ts new file mode 100644 index 000000000..c5a4ef369 --- /dev/null +++ b/integrations/clipper/src/pageCapture.ts @@ -0,0 +1,21 @@ +import type { PageCapturePayload } from './types' + +interface PageExtractorModule { + getPageCaptureFromPage: () => PageCapturePayload +} + +declare global { + // eslint-disable-next-line vars-on-top, no-var + var massCodePageExtractor: PageExtractorModule | undefined +} + +export async function getPageCaptureFromPage(): Promise { + await import(chrome.runtime.getURL('pageExtractor.js')) + const extractor = globalThis.massCodePageExtractor + + if (!extractor) { + throw new Error('Could not initialize the page extractor.') + } + + return extractor.getPageCaptureFromPage() +} diff --git a/integrations/clipper/src/pageExtractor.ts b/integrations/clipper/src/pageExtractor.ts new file mode 100644 index 000000000..a486674d5 --- /dev/null +++ b/integrations/clipper/src/pageExtractor.ts @@ -0,0 +1,130 @@ +import type { PageCapturePayload } from './types' +import Defuddle, { createMarkdownContent } from 'defuddle/full' + +declare global { + // eslint-disable-next-line vars-on-top, no-var + var massCodePageExtractor: PageExtractorModule | undefined +} + +interface PageExtractorModule { + getPageCaptureFromPage: () => PageCapturePayload +} + +export function getPageCaptureFromPage(): PageCapturePayload { + const contextLabel = getContextLabel() + const selectedText = getSelectedText() + const selectedHtml = contextLabel ? '' : getSelectedHtml() + const extracted = getExtractedPage() + const pageTitle = extracted.title || document.title + const selectedMarkdown = selectedHtml + ? normalizeMarkdown( + createMarkdownContent(selectedHtml, window.location.href), + ) + : undefined + + return { + contextLabel, + pageMarkdown: contextLabel ? undefined : extracted.markdown, + pageText: extracted.text, + pageTitle, + selectedMarkdown, + selectedText, + sourceTitle: pageTitle, + sourceUrl: window.location.href, + suggestedName: contextLabel, + url: window.location.href, + } +} + +function getSelectedText(): string { + return window.getSelection()?.toString().trim() ?? '' +} + +function getSelectedHtml(): string { + const selection = window.getSelection() + + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return '' + } + + const container = document.createElement('div') + for (let index = 0; index < selection.rangeCount; index += 1) { + container.append(selection.getRangeAt(index).cloneContents()) + } + + return container.innerHTML.trim() +} + +function getElementText( + selector: string, + root: ParentNode, +): string | undefined { + return root.querySelector(selector)?.textContent?.trim() || undefined +} + +function getContextLabel(): string | undefined { + const selection = window.getSelection() + const node = selection?.anchorNode + const element = node instanceof Element ? node : node?.parentElement + const fileRoot = element?.closest('.file, .gist-file, [data-path]') + + if (!fileRoot) { + return undefined + } + + const dataPath = fileRoot.getAttribute('data-path')?.trim() + if (dataPath) { + return dataPath + } + + return ( + getElementText('.file-info a', fileRoot) + ?? getElementText('.file-header a', fileRoot) + ?? getElementText('[data-testid="file-name"]', fileRoot) + ?? undefined + ) +} + +function getExtractedPage(): { + markdown?: string + text?: string + title?: string +} { + try { + const result = new Defuddle(document, { + includeReplies: false, + url: window.location.href, + }).parse() + const markdown = normalizeMarkdown( + createMarkdownContent(result.content, window.location.href), + ) + + return { + markdown: markdown || undefined, + text: getHtmlText(result.content), + title: result.title || undefined, + } + } + catch { + return { + title: document.title, + } + } +} + +function getHtmlText(html: string): string | undefined { + const parsed = new DOMParser().parseFromString(html, 'text/html') + + return parsed.body.textContent?.replace(/\s+/g, ' ').trim() || undefined +} + +function normalizeMarkdown(value: string): string { + return value + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +globalThis.massCodePageExtractor = { + getPageCaptureFromPage, +} diff --git a/integrations/clipper/src/popup.ts b/integrations/clipper/src/popup.ts new file mode 100644 index 000000000..9d52c7c70 --- /dev/null +++ b/integrations/clipper/src/popup.ts @@ -0,0 +1,386 @@ +import type { + CaptureTarget, + ExtensionSettings, + PageCapturePayload, +} from './types' +import { + buildCaptureRequest, + getCaptureNameSuggestion, + getCodeLanguage, + getSettings, + isCaptureTarget, + postCapture, + saveSettings, +} from './api' +import { getPageCaptureFromPage } from './pageCapture' +import './styles.css' + +const apiPortInput = document.querySelector('#apiPort') +const apiTokenInput = document.querySelector('#apiToken') +const captureNameInput + = document.querySelector('#captureName') +const previewInput = document.querySelector('#preview') +const previewLabel = document.querySelector('#previewLabel') +const saveSettingsButton + = document.querySelector('#saveSettings') +const captureButton = document.querySelector('#capture') +const captureLabel = document.querySelector('#captureLabel') +const settingsPanel = document.querySelector('#settingsPanel') +const sourceMark = document.querySelector('.source-mark') +const sourceTitle = document.querySelector('#sourceTitle') +const sourcePath = document.querySelector('#sourcePath') +const statusText = document.querySelector('#status') +const toggleSettingsButton + = document.querySelector('#toggleSettings') +const targetButtons = Array.from( + document.querySelectorAll('[data-target]'), +) + +let settings: ExtensionSettings +let activeTarget: CaptureTarget = 'notes' +let currentPayload: PageCapturePayload | null = null +let isCaptureNameEdited = false + +void init() + +async function init(): Promise { + settings = await getSettings() + activeTarget = settings.defaultTarget + + if (apiPortInput) { + apiPortInput.value = String(settings.apiPort) + } + + if (apiTokenInput) { + apiTokenInput.value = settings.apiToken + } + + bindEvents() + updateTargetButtons() + updateSettingsPanel(!settings.apiToken.trim()) + + try { + currentPayload = await getActiveTabCapture() + updateTargetButtons() + updateCaptureName(true) + updateSourceRow() + updatePreview() + } + catch (error) { + setStatus(getErrorMessage(error), true) + } +} + +function bindEvents(): void { + targetButtons.forEach((button) => { + button.addEventListener('click', () => { + const target = button.dataset.target + if (!isCaptureTarget(target)) { + return + } + + activeTarget = target + settings.defaultTarget = activeTarget + updateTargetButtons() + updateCaptureName() + updatePreview() + void persistDefaultTarget() + }) + }) + + captureNameInput?.addEventListener('input', () => { + isCaptureNameEdited = true + }) + + toggleSettingsButton?.addEventListener('click', () => { + updateSettingsPanel(settingsPanel?.hidden ?? true) + }) + + saveSettingsButton?.addEventListener('click', () => { + void persistSettings() + }) + + captureButton?.addEventListener('click', () => { + void captureCurrentPayload() + }) + + document.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault() + void captureCurrentPayload() + return + } + + if (event.key === 'Escape') { + window.close() + } + }) +} + +async function persistSettings(): Promise { + settings = readSettingsFromForm() + await saveSettings(settings) + setStatus('Settings saved.') +} + +async function persistDefaultTarget(): Promise { + try { + await saveSettings(settings) + } + catch (error) { + setStatus(getErrorMessage(error), true) + } +} + +async function captureCurrentPayload(): Promise { + if (!currentPayload) { + setStatus('No active page to capture.', true) + return + } + + settings = readSettingsFromForm() + await saveSettings(settings) + + if (!settings.apiToken.trim()) { + setStatus('Set the massCode API token first.', true) + return + } + + if (!canCaptureCurrentTarget()) { + setStatus('Select text on the page to save a snippet.', true) + return + } + + try { + const request = buildCaptureRequest( + activeTarget, + currentPayload, + captureNameInput?.value, + ) + const result = await postCapture(settings, request) + setStatus(`Saved ${result.target} item #${result.id}.`) + } + catch (error) { + setStatus(getErrorMessage(error), true) + } +} + +async function getActiveTabCapture(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + + if (!tab.id) { + throw new Error('No active tab found.') + } + + const [response] = await chrome.scripting.executeScript({ + func: getPageCaptureFromPage, + target: { tabId: tab.id }, + }) + + if (!response.result) { + throw new Error('Could not read the active page.') + } + + return { + ...response.result, + faviconUrl: tab.favIconUrl, + } +} + +function readSettingsFromForm(): ExtensionSettings { + return { + apiPort: Number(apiPortInput?.value) || settings.apiPort, + apiToken: apiTokenInput?.value ?? settings.apiToken, + defaultTarget: activeTarget, + } +} + +function updateTargetButtons(): void { + targetButtons.forEach((button) => { + button.dataset.active + = button.dataset.target === activeTarget ? 'true' : 'false' + }) + + if (captureLabel) { + captureLabel.textContent = getCaptureButtonLabel(activeTarget) + } + + if (captureButton) { + captureButton.disabled = !canCaptureCurrentTarget() + } +} + +function updateCaptureName(force = false): void { + if (!captureNameInput || !currentPayload) { + return + } + + if (isCaptureNameEdited && !force) { + return + } + + captureNameInput.value = getCaptureNameSuggestion( + activeTarget, + currentPayload, + ) + isCaptureNameEdited = false +} + +function updatePreview(): void { + if (!previewInput || !currentPayload) { + return + } + + if (activeTarget === 'http') { + previewInput.value = currentPayload.url + updatePreviewLabel() + return + } + + if (activeTarget === 'notes') { + previewInput.value + = currentPayload.selectedMarkdown + || currentPayload.selectedText + || currentPayload.pageMarkdown + || currentPayload.pageText + || currentPayload.pageTitle + updatePreviewLabel() + return + } + + previewInput.value + = currentPayload.selectedText || 'Select text on the page to save a snippet.' + updatePreviewLabel() +} + +function canCaptureCurrentTarget(): boolean { + if (!currentPayload) { + return false + } + + if (activeTarget === 'code') { + return Boolean(currentPayload.selectedText.trim()) + } + + return true +} + +function updateSettingsPanel(isOpen: boolean): void { + if (!settingsPanel || !toggleSettingsButton) { + return + } + + settingsPanel.hidden = !isOpen + toggleSettingsButton.dataset.active = isOpen ? 'true' : 'false' +} + +function updateSourceRow(): void { + if (!currentPayload) { + return + } + + const source = getSourceParts(currentPayload) + + if (sourceTitle) { + sourceTitle.textContent = source.title + } + + if (sourcePath) { + sourcePath.textContent = source.path + } + + updateSourceIcon(currentPayload.faviconUrl) +} + +function updatePreviewLabel(): void { + if (!previewLabel || !previewInput) { + return + } + + const lines = previewInput.value ? previewInput.value.split('\n').length : 0 + const format = getPreviewFormat() + + previewLabel.textContent = `Preview · ${format} · ${lines} ${lines === 1 ? 'line' : 'lines'}` +} + +function getPreviewFormat(): string { + if (activeTarget === 'http') { + return 'url' + } + + if (activeTarget === 'code' && currentPayload) { + if (!currentPayload.selectedText.trim()) { + return 'text' + } + + const language = getCodeLanguage(currentPayload) + + return language === 'plain_text' ? 'text' : language + } + + if ( + activeTarget === 'notes' + && (currentPayload?.selectedMarkdown || currentPayload?.pageMarkdown) + ) { + return 'markdown' + } + + return 'text' +} + +function getSourceParts(payload: PageCapturePayload): { + path: string + title: string +} { + try { + const url = new URL(payload.sourceUrl) + + return { + path: payload.contextLabel ?? (url.pathname || '/'), + title: url.hostname.replace(/^www\./, ''), + } + } + catch { + return { + path: payload.contextLabel ?? payload.sourceUrl, + title: payload.sourceTitle || 'Current page', + } + } +} + +function updateSourceIcon(faviconUrl?: string): void { + if (!sourceMark) { + return + } + + sourceMark.textContent = '' + + if (!faviconUrl) { + sourceMark.textContent = 'M' + sourceMark.style.backgroundImage = '' + return + } + + sourceMark.style.backgroundImage = `url("${faviconUrl}")` +} + +function getCaptureButtonLabel(target: CaptureTarget): string { + return target === 'code' + ? 'Save snippet' + : target === 'notes' + ? 'Save note' + : 'Save request' +} + +function setStatus(message: string, isError = false): void { + if (!statusText) { + return + } + + statusText.textContent = message + statusText.dataset.error = isError ? 'true' : 'false' +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error' +} diff --git a/integrations/clipper/src/styles.css b/integrations/clipper/src/styles.css new file mode 100644 index 000000000..f994ecc3a --- /dev/null +++ b/integrations/clipper/src/styles.css @@ -0,0 +1,368 @@ +:root { + --radius: 0.625rem; + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --background: oklch(24.78% 0 0); + --foreground: oklch(75% 0 0); + --card: color-mix(in oklch, var(--foreground) 4%, var(--background)); + --primary: oklch(50% 0.19 260); + --primary-foreground: oklch(100% 0 0); + --secondary: oklch(27% 0 0); + --secondary-foreground: oklch(95% 0 0); + --muted: oklch(27% 0 0); + --muted-foreground: oklch(60% 0 0); + --accent: oklch(32% 0 0); + --accent-hover: color-mix(in oklch, var(--accent) 50%, var(--background)); + --accent-foreground: oklch(95% 0 0); + --border: oklch(30% 0 0); + --input: oklch(30% 0 0); + --ring: oklch(50% 0.19 260); + --destructive: oklch(0.704 0.191 22.216); + color: var(--foreground); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-size: 14px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + width: 400px; + background: var(--background); +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +svg { + display: block; +} + +.icon { + width: 16px; + height: 16px; + fill: none; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.shell { + overflow: hidden; + border: 1px solid var(--border); + background: var(--background); +} + +.header, +.source-row, +.content, +.footer, +.settings-panel, +.status { + padding-right: 14px; + padding-left: 14px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-top: 14px; + padding-bottom: 12px; +} + +h1, +p { + margin: 0; +} + +h1 { + color: var(--secondary-foreground); + font-size: 15px; + font-weight: 600; + letter-spacing: 0; + line-height: 20px; +} + +.header p { + margin-top: 3px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; +} + +.icon-button { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: var(--radius-sm); + border: 0; + background: transparent; + color: var(--muted-foreground); +} + +.icon-button:hover, +.icon-button[data-active="true"] { + background: var(--accent); + color: var(--accent-foreground); +} + +.settings-panel { + display: grid; + grid-template-columns: 86px 1fr auto; + align-items: end; + gap: 8px; + padding-top: 10px; + padding-bottom: 10px; + border-top: 1px solid var(--border); + background: var(--card); +} + +.settings-panel[hidden] { + display: none; +} + +.source-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 42px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--card); +} + +.source-mark { + display: grid; + width: 20px; + height: 20px; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background-position: center; + background-repeat: no-repeat; + background-size: 16px 16px; + color: var(--foreground); + font-size: 11px; + font-weight: 600; + line-height: 1; +} + +.source-text { + display: flex; + min-width: 0; + align-items: baseline; + gap: 6px; +} + +#sourceTitle, +#sourcePath { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#sourceTitle { + color: var(--secondary-foreground); + font-weight: 500; +} + +#sourcePath { + color: var(--muted-foreground); + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.content { + display: grid; + gap: 12px; + padding-top: 12px; + padding-bottom: 12px; +} + +.segments { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); +} + +.segments button { + display: flex; + height: 34px; + align-items: center; + justify-content: center; + gap: 6px; + border: 0; + border-right: 1px solid var(--border); + background: transparent; + color: var(--muted-foreground); + font-size: 13px; + font-weight: 500; +} + +.segments button:last-child { + border-right: 0; +} + +.segments button:hover { + background: var(--accent-hover); + color: var(--accent-foreground); +} + +.segments button[data-active="true"] { + background: var(--accent); + color: var(--accent-foreground); +} + +label { + display: grid; + gap: 6px; + color: var(--muted-foreground); + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +input, +textarea { + width: 100%; + border: 1px solid var(--input); + border-radius: var(--radius-md); + background: var(--background); + color: var(--foreground); + outline: none; +} + +input { + height: 32px; + padding: 0 9px; +} + +textarea { + min-height: 116px; + max-height: 180px; + padding: 9px; + resize: vertical; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; + line-height: 18px; +} + +input:focus, +textarea:focus { + border-color: var(--ring); + box-shadow: 0 0 0 2px rgb(113 113 122 / 22%); +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 52px; + border-top: 1px solid var(--border); + background: var(--card); +} + +.shortcuts { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + color: var(--muted-foreground); + font-size: 12px; + white-space: nowrap; +} + +.shortcuts span:nth-child(1), +.shortcuts span:nth-child(2), +.shortcuts span:nth-child(4) { + display: grid; + min-width: 22px; + height: 22px; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--background); + color: var(--muted-foreground); + font-size: 11px; +} + +.primary-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + border: 0; + font-weight: 500; +} + +.primary-button { + min-width: 118px; + height: 34px; + padding: 0 12px; + background: var(--primary); + color: var(--primary-foreground); + font-size: 13px; +} + +.primary-button:hover { + background: color-mix(in oklch, var(--primary) 92%, black); +} + +.primary-button:disabled { + cursor: default; + opacity: 0.55; +} + +.primary-button:disabled:hover { + background: var(--primary); +} + +.secondary-button { + height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + background: var(--secondary); + color: var(--secondary-foreground); +} + +.secondary-button:hover { + background: var(--accent); +} + +.status { + min-height: 16px; + padding-top: 0; + padding-bottom: 10px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; +} + +.status:empty { + display: none; +} + +.status[data-error="true"] { + color: var(--destructive); +} diff --git a/integrations/clipper/src/types.ts b/integrations/clipper/src/types.ts new file mode 100644 index 000000000..de2b5140e --- /dev/null +++ b/integrations/clipper/src/types.ts @@ -0,0 +1,46 @@ +export type CaptureTarget = 'code' | 'notes' | 'http' + +export interface ExtensionSettings { + apiPort: number + apiToken: string + defaultTarget: CaptureTarget +} + +export interface PageCapturePayload { + contextLabel?: string + faviconUrl?: string + pageMarkdown?: string + pageTitle: string + pageText?: string + selectedMarkdown?: string + selectedText: string + sourceTitle: string + sourceUrl: string + suggestedName?: string + url: string +} + +export interface CaptureRequest { + target: CaptureTarget + contextLabel?: string + markdown?: string + name?: string + text?: string + url?: string + pageTitle?: string + suggestedName?: string + sourceTitle?: string + sourceUrl?: string + language?: string + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + source?: { + title?: string + url?: string + capturedAt?: number + } +} + +export interface CaptureResponse { + target: CaptureTarget + id: number +} diff --git a/integrations/clipper/tsconfig.json b/integrations/clipper/tsconfig.json new file mode 100644 index 000000000..1dcb17b81 --- /dev/null +++ b/integrations/clipper/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": false, + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noImplicitAny": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/integrations/clipper/vite.config.ts b/integrations/clipper/vite.config.ts new file mode 100644 index 000000000..27b72abfe --- /dev/null +++ b/integrations/clipper/vite.config.ts @@ -0,0 +1,73 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { defineConfig } from 'vite' + +const root = __dirname +const browsers = ['chrome', 'firefox', 'safari'] as const +type BrowserTarget = (typeof browsers)[number] + +interface ClipperPackage { + version: string +} + +interface ExtensionManifest { + version: string + [key: string]: unknown +} + +function getBrowserTarget(mode: string): BrowserTarget { + return browsers.includes(mode as BrowserTarget) + ? (mode as BrowserTarget) + : 'chrome' +} + +function readJson(filePath: string): T { + return JSON.parse(readFileSync(filePath, 'utf8')) as T +} + +export default defineConfig(({ mode }) => { + const browser = getBrowserTarget(mode) + const outDir = resolve(root, 'dist', browser) + + return { + build: { + emptyOutDir: true, + outDir, + rollupOptions: { + input: { + background: resolve(root, 'src/background.ts'), + pageExtractor: resolve(root, 'src/pageExtractor.ts'), + popup: resolve(root, 'popup.html'), + }, + output: { + assetFileNames: 'assets/[name][extname]', + chunkFileNames: 'assets/[name].js', + entryFileNames: '[name].js', + }, + }, + }, + plugins: [ + { + name: 'clipper-browser-manifest', + writeBundle() { + mkdirSync(outDir, { recursive: true }) + const packageJson = readJson( + resolve(root, 'package.json'), + ) + const manifest = readJson( + resolve(root, 'manifests', `${browser}.json`), + ) + + manifest.version = packageJson.version + + writeFileSync( + resolve(outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + }, + }, + ], + publicDir: resolve(root, 'public'), + root, + } +}) diff --git a/package.json b/package.json index 03512a6dc..f437909a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "masscode", - "version": "5.4.0", + "version": "5.9.1", "description": "A free and open source code snippets manager for developers", "author": { "name": "Anton Reshetov", @@ -17,20 +17,17 @@ "dev:main": "nodemon", "dev:start": "wait-on tcp:5177 build/main/index.js && cross-env NODE_ENV=development DEV_PORT=5177 electronmon .", "build": "vite build && npm run build:main && electron-builder", - "build:mac": "vite build && npm run build:main && npm run build:mac:x64 && npm run build:mac:arm64", + "build:mac": "vite build && npm run build:main && electron-builder --mac --x64 --arm64", "build:mac:x64": "vite build && npm run build:main && electron-builder --mac --x64", "build:mac:arm64": "vite build && npm run build:main && electron-builder --mac --arm64", "build:win": "vite build && npm run build:main && electron-builder --win --x64", "build:linux": "vite build && npm run build:main && electron-builder --linux --x64", - "build:sponsored:mac": "vite build && npm run build:main && npm run build:sponsored:mac:x64 && npm run build:sponsored:mac:arm64", - "build:sponsored:mac:x64": "vite build && npm run build:main && electron-builder --config electron-builder.sponsored.json --mac --x64", - "build:sponsored:mac:arm64": "vite build && npm run build:main && electron-builder --config electron-builder.sponsored.json --mac --arm64", - "build:sponsored:win": "vite build && npm run build:main && electron-builder --config electron-builder.sponsored.json --win --x64", - "build:sponsored:linux": "vite build && npm run build:main && electron-builder --config electron-builder.sponsored.json --linux --x64", + "build:mac:local": "vite build && npm run build:main && electron-builder --mac -c.mac.notarize=false", "build:all": "vite build && npm run build:main && npm run build:mac && npm run build:win && npm run build:linux", "build:main": "npm run i18n:copy && tsc -p tsconfig.main.json", "api:generate": "node scripts/api-generate.js", "i18n:copy": "node scripts/i18n/copy-locales.js", + "license:issue": "node scripts/license/issue.js", "i18n:check": "node scripts/i18n/check-locale-parity.mjs", "bench:load-test": "node scripts/bench-load-test.js", "bench:seed": "node scripts/bench-seed.js", @@ -40,6 +37,15 @@ "docs:generate-assets": "node docs/website/scripts/generate-assets.mjs", "docs:build": "pnpm docs:generate-assets && pnpm --dir docs/website build", "docs:preview": "pnpm --dir docs/website preview", + "integrations:clipper:build": "pnpm --dir integrations/clipper build", + "integrations:clipper:build:chrome": "pnpm --dir integrations/clipper build:chrome", + "integrations:clipper:build:firefox": "pnpm --dir integrations/clipper build:firefox", + "integrations:clipper:build:safari": "pnpm --dir integrations/clipper build:safari", + "integrations:clipper:package": "pnpm --dir integrations/clipper package", + "integrations:clipper:package:chrome": "pnpm --dir integrations/clipper package:chrome", + "integrations:clipper:package:firefox": "pnpm --dir integrations/clipper package:firefox", + "integrations:clipper:package:safari": "pnpm --dir integrations/clipper package:safari", + "integrations:clipper:typecheck": "pnpm --dir integrations/clipper typecheck", "lint": "eslint .", "lint:fix": "eslint --fix .", "release": "bumpp -c 'build: release v' -t", @@ -61,7 +67,9 @@ "@elysiajs/cors": "^1.2.0", "@elysiajs/node": "^1.4.2", "@elysiajs/swagger": "^1.3.1", + "@excalidraw/excalidraw": "^0.18.1", "@faker-js/faker": "^10.1.0", + "@internationalized/date": "3.7.0", "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.6.3", "@sinclair/typebox": "^0.34.41", @@ -87,7 +95,8 @@ "date-fns": "^4.1.0", "dom-to-image": "^2.6.0", "electron-store": "^8.2.0", - "elysia": "^1.4.16", + "electron-updater": "^6.8.9", + "elysia": "~1.4.28", "fs-extra": "^11.3.0", "i18next": "^24.2.2", "i18next-fs-backend": "^2.6.0", @@ -101,10 +110,11 @@ "markmap-view": "^0.18.10", "mathjs": "^15.1.1", "mermaid": "^11.6.0", - "nanoid": "^3.3.8", "nearest-color": "^0.4.4", "onigasm": "^2.2.5", "prettier": "^3.5.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", "reka-ui": "^2.9.0", "sanitize-html": "^2.15.0", "slash": "^3.0.0", @@ -113,6 +123,7 @@ "toml": "^3.0.0", "typed.js": "^2.1.0", "undici": "^6.21.2", + "unicode-emoji": "2.8.0", "uuid": "^11.1.0", "vue-sonner": "^1.3.0", "vue-virtual-scroller": "2.0.0-beta.8", @@ -134,8 +145,11 @@ "@types/fs-extra": "^11.0.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.8", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@types/sanitize-html": "^2.15.0", "@vitejs/plugin-vue": "^5.2.1", + "@vue/server-renderer": "3.5.13", "autoprefixer": "^10.4.20", "bumpp": "^9.10.2", "concurrently": "^9.1.2", @@ -163,8 +177,12 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "@parcel/watcher", "better-sqlite3", - "electron" + "electron", + "esbuild", + "simple-git-hooks", + "vue-demi" ] }, "simple-git-hooks": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b998308a4..446376231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,16 +40,22 @@ importers: version: 1.1.5 '@elysiajs/cors': specifier: ^1.2.0 - version: 1.2.0(elysia@1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3)) + version: 1.2.0(elysia@1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3)) '@elysiajs/node': specifier: ^1.4.2 - version: 1.4.2(elysia@1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3)) + version: 1.4.2(elysia@1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3)) '@elysiajs/swagger': specifier: ^1.3.1 - version: 1.3.1(elysia@1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3)) + version: 1.3.1(elysia@1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3)) + '@excalidraw/excalidraw': + specifier: ^0.18.1 + version: 0.18.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@faker-js/faker': specifier: ^10.1.0 version: 10.1.0 + '@internationalized/date': + specifier: 3.7.0 + version: 3.7.0 '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 @@ -125,9 +131,12 @@ importers: electron-store: specifier: ^8.2.0 version: 8.2.0 + electron-updater: + specifier: ^6.8.9 + version: 6.8.9 elysia: - specifier: ^1.4.16 - version: 1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) + specifier: ~1.4.28 + version: 1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) fs-extra: specifier: ^11.3.0 version: 11.3.0 @@ -167,9 +176,6 @@ importers: mermaid: specifier: ^11.6.0 version: 11.6.0 - nanoid: - specifier: ^3.3.8 - version: 3.3.8 nearest-color: specifier: ^0.4.4 version: 0.4.4 @@ -179,6 +185,12 @@ importers: prettier: specifier: ^3.5.3 version: 3.5.3 + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) reka-ui: specifier: ^2.9.0 version: 2.9.0(vue@3.5.13(typescript@5.7.3)) @@ -203,6 +215,9 @@ importers: undici: specifier: ^6.21.2 version: 6.21.2 + unicode-emoji: + specifier: 2.8.0 + version: 2.8.0 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -261,12 +276,21 @@ importers: '@types/node': specifier: ^22.10.8 version: 22.13.1 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) '@types/sanitize-html': specifier: ^2.15.0 version: 2.15.0 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.1(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(sass@1.85.1)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) + '@vue/server-renderer': + specifier: 3.5.13 + version: 3.5.13(vue@3.5.13(typescript@5.7.3)) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.5.3) @@ -398,6 +422,9 @@ packages: '@antfu/install-pkg@1.0.0': resolution: {integrity: sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} @@ -436,6 +463,9 @@ packages: '@borewit/text-codec@0.1.1': resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + '@braintree/sanitize-url@6.0.2': + resolution: {integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -451,6 +481,9 @@ packages: '@chevrotain/types@11.0.3': resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} @@ -894,6 +927,25 @@ packages: resolution: {integrity: sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@excalidraw/excalidraw@0.18.1': + resolution: {integrity: sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==} + peerDependencies: + react: ^17.0.2 || ^18.2.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.2.0 || ^19.0.0 + + '@excalidraw/laser-pointer@1.3.1': + resolution: {integrity: sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==} + + '@excalidraw/markdown-to-text@0.1.2': + resolution: {integrity: sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + resolution: {integrity: sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==} + + '@excalidraw/random-username@1.1.0': + resolution: {integrity: sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==} + engines: {node: '>=10'} + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -901,9 +953,24 @@ packages: '@floating-ui/core@1.6.9': resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/dom@1.6.13': resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} @@ -962,6 +1029,9 @@ packages: '@iconify/utils@2.3.0': resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@interactjs/types@1.10.27': resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==} @@ -1046,6 +1116,12 @@ packages: '@mermaid-js/parser@0.4.0': resolution: {integrity: sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==} + '@mermaid-js/parser@0.6.3': + resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + + '@mermaid-js/parser@1.1.1': + resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1163,6 +1239,288 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@radix-ui/primitive@1.0.0': + resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} + + '@radix-ui/primitive@1.1.1': + resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + + '@radix-ui/react-arrow@1.1.2': + resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.0.1': + resolution: {integrity: sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-compose-refs@1.0.0': + resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.0.0': + resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.0.0': + resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-dismissable-layer@1.1.5': + resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.1': + resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.2': + resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.0.0': + resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popover@1.1.6': + resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.2': + resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.4': + resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.0.0': + resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-presence@1.1.2': + resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@1.0.1': + resolution: {integrity: sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.0.2': + resolution: {integrity: sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-slot@1.0.1': + resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.0.2': + resolution: {integrity: sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-callback-ref@1.0.0': + resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.0.0': + resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.0': + resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.0.0': + resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.0': + resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.0': + resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.0': + resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@rollup/rollup-android-arm-eabi@4.34.8': resolution: {integrity: sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==} cpu: [arm] @@ -1592,6 +1950,14 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -1669,6 +2035,9 @@ packages: '@unhead/schema@1.11.19': resolution: {integrity: sha512-7VhYHWK7xHgljdv+C01MepCSYZO2v6OhgsfKWPxRQBDDGfUKCUaChox0XMq3tFvXP6u4zSp6yzcDw2yxCfVMwg==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-vue@5.2.1': resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2029,6 +2398,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-fs-access@0.29.1: + resolution: {integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==} + browserslist@4.24.4: resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2047,6 +2419,10 @@ packages: resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==} engines: {node: '>=12.0.0'} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + builder-util@25.1.7: resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} @@ -2094,6 +2470,9 @@ packages: caniuse-lite@1.0.30001698: resolution: {integrity: sha512-xJ3km2oiG/MbNU8G6zIq6XRZ6HtAOVXsbOrP/blGazi52kc5Yy7b6sDA5O+FbROzRrV7BSTllLHuNvmawYUJjw==} + canvas-roundrect-polyfill@0.0.1: + resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2208,6 +2587,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@1.1.1: + resolution: {integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==} + engines: {node: '>=6'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2315,8 +2698,8 @@ packages: engines: {node: '>=16'} hasBin: true - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} core-js-compat@3.40.0: @@ -2351,6 +2734,10 @@ packages: typescript: optional: true + crc-32@0.3.0: + resolution: {integrity: sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==} + engines: {node: '>=0.8'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -2401,6 +2788,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -2415,6 +2805,10 @@ packages: resolution: {integrity: sha512-/eOXg2uGdMdpGlEes5Sf6zE+jUG+05f3htFNQIxLxduOH/SsaUZiPBfAwP1btVIVzsnhiNOdi+hvDRLYfMZjGw==} engines: {node: '>=0.10'} + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + d3-array@2.12.1: resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} @@ -2557,6 +2951,9 @@ packages: dagre-d3-es@7.0.11: resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==} + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + dargs@8.1.0: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} @@ -2567,6 +2964,9 @@ packages: dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -2662,6 +3062,9 @@ packages: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -2700,6 +3103,9 @@ packages: dompurify@3.2.5: resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==} + dompurify@3.4.9: + resolution: {integrity: sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -2748,6 +3154,9 @@ packages: electron-to-chromium@1.5.95: resolution: {integrity: sha512-XNsZaQrgQX+BG37BRQv+E+HcOZlWhqYaDoVVNCws/WrYYdbGrkR1qCDJ2mviBF3flCs6/BTa4O7ANfFTFZk6Dg==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + electron@34.1.1: resolution: {integrity: sha512-1aDYk9Gsv1/fFeClMrxWGoVMl7uCUgl1pe26BiTnLXmAoqEXCa3f3sCKFWV+cuDzUjQGAZcpkWhGYTgWUSQrLA==} engines: {node: '>= 12.20.55'} @@ -2758,8 +3167,8 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - elysia@1.4.16: - resolution: {integrity: sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA==} + elysia@1.4.28: + resolution: {integrity: sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==} peerDependencies: '@sinclair/typebox': '>= 0.34.0 < 1' '@types/bun': '>= 1.2.0' @@ -2835,9 +3244,16 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + es6-promise-pool@2.5.0: + resolution: {integrity: sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==} + engines: {node: '>=0.10.0'} + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} @@ -3203,6 +3619,10 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -3237,6 +3657,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + fuzzy@0.1.3: + resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} + engines: {node: '>= 0.6.0'} + gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3254,6 +3678,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3326,6 +3754,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3449,9 +3880,15 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + image-blob-reduce@3.0.1: + resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immutable@4.3.8: + resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} + immutable@5.0.3: resolution: {integrity: sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==} @@ -3466,6 +3903,9 @@ packages: import-meta-resolve@4.1.0: resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3608,6 +4048,24 @@ packages: resolution: {integrity: sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==} engines: {node: '>= 20'} + jotai-scope@0.7.2: + resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} + peerDependencies: + jotai: '>=2.9.2' + react: '>=17.0.0' + + jotai@2.11.0: + resolution: {integrity: sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3689,6 +4147,10 @@ packages: resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==} hasBin: true + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3843,15 +4305,25 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} lodash.difference@4.5.0: resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -3870,6 +4342,9 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash.union@4.6.0: resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} @@ -3956,6 +4431,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + markmap-common@0.18.9: resolution: {integrity: sha512-MV2HQO7IGIm3jWEJXSG8vmdpqf4WIDXcEyAEN52lrWR1qD53Zg5l81JwjXoZ2l0rY5mofKYqUFlmdM2fqTGMVg==} @@ -4037,6 +4517,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.15.0: + resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + mermaid@11.6.0: resolution: {integrity: sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==} @@ -4248,11 +4731,24 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multimath@2.0.0: + resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} + + nanoid@3.3.3: + resolution: {integrity: sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -4343,6 +4839,10 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4374,6 +4874,9 @@ packages: onigasm@2.2.5: resolution: {integrity: sha512-F+th54mPc0l1lp1ZcFMyL/jTs2Tlq4SqIHKIXGZOR/VkHkF9A7Fr5rRr5+ZG/lWeRsyrClLYRq7s/yFQ/XhWCA==} + open-color@1.9.1: + resolution: {integrity: sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -4435,9 +4938,15 @@ packages: package-manager-detector@0.2.9: resolution: {integrity: sha512-+vYvA/Y31l8Zk8dwxHhL3JfTuHPm6tlxM2A3GeQyl7ovYnSp1+mzAxClxaOr0qO1TtPxbQxetI7v5XqKLJZk7Q==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.0.3: + resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4522,6 +5031,12 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-freehand@1.2.0: + resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} + + pica@7.1.1: + resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4561,9 +5076,21 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + png-chunk-text@1.0.0: + resolution: {integrity: sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==} + + png-chunks-encode@1.0.0: + resolution: {integrity: sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==} + + png-chunks-extract@1.0.0: + resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + points-on-curve@1.0.1: + resolution: {integrity: sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==} + points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} @@ -4699,6 +5226,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pwacompat@2.0.17: + resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4713,6 +5243,45 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + read-binary-file-arch@1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true @@ -4840,6 +5409,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.4: + resolution: {integrity: sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -4874,6 +5446,11 @@ packages: sanitize-html@2.15.0: resolution: {integrity: sha512-wIjst57vJGpLyBP8ioUbg6ThwJie5SuSIjHxJg53v5Fg+kUK+AXlb7bK3RNXpp315MvwM+0OBGCV6h5pPHsVhA==} + sass@1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + sass@1.85.1: resolution: {integrity: sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==} engines: {node: '>=14.0.0'} @@ -4882,6 +5459,9 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -4908,6 +5488,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -4976,6 +5561,10 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} + sliced@1.0.1: + resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} + deprecated: Unsupported + slugify@1.6.6: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} @@ -5174,6 +5763,9 @@ packages: tiny-emitter@2.1.0: resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5249,6 +5841,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -5311,6 +5906,9 @@ packages: resolution: {integrity: sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==} engines: {node: '>=18.17'} + unicode-emoji@2.8.0: + resolution: {integrity: sha512-Mi4klQ1mrbUzNROyB/cu2MqaHIJTTrQwcYF0G1aW66snWaKHosn9lEHfyI8mw/SUSRr5yQ5dExqTD3+FFIh6tw==} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -5393,6 +5991,31 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -5581,6 +6204,9 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + webworkify@1.5.0: + resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -5677,6 +6303,21 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5737,6 +6378,11 @@ snapshots: package-manager-detector: 0.2.9 tinyexec: 0.3.2 + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@antfu/utils@0.7.10': {} '@antfu/utils@8.1.1': {} @@ -5768,6 +6414,8 @@ snapshots: '@borewit/text-codec@0.1.1': {} + '@braintree/sanitize-url@6.0.2': {} + '@braintree/sanitize-url@7.1.1': {} '@chevrotain/cst-dts-gen@11.0.3': @@ -5785,6 +6433,8 @@ snapshots: '@chevrotain/types@11.0.3': {} + '@chevrotain/types@11.1.2': {} + '@chevrotain/utils@11.0.3': {} '@clack/core@0.4.1': @@ -6192,7 +6842,7 @@ snapshots: make-fetch-happen: 10.2.1 nopt: 6.0.0 proc-log: 2.0.1 - semver: 7.7.1 + semver: 7.7.4 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -6270,21 +6920,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@elysiajs/cors@1.2.0(elysia@1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3))': + '@elysiajs/cors@1.2.0(elysia@1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3))': dependencies: - elysia: 1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) + elysia: 1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) - '@elysiajs/node@1.4.2(elysia@1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3))': + '@elysiajs/node@1.4.2(elysia@1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3))': dependencies: crossws: 0.4.1(srvx@0.9.7) - elysia: 1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) + elysia: 1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) srvx: 0.9.7 - '@elysiajs/swagger@1.3.1(elysia@1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3))': + '@elysiajs/swagger@1.3.1(elysia@1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3))': dependencies: '@scalar/themes': 0.9.68 '@scalar/types': 0.0.12 - elysia: 1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) + elysia: 1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3) openapi-types: 12.1.3 pathe: 1.1.2 @@ -6440,17 +7090,87 @@ snapshots: '@eslint/core': 0.10.0 levn: 0.4.1 - '@faker-js/faker@10.1.0': {} + '@excalidraw/excalidraw@0.18.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@braintree/sanitize-url': 6.0.2 + '@excalidraw/laser-pointer': 1.3.1 + '@excalidraw/mermaid-to-excalidraw': 2.2.2 + '@excalidraw/random-username': 1.1.0 + '@radix-ui/react-popover': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + browser-fs-access: 0.29.1 + canvas-roundrect-polyfill: 0.0.1 + clsx: 1.1.1 + cross-env: 7.0.3 + es6-promise-pool: 2.5.0 + fractional-indexing: 3.2.0 + fuzzy: 0.1.3 + image-blob-reduce: 3.0.1 + jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7) + jotai-scope: 0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + nanoid: 3.3.3 + open-color: 1.9.1 + pako: 2.0.3 + perfect-freehand: 1.2.0 + pica: 7.1.1 + png-chunk-text: 1.0.0 + png-chunks-encode: 1.0.0 + png-chunks-extract: 1.0.0 + points-on-curve: 1.0.1 + pwacompat: 2.0.17 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + roughjs: 4.6.4 + sass: 1.51.0 + tunnel-rat: 0.1.2(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - immer - '@floating-ui/core@1.6.9': + '@excalidraw/laser-pointer@1.3.1': {} + + '@excalidraw/markdown-to-text@0.1.2': {} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + dependencies: + '@excalidraw/markdown-to-text': 0.1.2 + '@mermaid-js/parser': 0.6.3 + mermaid: 11.15.0 + nanoid: 4.0.2 + + '@excalidraw/random-username@1.1.0': {} + + '@faker-js/faker@10.1.0': {} + + '@floating-ui/core@1.6.9': dependencies: '@floating-ui/utils': 0.2.9 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + '@floating-ui/dom@1.6.13': dependencies: '@floating-ui/core': 1.6.9 '@floating-ui/utils': 0.2.9 + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.9': {} '@floating-ui/vue@1.1.6(vue@3.5.13(typescript@5.7.3))': @@ -6512,6 +7232,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@interactjs/types@1.10.27': {} '@internationalized/date@3.7.0': @@ -6647,6 +7373,14 @@ snapshots: dependencies: langium: 3.3.1 + '@mermaid-js/parser@0.6.3': + dependencies: + langium: 3.3.1 + + '@mermaid-js/parser@1.1.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6735,6 +7469,286 @@ snapshots: '@pkgr/core@0.1.1': {} + '@radix-ui/primitive@1.0.0': + dependencies: + '@babel/runtime': 7.28.4 + + '@radix-ui/primitive@1.1.1': {} + + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.0.1(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-compose-refs@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.7 + + '@radix-ui/react-compose-refs@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.7 + + '@radix-ui/react-context@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-direction@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.7 + + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-id@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-popover@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.4 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-presence@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-slot': 1.0.1(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-primitive@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-collection': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-direction': 1.0.0(react@19.2.7) + '@radix-ui/react-id': 1.0.0(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-slot@1.0.1(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-slot@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tabs@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-direction': 1.0.0(react@19.2.7) + '@radix-ui/react-id': 1.0.0(react@19.2.7) + '@radix-ui/react-presence': 1.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-use-callback-ref@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.7 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.7 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.0 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/rect@1.1.0': {} + '@rollup/rollup-android-arm-eabi@4.34.8': optional: true @@ -7135,6 +8149,14 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/responselike@1.0.3': dependencies: '@types/node': 22.13.1 @@ -7246,6 +8268,11 @@ snapshots: hookable: 5.5.3 zhead: 2.2.4 + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-vue@5.2.1(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(sass@1.85.1)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3))': dependencies: vite: 6.1.1(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(sass@1.85.1)(yaml@2.7.0) @@ -7708,6 +8735,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-fs-access@0.29.1: {} + browserslist@4.24.4: dependencies: caniuse-lite: 1.0.30001698 @@ -7731,6 +8760,13 @@ snapshots: transitivePeerDependencies: - supports-color + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3 + sax: 1.4.1 + transitivePeerDependencies: + - supports-color + builder-util@25.1.7: dependencies: 7zip-bin: 5.2.0 @@ -7831,6 +8867,8 @@ snapshots: caniuse-lite@1.0.30001698: {} + canvas-roundrect-polyfill@0.0.1: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -7963,6 +9001,8 @@ snapshots: clone@1.0.4: {} + clsx@1.1.1: {} + clsx@2.1.1: {} codemirror-textmate@1.1.0(codemirror@5.65.18)(onigasm@2.2.5): @@ -8068,7 +9108,7 @@ snapshots: meow: 12.1.1 split2: 4.2.0 - cookie@1.0.2: {} + cookie@1.1.1: {} core-js-compat@3.40.0: dependencies: @@ -8103,6 +9143,8 @@ snapshots: optionalDependencies: typescript: 5.7.3 + crc-32@0.3.0: {} + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -8147,18 +9189,32 @@ snapshots: csstype@3.1.3: {} + csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.31.2): dependencies: cose-base: 1.0.3 cytoscape: 3.31.2 + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + cytoscape-fcose@2.2.0(cytoscape@3.31.2): dependencies: cose-base: 2.2.0 cytoscape: 3.31.2 + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + cytoscape@3.31.2: {} + cytoscape@3.34.0: {} + d3-array@2.12.1: dependencies: internmap: 1.0.1 @@ -8331,12 +9387,19 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.21 + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.21 + dargs@8.1.0: {} date-fns@4.1.0: {} dayjs@1.11.13: {} + dayjs@1.11.21: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -8409,6 +9472,8 @@ snapshots: detect-libc@2.0.3: {} + detect-node-es@1.1.0: {} + detect-node@2.1.0: optional: true @@ -8470,6 +9535,10 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.4.9: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -8549,6 +9618,19 @@ snapshots: electron-to-chromium@1.5.95: {} + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.1.0 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron@34.1.1: dependencies: '@electron/get': 2.0.3 @@ -8564,10 +9646,10 @@ snapshots: runtime-required: 1.1.0 watchboy: 0.4.3 - elysia@1.4.16(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3): + elysia@1.4.28(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.7.3): dependencies: '@sinclair/typebox': 0.34.41 - cookie: 1.0.2 + cookie: 1.1.1 exact-mirror: 0.2.5(@sinclair/typebox@0.34.41) fast-decode-uri-component: 1.0.1 file-type: 21.1.1 @@ -8632,9 +9714,13 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.47.0: {} + es6-error@4.1.1: optional: true + es6-promise-pool@2.5.0: {} + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -9114,6 +10200,8 @@ snapshots: fraction.js@5.3.4: {} + fractional-indexing@3.2.0: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -9152,6 +10240,8 @@ snapshots: function-bind@1.1.2: {} + fuzzy@0.1.3: {} + gauge@4.0.4: dependencies: aproba: 2.0.0 @@ -9180,6 +10270,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -9276,6 +10368,8 @@ snapshots: gopd: 1.2.0 optional: true + glur@1.1.2: {} + gopd@1.2.0: {} got@11.8.6: @@ -9409,8 +10503,14 @@ snapshots: ignore@5.3.2: {} + image-blob-reduce@3.0.1: + dependencies: + pica: 7.1.1 + immediate@3.0.6: {} + immutable@4.3.8: {} + immutable@5.0.3: {} import-fresh@3.3.1: @@ -9424,6 +10524,8 @@ snapshots: import-meta-resolve@4.1.0: {} + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -9539,6 +10641,16 @@ snapshots: '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 + jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + + jotai@2.11.0(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -9608,6 +10720,10 @@ snapshots: dependencies: commander: 8.3.0 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -9750,12 +10866,18 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} + lodash.isequal@4.5.0: {} + lodash.isplainobject@4.0.6: {} lodash.kebabcase@4.1.1: {} @@ -9768,6 +10890,8 @@ snapshots: lodash.startcase@4.4.0: {} + lodash.throttle@4.1.1: {} + lodash.union@4.6.0: {} lodash.uniq@4.5.0: {} @@ -9867,6 +10991,8 @@ snapshots: marked@15.0.8: {} + marked@16.4.2: {} + markmap-common@0.18.9: dependencies: '@babel/runtime': 7.28.4 @@ -10033,6 +11159,30 @@ snapshots: merge2@1.4.1: {} + mermaid@11.15.0: + dependencies: + '@braintree/sanitize-url': 7.1.1 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.1.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.9 + es-toolkit: 1.47.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + mermaid@11.6.0: dependencies: '@braintree/sanitize-url': 7.1.1 @@ -10353,8 +11503,17 @@ snapshots: muggle-string@0.4.1: {} + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + + nanoid@3.3.3: {} + nanoid@3.3.8: {} + nanoid@4.0.2: {} + napi-build-utils@2.0.0: {} natural-compare@1.4.0: {} @@ -10460,6 +11619,8 @@ snapshots: tinyexec: 0.3.2 ufo: 1.5.4 + object-assign@4.1.1: {} + object-keys@1.1.1: optional: true @@ -10489,6 +11650,8 @@ snapshots: dependencies: lru-cache: 5.1.1 + open-color@1.9.1: {} + openapi-types@12.1.3: {} optionator@0.9.4: @@ -10554,8 +11717,12 @@ snapshots: package-manager-detector@0.2.9: {} + package-manager-detector@1.6.0: {} + pako@1.0.11: {} + pako@2.0.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10624,6 +11791,16 @@ snapshots: perfect-debounce@1.0.0: {} + perfect-freehand@1.2.0: {} + + pica@7.1.1: + dependencies: + glur: 1.1.2 + inherits: 2.0.4 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -10654,8 +11831,21 @@ snapshots: pluralize@8.0.0: {} + png-chunk-text@1.0.0: {} + + png-chunks-encode@1.0.0: + dependencies: + crc-32: 0.3.0 + sliced: 1.0.1 + + png-chunks-extract@1.0.0: + dependencies: + crc-32: 0.3.0 + points-on-curve@0.2.0: {} + points-on-curve@1.0.1: {} + points-on-path@0.2.1: dependencies: path-data-parser: 0.1.0 @@ -10732,6 +11922,8 @@ snapshots: punycode@2.3.1: {} + pwacompat@2.0.17: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -10748,9 +11940,43 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react@19.2.7: {} + read-binary-file-arch@1.0.6: dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -10911,6 +12137,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.8 fsevents: 2.3.3 + roughjs@4.6.4: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -10953,6 +12186,12 @@ snapshots: parse-srcset: 1.0.2 postcss: 8.5.3 + sass@1.51.0: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.8 + source-map-js: 1.2.1 + sass@1.85.1: dependencies: chokidar: 4.0.3 @@ -10963,6 +12202,8 @@ snapshots: sax@1.4.1: {} + scheduler@0.27.0: {} + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.1 @@ -10982,6 +12223,8 @@ snapshots: semver@7.7.1: {} + semver@7.7.4: {} + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -11042,6 +12285,8 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 + sliced@1.0.1: {} + slugify@1.6.6: {} smart-buffer@4.2.0: {} @@ -11237,6 +12482,8 @@ snapshots: tiny-emitter@2.1.0: {} + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -11302,6 +12549,14 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tunnel-rat@0.1.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + - react + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -11339,6 +12594,8 @@ snapshots: undici@6.21.2: {} + unicode-emoji@2.8.0: {} + unicorn-magic@0.1.0: {} unimport@4.1.2: @@ -11440,6 +12697,25 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {} @@ -11610,6 +12886,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webworkify@1.5.0: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -11700,4 +12978,11 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zustand@4.5.7(@types/react@19.2.17)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + zwitch@2.0.4: {} diff --git a/scripts/api-generate.js b/scripts/api-generate.js index e5c987b00..ad704d83c 100644 --- a/scripts/api-generate.js +++ b/scripts/api-generate.js @@ -15,7 +15,7 @@ const appDataDir = path.join( 'v2', ) const store = new Store({ name: 'preferences', cwd: appDataDir }) -const apiPort = store.get('apiPort', 4321) +const apiPort = store.get('api.port', store.get('apiPort', 4321)) const url = `http://localhost:${apiPort}/swagger/json` async function generateApi() { diff --git a/scripts/license/issue.js b/scripts/license/issue.js new file mode 100644 index 000000000..a9248a8d2 --- /dev/null +++ b/scripts/license/issue.js @@ -0,0 +1,56 @@ +/** + * Выпуск supporter-лицензии, подписанной приватным ключом Ed25519. + * + * Запуск: pnpm license:issue --email john@example.com [--name "John Doe"] + */ +const { Buffer } = require('node:buffer') +const { createPrivateKey, sign } = require('node:crypto') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const process = require('node:process') + +const privateKeyPath + = process.env.MASSCODE_LICENSE_PRIVATE_KEY + || path.join(os.homedir(), '.masscode', 'license-private.pem') + +function getArg(name) { + const index = process.argv.indexOf(`--${name}`) + if (index === -1 || index === process.argv.length - 1) { + return undefined + } + return process.argv[index + 1] +} + +const name = getArg('name') +const email = getArg('email') + +if (!email) { + console.error( + 'Usage: pnpm license:issue --email john@example.com [--name "John Doe"]', + ) + process.exit(1) +} + +if (!fs.existsSync(privateKeyPath)) { + console.error(`Private key not found: ${privateKeyPath}`) + console.error('Run "node scripts/license/keygen.js" first.') + process.exit(1) +} + +const privateKey = createPrivateKey(fs.readFileSync(privateKeyPath)) + +const payload = { + email, + ...(name ? { name } : {}), + issuedAt: new Date().toISOString().slice(0, 10), +} + +const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString( + 'base64url', +) +const signature = sign(null, Buffer.from(payloadBase64), privateKey).toString( + 'base64url', +) + +console.log(`${payloadBase64}.${signature}`) diff --git a/scripts/license/keygen.js b/scripts/license/keygen.js new file mode 100644 index 000000000..75b4fd990 --- /dev/null +++ b/scripts/license/keygen.js @@ -0,0 +1,43 @@ +/** + * Одноразовая генерация пары ключей Ed25519 для supporter-лицензий. + * + * Приватный ключ сохраняется в ~/.masscode/license-private.pem и никогда + * не должен попадать в репозиторий. Публичный ключ выводится в stdout — + * его нужно вшить в src/main/license/index.ts (LICENSE_PUBLIC_KEY). + * + * Запуск: node scripts/license/keygen.js + */ +const { generateKeyPairSync } = require('node:crypto') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const process = require('node:process') + +const privateKeyDir = path.join(os.homedir(), '.masscode') +const privateKeyPath = path.join(privateKeyDir, 'license-private.pem') + +if (fs.existsSync(privateKeyPath)) { + console.error(`Private key already exists: ${privateKeyPath}`) + console.error( + 'Remove it manually if you really want to generate a new pair.', + ) + process.exit(1) +} + +const { publicKey, privateKey } = generateKeyPairSync('ed25519') + +fs.mkdirSync(privateKeyDir, { recursive: true }) +fs.writeFileSync( + privateKeyPath, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, +) + +const publicKeyBase64 = publicKey + .export({ type: 'spki', format: 'der' }) + .toString('base64') + +console.log(`Private key saved to: ${privateKeyPath}`) +console.log('') +console.log('Public key (embed into src/main/license/index.ts):') +console.log(publicKeyBase64) diff --git a/src/main/__tests__/folderIcons.test.ts b/src/main/__tests__/folderIcons.test.ts new file mode 100644 index 000000000..28db8d857 --- /dev/null +++ b/src/main/__tests__/folderIcons.test.ts @@ -0,0 +1,447 @@ +import { Buffer } from 'node:buffer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createFolderIconPng, + parseFolderIconSetPayload, + parseFolderIconTarget, + parseFolderIconWritePayload, + resolveFolderIconPath, + resolveFolderIconResponse, + setFolderIcon, + writeFolderIcon, +} from '../folderIcons' + +const { + createFromBuffer, + folderIconValues, + fsMock, + getFileAvailability, + prioritizeCloudDownload, + rememberAppFileChange, + updateCodeFolder, + updateHttpFolder, + updateNotesFolder, +} = vi.hoisted(() => ({ + createFromBuffer: vi.fn(), + folderIconValues: { code: null as string | null }, + fsMock: { + lstat: vi.fn(), + readFile: vi.fn(), + realpathSync: vi.fn((value: string) => value), + remove: vi.fn(), + rename: vi.fn(), + writeFile: vi.fn(), + }, + getFileAvailability: vi.fn(), + prioritizeCloudDownload: vi.fn(), + rememberAppFileChange: vi.fn(), + updateCodeFolder: vi.fn(), + updateHttpFolder: vi.fn(), + updateNotesFolder: vi.fn(), +})) + +vi.mock('electron', () => ({ + nativeImage: { createFromBuffer }, +})) + +vi.mock('fs-extra', () => ({ default: fsMock })) + +vi.mock('../storage/providers/markdown/cloudDownloads', () => ({ + prioritizeCloudDownload, +})) + +vi.mock('../storage/providers/markdown/runtime/shared/appChanges', () => ({ + rememberAppFileChange, +})) + +vi.mock('../storage/providers/markdown/runtime/shared/cloudFiles', () => ({ + getFileAvailability, +})) + +vi.mock('../storage', () => ({ + useHttpStorage: () => ({ + folders: { + getFolders: () => [ + { icon: null, id: 3, name: 'API', parentId: null, orderIndex: 0 }, + ], + updateFolder: updateHttpFolder, + }, + }), + useNotesStorage: () => ({ + folders: { + getFolders: () => [ + { icon: null, id: 2, name: 'Docs', parentId: null, orderIndex: 0 }, + ], + updateFolder: updateNotesFolder, + }, + }), + useStorage: () => ({ + folders: { + getFolders: () => [ + { + icon: folderIconValues.code, + id: 1, + name: 'Code', + parentId: null, + orderIndex: 0, + }, + ], + updateFolder: updateCodeFolder, + }, + }), +})) + +vi.mock('../storage/providers/markdown/runtime', () => ({ + getPaths: () => ({ vaultPath: '/vault/code' }), + getVaultPath: () => '/vault', +})) + +vi.mock('../storage/providers/markdown/notes', () => ({ + getNotesPaths: () => ({ notesRoot: '/vault/notes' }), +})) + +vi.mock('../storage/providers/markdown/http', () => ({ + getHttpPaths: () => ({ httpRoot: '/vault/http' }), +})) + +beforeEach(() => { + vi.clearAllMocks() + folderIconValues.code = null + fsMock.realpathSync.mockImplementation((value: string) => value) + getFileAvailability.mockReturnValue({ + exists: false, + isCloudPlaceholder: false, + stats: null, + }) + updateCodeFolder.mockImplementation((_folderId, input) => { + folderIconValues.code = input.icon + return { invalidInput: false, notFound: false } + }) + updateHttpFolder.mockReturnValue({ invalidInput: false, notFound: false }) + updateNotesFolder.mockReturnValue({ invalidInput: false, notFound: false }) +}) + +describe('folder icon payload validation', () => { + it('accepts valid targets and rejects unknown spaces and ids', () => { + expect(parseFolderIconTarget({ folderId: 2, spaceId: 'notes' })).toEqual({ + folderId: 2, + spaceId: 'notes', + }) + expect(parseFolderIconTarget({ folderId: 0, spaceId: 'notes' })).toBeNull() + expect(parseFolderIconTarget({ folderId: 2, spaceId: 'math' })).toBeNull() + }) + + it('accepts builtin/null updates and rejects renderer-supplied custom values', () => { + expect( + parseFolderIconSetPayload({ + folderId: 1, + icon: 'emoji:👩🏽‍🚀', + spaceId: 'code', + }), + ).toEqual({ folderId: 1, icon: 'emoji:👩🏽‍🚀', spaceId: 'code' }) + expect( + parseFolderIconSetPayload({ + folderId: 1, + icon: 'emoji:🇩🇪', + spaceId: 'code', + }), + ).toEqual({ folderId: 1, icon: 'emoji:🇩🇪', spaceId: 'code' }) + expect( + parseFolderIconSetPayload({ + folderId: 1, + icon: 'emoji:not-an-emoji', + spaceId: 'code', + }), + ).toBeNull() + expect( + parseFolderIconSetPayload({ + folderId: 1, + icon: 'emoji:😀😀', + spaceId: 'code', + }), + ).toBeNull() + expect( + parseFolderIconSetPayload({ + folderId: 1, + icon: 'lucide:folder-open', + spaceId: 'code', + }), + ).toEqual({ folderId: 1, icon: 'lucide:folder-open', spaceId: 'code' }) + expect( + parseFolderIconSetPayload({ + folderId: 1, + icon: null, + spaceId: 'code', + }), + ).toEqual({ folderId: 1, icon: null, spaceId: 'code' }) + expect( + parseFolderIconSetPayload({ + folderId: 1, + icon: 'custom:forged', + spaceId: 'code', + }), + ).toBeNull() + }) + + it('accepts PNG/JPEG signatures and rejects arbitrary bytes', () => { + const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer + const jpeg = new Uint8Array([255, 216, 255, 1]).buffer + + expect( + parseFolderIconWritePayload({ + buffer: png, + folderId: 1, + spaceId: 'code', + }), + ).not.toBeNull() + expect( + parseFolderIconWritePayload({ + buffer: jpeg, + folderId: 1, + spaceId: 'code', + }), + ).not.toBeNull() + expect( + parseFolderIconWritePayload({ + buffer: new Uint8Array([1, 2, 3]).buffer, + folderId: 1, + spaceId: 'code', + }), + ).toBeNull() + }) +}) + +describe('folder icon processing', () => { + it('center-crops and resizes to 128x128 PNG', () => { + const toPNG = vi.fn(() => Buffer.from('png')) + const resize = vi.fn(() => ({ toPNG })) + const crop = vi.fn(() => ({ resize })) + const decode = vi.fn(() => ({ + crop, + getSize: () => ({ height: 200, width: 300 }), + isEmpty: () => false, + })) + + expect(createFolderIconPng(Buffer.from('input'), decode as never)).toEqual( + Buffer.from('png'), + ) + expect(crop).toHaveBeenCalledWith({ height: 200, width: 200, x: 50, y: 0 }) + expect(resize).toHaveBeenCalledWith({ + height: 128, + quality: 'best', + width: 128, + }) + }) + + it('rejects an image that cannot be decoded', () => { + const decode = vi.fn(() => ({ isEmpty: () => true })) + + expect(() => + createFolderIconPng(Buffer.from('input'), decode as never), + ).toThrow('Image could not be decoded') + }) +}) + +describe('folder icon path resolver', () => { + it('resolves the icon adjacent to the current folder path', () => { + expect(resolveFolderIconPath('code', 1)).toBe('/vault/code/Code/.icon.png') + expect(resolveFolderIconPath('notes', 2)).toBe( + '/vault/notes/Docs/.icon.png', + ) + expect(resolveFolderIconPath('http', 3)).toBe('/vault/http/API/.icon.png') + expect(resolveFolderIconPath('code', 999)).toBeNull() + }) + + it('rejects a folder whose real path escapes the space root', () => { + fsMock.realpathSync + .mockReturnValueOnce('/real/vault/code') + .mockReturnValueOnce('/outside/Code') + + expect(resolveFolderIconPath('code', 1)).toBeNull() + }) +}) + +describe('folder icon persistence', () => { + function mockPngEncoding(png = Buffer.from('optimized-png')) { + createFromBuffer.mockReturnValue({ + crop: () => ({ + resize: () => ({ toPNG: () => png }), + }), + getSize: () => ({ height: 256, width: 256 }), + isEmpty: () => false, + }) + return png + } + + it('writes the optimized file and updates folder metadata', async () => { + fsMock.writeFile.mockResolvedValue(undefined) + fsMock.rename.mockResolvedValue(undefined) + fsMock.remove.mockResolvedValue(undefined) + updateCodeFolder.mockReturnValue({ invalidInput: false, notFound: false }) + const png = mockPngEncoding() + + const value = await writeFolderIcon({ + buffer: new Uint8Array([137, 80, 78, 71]).buffer, + folderId: 1, + spaceId: 'code', + }) + + expect(value).toMatch(/^custom:[a-f0-9]{16}$/) + expect(fsMock.writeFile).toHaveBeenCalledWith( + expect.stringContaining('/vault/code/Code/.icon.png.'), + png, + { flag: 'wx' }, + ) + expect(updateCodeFolder).toHaveBeenCalledWith(1, { icon: value }) + }) + + it('restores the previous PNG when metadata update fails', async () => { + const previousPng = Buffer.from('previous-png') + getFileAvailability.mockReturnValue({ + exists: true, + isCloudPlaceholder: false, + stats: { isFile: () => true }, + }) + fsMock.lstat.mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + }) + fsMock.readFile.mockResolvedValueOnce(previousPng) + fsMock.writeFile.mockResolvedValue(undefined) + fsMock.rename.mockResolvedValue(undefined) + fsMock.remove.mockResolvedValue(undefined) + updateCodeFolder.mockImplementationOnce(() => { + throw new Error('metadata failed') + }) + mockPngEncoding() + + await expect( + writeFolderIcon({ + buffer: new Uint8Array([137, 80, 78, 71]).buffer, + folderId: 1, + spaceId: 'code', + }), + ).rejects.toThrow('metadata failed') + + expect(fsMock.writeFile).toHaveBeenCalledWith( + '/vault/code/Code/.icon.png', + previousPng, + ) + expect(updateCodeFolder).toHaveBeenLastCalledWith(1, { icon: null }) + }) + + it('does not read or overwrite a cloud placeholder', async () => { + getFileAvailability.mockReturnValue({ + exists: true, + isCloudPlaceholder: true, + stats: { isFile: () => true }, + }) + mockPngEncoding() + + await expect( + writeFolderIcon({ + buffer: new Uint8Array([137, 80, 78, 71]).buffer, + folderId: 1, + spaceId: 'code', + }), + ).rejects.toThrow('temporarily unavailable') + + expect(prioritizeCloudDownload).toHaveBeenCalledWith( + '/vault/code/Code/.icon.png', + ) + expect(fsMock.readFile).not.toHaveBeenCalled() + expect(fsMock.writeFile).not.toHaveBeenCalled() + }) + + it('updates metadata and removes the adjacent file as one operation', async () => { + folderIconValues.code = 'custom:old' + fsMock.remove.mockResolvedValue(undefined) + + await setFolderIcon({ folderId: 1, icon: null, spaceId: 'code' }) + + expect(updateCodeFolder).toHaveBeenCalledWith(1, { icon: null }) + expect(fsMock.remove).toHaveBeenCalledWith('/vault/code/Code/.icon.png') + }) + + it('restores metadata when adjacent file cleanup fails', async () => { + folderIconValues.code = 'custom:old' + fsMock.remove.mockRejectedValueOnce(new Error('cleanup failed')) + + await expect( + setFolderIcon({ + folderId: 1, + icon: null, + spaceId: 'code', + }), + ).rejects.toThrow('cleanup failed') + + expect(updateCodeFolder).toHaveBeenNthCalledWith(1, 1, { icon: null }) + expect(updateCodeFolder).toHaveBeenLastCalledWith(1, { + icon: 'custom:old', + }) + }) + + it('serializes cleanup before a subsequent upload for the same folder', async () => { + folderIconValues.code = 'custom:old' + let finishCleanup!: () => void + const cleanupPromise = new Promise((resolve) => { + finishCleanup = resolve + }) + fsMock.remove.mockReturnValueOnce(cleanupPromise) + fsMock.writeFile.mockResolvedValue(undefined) + fsMock.rename.mockResolvedValue(undefined) + const setPromise = setFolderIcon({ + folderId: 1, + icon: 'lucide:folder-open', + spaceId: 'code', + }) + const png = mockPngEncoding() + const writePromise = writeFolderIcon({ + buffer: new Uint8Array([137, 80, 78, 71]).buffer, + folderId: 1, + spaceId: 'code', + }) + + await Promise.resolve() + expect(fsMock.writeFile).not.toHaveBeenCalled() + + finishCleanup() + await setPromise + await writePromise + + expect(fsMock.writeFile).toHaveBeenCalledWith( + expect.stringContaining('/vault/code/Code/.icon.png.'), + png, + { flag: 'wx' }, + ) + }) +}) + +describe('folder icon protocol', () => { + it('queues cloud placeholders instead of reading them', async () => { + fsMock.lstat.mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + }) + getFileAvailability.mockReturnValue({ + exists: true, + isCloudPlaceholder: true, + stats: { isFile: () => true }, + }) + + const response = await resolveFolderIconResponse('notes', '2') + + expect(response.status).toBe(503) + expect(prioritizeCloudDownload).toHaveBeenCalledWith( + '/vault/notes/Docs/.icon.png', + ) + expect(fsMock.readFile).not.toHaveBeenCalled() + }) + + it('rejects malformed ids before filesystem access', async () => { + const response = await resolveFolderIconResponse('code', '1/../2') + + expect(response.status).toBe(404) + expect(fsMock.lstat).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/api/dto/captures.ts b/src/main/api/dto/captures.ts new file mode 100644 index 000000000..fb9b944be --- /dev/null +++ b/src/main/api/dto/captures.ts @@ -0,0 +1,51 @@ +import Elysia, { t } from 'elysia' + +const captureTarget = t.Union([ + t.Literal('code'), + t.Literal('notes'), + t.Literal('http'), +]) + +const captureHttpMethod = t.Union([ + t.Literal('GET'), + t.Literal('POST'), + t.Literal('PUT'), + t.Literal('PATCH'), + t.Literal('DELETE'), +]) + +const captureSource = t.Object({ + title: t.Optional(t.String()), + url: t.Optional(t.String()), + capturedAt: t.Optional(t.Number()), +}) + +const captureRequest = t.Object({ + target: captureTarget, + name: t.Optional(t.String()), + folderId: t.Optional(t.Union([t.Number(), t.Null()])), + text: t.Optional(t.String()), + markdown: t.Optional(t.String()), + url: t.Optional(t.String()), + pageTitle: t.Optional(t.String()), + suggestedName: t.Optional(t.String()), + sourceTitle: t.Optional(t.String()), + sourceUrl: t.Optional(t.String()), + contextLabel: t.Optional(t.String()), + language: t.Optional(t.String()), + method: t.Optional(captureHttpMethod), + source: t.Optional(captureSource), +}) + +const captureResponse = t.Object({ + target: captureTarget, + id: t.Number(), +}) + +export const capturesDTO = new Elysia().model({ + captureRequest, + captureResponse, +}) + +export type CaptureRequest = typeof captureRequest.static +export type CaptureResponse = typeof captureResponse.static diff --git a/src/main/api/dto/http-requests.ts b/src/main/api/dto/http-requests.ts index 538dbf55e..4bbde74cb 100644 --- a/src/main/api/dto/http-requests.ts +++ b/src/main/api/dto/http-requests.ts @@ -1,4 +1,5 @@ import Elysia, { t } from 'elysia' +import { commonQuery } from './common/query' const httpMethod = t.Union([ t.Literal('GET'), @@ -90,14 +91,39 @@ const httpRequestItem = t.Object({ filePath: t.String(), isFavorites: t.Number(), isDeleted: t.Number(), + pendingCloudDownload: t.Optional(t.Boolean()), createdAt: t.Number(), updatedAt: t.Number(), }) -const httpRequestsResponse = t.Array(httpRequestItem) +// Список не сериализует body: полная запись выбранного запроса загружается +// через GET /http-requests/:id, а тела остаются ленивыми на диске до +// первого обращения. description лежит в metadata-индексе и отдаётся +// списком (совместимость с клиентами v5.8). +const httpRequestListItem = t.Object({ + id: t.Number(), + name: t.String(), + folderId: t.Union([t.Number(), t.Null()]), + method: httpMethod, + url: t.String(), + headers: t.Array(httpHeaderEntry), + query: t.Array(httpQueryEntry), + bodyType: httpBodyType, + formData: t.Array(httpFormDataEntry), + auth: httpAuth, + description: t.String(), + filePath: t.String(), + isFavorites: t.Number(), + isDeleted: t.Number(), + pendingCloudDownload: t.Optional(t.Boolean()), + createdAt: t.Number(), + updatedAt: t.Number(), +}) + +const httpRequestsResponse = t.Array(httpRequestListItem) const httpRequestsQuery = t.Object({ - search: t.Optional(t.String()), + ...commonQuery.properties, searchNameOnly: t.Optional(t.Number({ minimum: 0, maximum: 1 })), folderId: t.Optional(t.Number()), isFavorites: t.Optional(t.Number({ minimum: 0, maximum: 1 })), diff --git a/src/main/api/dto/internal-links.ts b/src/main/api/dto/internal-links.ts new file mode 100644 index 000000000..5ab4d8de5 --- /dev/null +++ b/src/main/api/dto/internal-links.ts @@ -0,0 +1,53 @@ +import Elysia, { t } from 'elysia' + +const internalLinksResolveBody = t.Object({ + titles: t.Array(t.String(), { maxItems: 500 }), +}) + +const resolvedInternalLinkEntity = t.Object({ + type: t.Union([ + t.Literal('snippet'), + t.Literal('note'), + t.Literal('http-request'), + ]), + id: t.Number(), + name: t.String(), + folder: t.Nullable( + t.Object({ + id: t.Number(), + name: t.String(), + }), + ), + isDeleted: t.Number(), + firstContent: t.Optional( + t.Nullable( + t.Object({ + language: t.String(), + value: t.Union([t.String(), t.Null()]), + }), + ), + ), + contentExcerpt: t.Optional(t.String()), + request: t.Optional( + t.Object({ + method: t.String(), + url: t.String(), + description: t.String(), + }), + ), +}) + +const internalLinksResolveResponse = t.Array( + t.Object({ + title: t.String(), + resolved: t.Nullable(resolvedInternalLinkEntity), + }), +) + +export const internalLinksDTO = new Elysia().model({ + internalLinksResolveBody, + internalLinksResolveResponse, +}) + +export type InternalLinksResolveResponse = + typeof internalLinksResolveResponse.static diff --git a/src/main/api/dto/notes.ts b/src/main/api/dto/notes.ts index b77cfc2d0..444accdf1 100644 --- a/src/main/api/dto/notes.ts +++ b/src/main/api/dto/notes.ts @@ -4,6 +4,7 @@ import { commonQuery } from './common/query' const notesAdd = t.Object({ name: t.String(), folderId: t.Optional(t.Union([t.Number(), t.Null()])), + properties: t.Optional(t.Record(t.String(), t.Any())), }) const notesUpdate = t.Object({ @@ -18,11 +19,18 @@ const notesContentUpdate = t.Object({ content: t.String(), }) -const noteItem = t.Object({ +const noteProperties = t.Record(t.String(), t.Any()) + +const notePropertiesUpdate = t.Object({ + properties: t.Optional(noteProperties), + unset: t.Optional(t.Array(t.String())), +}) + +const noteItemBase = { id: t.Number(), name: t.String(), description: t.Union([t.String(), t.Null()]), - content: t.String(), + properties: noteProperties, tags: t.Array( t.Object({ id: t.Number(), @@ -39,20 +47,37 @@ const noteItem = t.Object({ isDeleted: t.Number(), createdAt: t.Number(), updatedAt: t.Number(), + // Файл заметки — облачный плейсхолдер, содержимое докачивается в фоне. + pendingCloudDownload: t.Optional(t.Boolean()), +} + +const noteItem = t.Object({ + ...noteItemBase, + content: t.String(), }) -const notesResponse = t.Array(noteItem) +// Список не содержит контента заметок: контент выбранной заметки +// загружается отдельным GET /notes/:id. +const noteListItem = t.Object(noteItemBase) + +const notesResponse = t.Array(noteListItem) const notesCountsResponse = t.Object({ total: t.Number(), trash: t.Number(), }) +const notesTasksCleanupResponse = t.Object({ + count: t.Number(), +}) + export const notesDTO = new Elysia().model({ notesAdd, notesContentUpdate, notesCountsResponse, + notesTasksCleanupResponse, noteItemResponse: noteItem, + noteProperties, notesResponse, notesQuery: t.Object({ ...commonQuery.properties, @@ -62,11 +87,20 @@ export const notesDTO = new Elysia().model({ isFavorites: t.Optional(t.Number({ minimum: 0, maximum: 1 })), isDeleted: t.Optional(t.Number({ minimum: 0, maximum: 1 })), isInbox: t.Optional(t.Number({ minimum: 0, maximum: 1 })), + propertyDue: t.Optional( + t.Union([t.Literal('today'), t.Literal('upcoming')]), + ), + propertyStatus: t.Optional(t.String()), + propertyStatusNot: t.Optional(t.String()), + propertyType: t.Optional(t.String()), + hideCompletedTasks: t.Optional(t.Number({ minimum: 0, maximum: 1 })), }), + notePropertiesUpdate, notesUpdate, }) export type NotesAdd = typeof notesAdd.static export type NotesResponse = typeof notesResponse.static +export type NoteListItemResponse = typeof noteListItem.static export type NotesCountsResponse = typeof notesCountsResponse.static export type NoteItemResponse = typeof noteItem.static diff --git a/src/main/api/dto/snippets.ts b/src/main/api/dto/snippets.ts index 05bc436b1..47a4d3969 100644 --- a/src/main/api/dto/snippets.ts +++ b/src/main/api/dto/snippets.ts @@ -26,7 +26,7 @@ const snippetContentsUpdate = t.Object({ language: t.Optional(t.String()), // TODO: enum }) -const snippetItem = t.Object({ +const snippetItemBase = { id: t.Number(), name: t.String(), description: t.Union([t.String(), t.Null()]), @@ -42,6 +42,16 @@ const snippetItem = t.Object({ name: t.String(), }), ), + isFavorites: t.Number(), + isDeleted: t.Number(), + createdAt: t.Number(), + updatedAt: t.Number(), + // Файл сниппета — облачный плейсхолдер, содержимое докачивается в фоне. + pendingCloudDownload: t.Optional(t.Boolean()), +} + +const snippetItem = t.Object({ + ...snippetItemBase, contents: t.Array( t.Object({ id: t.Number(), @@ -50,13 +60,22 @@ const snippetItem = t.Object({ language: t.String(), }), ), - isFavorites: t.Number(), - isDeleted: t.Number(), - createdAt: t.Number(), - updatedAt: t.Number(), }) -const snippetsResponse = t.Array(snippetItem) +// Список не содержит тел фрагментов: контент выбранного сниппета +// загружается отдельным GET /snippets/:id. +const snippetListItem = t.Object({ + ...snippetItemBase, + contents: t.Array( + t.Object({ + id: t.Number(), + label: t.String(), + language: t.String(), + }), + ), +}) + +const snippetsResponse = t.Array(snippetListItem) const snippetsCountsResponse = t.Object({ total: t.Number(), @@ -84,5 +103,6 @@ export const snippetsDTO = new Elysia().model({ export type SnippetsAdd = typeof snippetsAdd.static export type SnippetsResponse = typeof snippetsResponse.static +export type SnippetListItemResponse = typeof snippetListItem.static export type SnippetsCountsResponse = typeof snippetsCountsResponse.static export type SnippetItemResponse = typeof snippetItem.static diff --git a/src/main/api/dto/vault-doctor.ts b/src/main/api/dto/vault-doctor.ts new file mode 100644 index 000000000..6f1ffee7d --- /dev/null +++ b/src/main/api/dto/vault-doctor.ts @@ -0,0 +1,117 @@ +import Elysia, { t } from 'elysia' + +const vaultDoctorSpace = t.Union([ + t.Literal('code'), + t.Literal('notes'), + t.Literal('http'), + t.Literal('math'), +]) + +const vaultDoctorAction = t.Union([ + t.Literal('create-folder-metadata'), + t.Literal('detect-conflict'), + t.Literal('write-frontmatter'), + t.Literal('register-file'), + t.Literal('reassign-id'), + t.Literal('repair-environment-state'), + t.Literal('repair-math-state'), + t.Literal('sync-state'), + t.Literal('skip'), +]) + +const vaultDoctorKind = t.Union([ + t.Literal('conflict'), + t.Literal('environment'), + t.Literal('file'), + t.Literal('folder'), + t.Literal('math-sheet'), + t.Literal('note'), + t.Literal('snippet'), +]) + +const vaultDoctorStatus = t.Union([ + t.Literal('applied'), + t.Literal('blocked'), + t.Literal('needs-decision'), + t.Literal('pending'), + t.Literal('skipped'), +]) + +const vaultDoctorConflictReason = t.Union([ + t.Literal('conflicted-copy'), + t.Literal('duplicate-id'), + t.Literal('invalid-frontmatter'), + t.Literal('merge-markers'), +]) + +const vaultDoctorFingerprint = t.Object({ + mtimeMs: t.Number(), + path: t.String(), + size: t.Number(), +}) + +const vaultDoctorItem = t.Object({ + action: vaultDoctorAction, + fingerprint: vaultDoctorFingerprint, + kind: vaultDoctorKind, + path: t.String(), + space: vaultDoctorSpace, + status: vaultDoctorStatus, +}) + +const vaultDoctorWarning = t.Object({ + code: t.String(), + details: t.Optional(t.Record(t.String(), t.String())), + path: t.String(), + space: vaultDoctorSpace, +}) + +const vaultDoctorConflictGroup = t.Object({ + id: t.String(), + items: t.Array(vaultDoctorItem), + reason: vaultDoctorConflictReason, +}) + +const vaultDoctorInput = t.Object({ + decisions: t.Optional( + t.Array( + t.Object({ + groupId: t.String(), + keepPath: t.String(), + }), + ), + ), + spaces: t.Optional(t.Array(vaultDoctorSpace)), +}) + +const vaultDoctorResponse = t.Object({ + conflictGroups: t.Array(vaultDoctorConflictGroup), + items: t.Array(vaultDoctorItem), + // Запрошенные пространства ещё сверяются с диском после открытия vault: + // аудит не выполнялся, пустой результат нельзя трактовать как чистый. + notReady: t.Optional(t.Boolean()), + summary: t.Object({ + affectedFiles: t.Number(), + blocked: t.Number(), + conflicts: t.Number(), + folders: t.Number(), + httpEnvironments: t.Number(), + httpRequests: t.Number(), + mathSheets: t.Number(), + notes: t.Number(), + skipped: t.Number(), + snippets: t.Number(), + warnings: t.Number(), + }), + warnings: t.Array(vaultDoctorWarning), +}) + +export const vaultDoctorDTO = new Elysia().model({ + vaultDoctorInput, + vaultDoctorResponse, +}) + +export type VaultDoctorInput = typeof vaultDoctorInput.static +export type VaultDoctorResponse = typeof vaultDoctorResponse.static +export type VaultDoctorItem = typeof vaultDoctorItem.static +export type VaultDoctorWarning = typeof vaultDoctorWarning.static diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 15f64fa26..eb6902ac3 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -4,6 +4,7 @@ import { app as electronApp } from 'electron' import { Elysia } from 'elysia' import { store } from '../store' import { importEsm } from '../utils' +import captures from './routes/captures' import folders from './routes/folders' import httpEnvironments from './routes/http-environments' import httpFolders from './routes/http-folders' @@ -11,6 +12,7 @@ import httpHistory from './routes/http-history' import httpImport from './routes/http-import' import httpRequests from './routes/http-requests' import imports from './routes/imports' +import internalLinks from './routes/internal-links' import noteFolders from './routes/note-folders' import noteTags from './routes/note-tags' import notes from './routes/notes' @@ -40,6 +42,7 @@ export async function initApi() { }, }), ) + .use(captures) .use(snippets) .use(folders) .use(system) @@ -49,6 +52,7 @@ export async function initApi() { .use(notes) .use(noteFolders) .use(noteTags) + .use(internalLinks) .use(httpFolders) .use(httpRequests) .use(httpEnvironments) diff --git a/src/main/api/integrations/auth.ts b/src/main/api/integrations/auth.ts new file mode 100644 index 000000000..462393634 --- /dev/null +++ b/src/main/api/integrations/auth.ts @@ -0,0 +1,73 @@ +import { Buffer } from 'node:buffer' +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { store } from '../../store' + +const TOKEN_PREFIX = 'mc_' +const TOKEN_BYTES = 32 + +export function hashIntegrationToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function getTokenPreview(token: string): string { + return `${token.slice(0, 5)}...${token.slice(-4)}` +} + +function isSameHash(a: string, b: string): boolean { + const aBuffer = Buffer.from(a, 'hex') + const bBuffer = Buffer.from(b, 'hex') + + if (aBuffer.length !== bBuffer.length) { + return false + } + + return timingSafeEqual(aBuffer, bBuffer) +} + +export function generateIntegrationToken(): { + token: string + tokenPreview: string +} { + const token = `${TOKEN_PREFIX}${randomBytes(TOKEN_BYTES).toString('base64url')}` + const tokenPreview = getTokenPreview(token) + + store.preferences.set('api.integrations.enabled', true) + store.preferences.set( + 'api.integrations.tokenHash', + hashIntegrationToken(token), + ) + store.preferences.set('api.integrations.tokenPreview', tokenPreview) + + return { token, tokenPreview } +} + +export function revokeIntegrationToken(): void { + store.preferences.set('api.integrations.enabled', false) + store.preferences.set('api.integrations.tokenHash', null) + store.preferences.set('api.integrations.tokenPreview', null) +} + +export function isIntegrationTokenAuthorized( + authorizationHeader?: string, +): boolean { + const enabled = store.preferences.get('api.integrations.enabled') as boolean + const tokenHash = store.preferences.get('api.integrations.tokenHash') as + | string + | null + + if (!enabled || !tokenHash || !authorizationHeader) { + return false + } + + const bearerPrefix = 'bearer ' + if (!authorizationHeader.toLowerCase().startsWith(bearerPrefix)) { + return false + } + + const token = authorizationHeader.slice(bearerPrefix.length).trim() + if (!token) { + return false + } + + return isSameHash(hashIntegrationToken(token), tokenHash) +} diff --git a/src/main/api/routes/captures.ts b/src/main/api/routes/captures.ts new file mode 100644 index 000000000..2b7041d60 --- /dev/null +++ b/src/main/api/routes/captures.ts @@ -0,0 +1,508 @@ +import type { CaptureRequest, CaptureResponse } from '../dto/captures' +import { BrowserWindow } from 'electron' +import { Elysia } from 'elysia' +import { getEntryNameValidationIssue } from '../../../shared/entryNameValidation' +import { useHttpStorage, useNotesStorage, useStorage } from '../../storage' +import { capturesDTO } from '../dto/captures' +import { commonMessageResponse } from '../dto/common/response' +import { isIntegrationTokenAuthorized } from '../integrations/auth' + +const app = new Elysia({ prefix: '/captures' }) +const INVALID_CAPTURE_NAME_CHARS = new Set([ + '#', + '[', + ']', + '<', + '>', + ':', + '"', + '/', + '\\', + '^', + '|', + '?', + '*', +]) + +function parseStorageError( + error: unknown, +): { code: string, message: string } | null { + if (!(error instanceof Error)) { + return null + } + + const separatorIndex = error.message.indexOf(':') + if (separatorIndex <= 0) { + return null + } + + return { + code: error.message.slice(0, separatorIndex), + message: error.message.slice(separatorIndex + 1).trim(), + } +} + +function mapStorageError(status: unknown, error: unknown): never { + const setStatus = status as ( + code: number, + payload: { message: string }, + ) => never + const parsedError = parseStorageError(error) + + if (!parsedError) { + return setStatus(500, { message: 'Internal storage error' }) + } + + if (parsedError.code === 'NAME_CONFLICT') { + return setStatus(409, { message: parsedError.message }) + } + + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + + if (parsedError.code === 'FOLDER_NOT_FOUND') { + return setStatus(404, { message: parsedError.message }) + } + + if ( + parsedError.code === 'INVALID_NAME' + || parsedError.code === 'RESERVED_NAME' + ) { + return setStatus(400, { message: parsedError.message }) + } + + return setStatus(500, { + message: parsedError.message || 'Internal storage error', + }) +} + +function trimToValue(value?: string): string | undefined { + const trimmed = value?.trim() + return trimmed || undefined +} + +function getUrlHost(url?: string): string | undefined { + if (!url) { + return undefined + } + + try { + return new URL(url).hostname + } + catch { + return undefined + } +} + +function getHttpNameFromUrl(url?: string, method = 'GET'): string | undefined { + if (!url) { + return undefined + } + + try { + const parsedUrl = new URL(url) + const path + = parsedUrl.pathname === '/' ? parsedUrl.hostname : parsedUrl.pathname + + return `${method} ${path}` + } + catch { + return undefined + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getNextIndexedName(baseName: string, existingNames: string[]): string { + const normalizedBase = baseName.trim() + const indexedNameRe = new RegExp( + `^${escapeRegExp(normalizedBase)}(?:\\s+(\\d+))?$`, + 'i', + ) + + let maxIndex = 0 + existingNames.forEach((name) => { + const match = name.trim().match(indexedNameRe) + if (!match) { + return + } + + const index = match[1] ? Number(match[1]) : 0 + if (Number.isFinite(index)) { + maxIndex = Math.max(maxIndex, index) + } + }) + + return `${normalizedBase} ${maxIndex + 1}` +} + +function getUniqueName(baseName: string, existingNames: string[]): string { + const normalizedBase = sanitizeCaptureName(baseName, 'Captured item') + const hasConflict = existingNames.some( + name => name.trim().toLowerCase() === normalizedBase.toLowerCase(), + ) + + return hasConflict + ? getNextIndexedName(normalizedBase, existingNames) + : normalizedBase +} + +function sanitizeCaptureName(name: string, fallback: string): string { + let sanitized = Array.from(name) + .map(char => + INVALID_CAPTURE_NAME_CHARS.has(char) || char.charCodeAt(0) <= 0x1F + ? ' ' + : char, + ) + .join('') + .replace(/\s+/g, ' ') + .trim() + + sanitized = sanitized.replace(/^\.+/, '').replace(/\.+$/, '').trim() + + if (!sanitized) { + sanitized = fallback + } + + const issue = getEntryNameValidationIssue(sanitized) + + if (issue?.code === 'windowsReserved') { + return `${sanitized} capture` + } + + return issue ? fallback : sanitized +} + +function resolveCaptureName(body: CaptureRequest, fallback: string): string { + const sourceTitle + = trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + const sourceUrl + = trimToValue(body.sourceUrl) + ?? trimToValue(body.source?.url) + ?? trimToValue(body.url) + const explicitName = trimToValue(body.name) + + if (explicitName) { + return explicitName + } + + if (body.target === 'http') { + const requestUrl = trimToValue(body.url) ?? sourceUrl + + return ( + trimToValue(body.suggestedName) + ?? getHttpNameFromUrl(requestUrl, body.method ?? 'GET') + ?? trimToValue(body.contextLabel) + ?? getUrlHost(requestUrl) + ?? fallback + ) + } + + if (body.target === 'code') { + return ( + trimToValue(body.suggestedName) + ?? trimToValue(body.contextLabel) + ?? sourceTitle + ?? getUrlHost(sourceUrl) + ?? fallback + ) + } + + return ( + trimToValue(body.suggestedName) + ?? sourceTitle + ?? trimToValue(body.contextLabel) + ?? getUrlHost(sourceUrl) + ?? fallback + ) +} + +function resolveCapturedAt(body: CaptureRequest): string { + const capturedAt = body.source?.capturedAt + const date + = typeof capturedAt === 'number' && Number.isFinite(capturedAt) + ? new Date(capturedAt) + : new Date() + + return new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + hour: '2-digit', + hour12: false, + minute: '2-digit', + month: 'short', + second: '2-digit', + timeZoneName: 'short', + year: 'numeric', + }).format(date) +} + +function createSourceDescription(body: CaptureRequest): string { + const title + = trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + const url + = trimToValue(body.sourceUrl) + ?? trimToValue(body.source?.url) + ?? trimToValue(body.url) + const lines: string[] = [] + + if (url) { + lines.push(`Captured from: ${title ? `${title} (${url})` : url}`) + } + + const contextLabel = trimToValue(body.contextLabel) + if (contextLabel) { + lines.push(`Context: ${contextLabel}`) + } + + lines.push(`Captured at: ${resolveCapturedAt(body)}`) + + return lines.join('\n') +} + +function getCodeFence(text: string): string { + const matches = text.match(/`{3,}/g) + if (!matches) { + return '```' + } + + const length = Math.max(...matches.map(match => match.length)) + 1 + + return '`'.repeat(length) +} + +function getMarkdownFenceLanguage(body: CaptureRequest): string | undefined { + const sourceName + = trimToValue(body.contextLabel) + ?? trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + + if (!sourceName) { + return undefined + } + + const extension = sourceName.match(/\.([a-z0-9]+)(?:$|[\s?#])/i)?.[1] + if (!extension) { + return undefined + } + + const languages: Record = { + cjs: 'js', + css: 'css', + html: 'html', + js: 'js', + json: 'json', + jsx: 'jsx', + md: 'md', + mjs: 'js', + sh: 'sh', + ts: 'ts', + tsx: 'tsx', + vue: 'vue', + yaml: 'yaml', + yml: 'yaml', + } + + return languages[extension.toLowerCase()] +} + +function createNoteContent(body: CaptureRequest): string { + const text = trimToValue(body.text) + const markdown = trimToValue(body.markdown) + const title + = trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + const url + = trimToValue(body.sourceUrl) + ?? trimToValue(body.source?.url) + ?? trimToValue(body.url) + const lines: string[] = [] + const sourceLines: string[] = [] + + if (markdown) { + lines.push(markdown, '') + } + else if (text) { + const language = getMarkdownFenceLanguage(body) + + if (language) { + const fence = getCodeFence(text) + + lines.push(`${fence}${language}`, text, fence, '') + } + else { + lines.push(text, '') + } + } + + if (url) { + if (title) { + sourceLines.push(`Source: ${title}`) + sourceLines.push(`URL: ${url}`) + } + else { + sourceLines.push(`Source: ${url}`) + } + } + + const contextLabel = trimToValue(body.contextLabel) + if (contextLabel) { + sourceLines.push(`Context: ${contextLabel}`) + } + + sourceLines.push(`Captured: ${resolveCapturedAt(body)}`) + lines.push(...sourceLines.map(line => `> ${line}`)) + + return lines.join('\n') +} + +function getCaptureValidationMessage(body: CaptureRequest): string | null { + if (body.target === 'code' && !trimToValue(body.text)) { + return 'Code capture requires selected text' + } + + if ( + body.target === 'notes' + && !trimToValue(body.text) + && !trimToValue(body.markdown) + ) { + return 'Notes capture requires text or markdown content' + } + + return null +} + +function notifyStorageSynced(): void { + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send('system:storage-synced') + }) +} + +function createCodeCapture(body: CaptureRequest): CaptureResponse { + const storage = useStorage() + const folderId = body.folderId ?? null + const siblings = storage.snippets.getSnippets({ + isDeleted: 0, + ...(folderId === null ? { isInbox: 1 } : { folderId }), + }) + const name = getUniqueName( + resolveCaptureName(body, 'Captured snippet'), + siblings.map(item => item.name), + ) + const { id } = storage.snippets.createSnippet({ folderId, name }) + + storage.snippets.createSnippetContent(id, { + label: name, + language: trimToValue(body.language) ?? 'plain_text', + value: body.text ?? '', + }) + + const description = createSourceDescription(body) + if (description) { + storage.snippets.updateSnippet(id, { description }) + } + + return { id, target: 'code' } +} + +function createNotesCapture(body: CaptureRequest): CaptureResponse { + const storage = useNotesStorage() + const folderId = body.folderId ?? null + const siblings = storage.notes.getNotes({ + isDeleted: 0, + ...(folderId === null ? { isInbox: 1 } : { folderId }), + }) + const name = getUniqueName( + resolveCaptureName(body, 'Captured note'), + siblings.map(item => item.name), + ) + const { id } = storage.notes.createNote({ folderId, name }) + + storage.notes.updateNoteContent(id, createNoteContent(body)) + + return { id, target: 'notes' } +} + +function createHttpCapture(body: CaptureRequest): CaptureResponse { + const storage = useHttpStorage() + const folderId = body.folderId ?? null + const siblings = storage.requests.getRequests({ + isDeleted: 0, + ...(folderId === null ? { isInbox: 1 } : { folderId }), + }) + const url = trimToValue(body.url) ?? trimToValue(body.source?.url) ?? '' + const name = getUniqueName( + resolveCaptureName(body, 'Captured request'), + siblings.map(item => item.name), + ) + const { id } = storage.requests.createRequest({ + folderId, + method: body.method ?? 'GET', + name, + url, + }) + + const description = createSourceDescription(body) + if (description) { + storage.requests.updateRequest(id, { description }) + } + + return { id, target: 'http' } +} + +app.use(capturesDTO).post( + '/', + ({ body, headers, status }) => { + if (!isIntegrationTokenAuthorized(headers.authorization)) { + return status(401, { message: 'Unauthorized integration request' }) + } + + const validationMessage = getCaptureValidationMessage(body) + if (validationMessage) { + return status(400, { message: validationMessage }) + } + + try { + const result + = body.target === 'code' + ? createCodeCapture(body) + : body.target === 'notes' + ? createNotesCapture(body) + : createHttpCapture(body) + + notifyStorageSynced() + + return result + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'captureRequest', + response: { + 200: 'captureResponse', + 400: commonMessageResponse, + 401: commonMessageResponse, + 404: commonMessageResponse, + 409: commonMessageResponse, + 500: commonMessageResponse, + }, + detail: { + tags: ['Captures'], + }, + }, +) + +export default app diff --git a/src/main/api/routes/folders.ts b/src/main/api/routes/folders.ts index 5510c0901..354cc4a66 100644 --- a/src/main/api/routes/folders.ts +++ b/src/main/api/routes/folders.ts @@ -39,6 +39,13 @@ function mapStorageError(status: unknown, error: unknown): never { return setStatus(409, { message: parsedError.message }) } + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + if (parsedError.code === 'FOLDER_NOT_FOUND') { return setStatus(404, { message: parsedError.message }) } diff --git a/src/main/api/routes/http-environments.ts b/src/main/api/routes/http-environments.ts index 684694920..d1ccf72e8 100644 --- a/src/main/api/routes/http-environments.ts +++ b/src/main/api/routes/http-environments.ts @@ -39,6 +39,13 @@ function mapStorageError(status: unknown, error: unknown): never { return setStatus(409, { message: parsedError.message }) } + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + if ( parsedError.code === 'INVALID_NAME' || parsedError.code === 'RESERVED_NAME' diff --git a/src/main/api/routes/http-folders.ts b/src/main/api/routes/http-folders.ts index d169d4e67..f57ac0fe8 100644 --- a/src/main/api/routes/http-folders.ts +++ b/src/main/api/routes/http-folders.ts @@ -39,6 +39,13 @@ function mapStorageError(status: unknown, error: unknown): never { return setStatus(409, { message: parsedError.message }) } + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + if (parsedError.code === 'FOLDER_NOT_FOUND') { return setStatus(404, { message: parsedError.message }) } diff --git a/src/main/api/routes/http-requests.ts b/src/main/api/routes/http-requests.ts index 334766e8b..15e0fee56 100644 --- a/src/main/api/routes/http-requests.ts +++ b/src/main/api/routes/http-requests.ts @@ -45,6 +45,13 @@ function mapStorageError(status: unknown, error: unknown): never { return setStatus(409, { message: parsedError.message }) } + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + if (parsedError.code === 'FOLDER_NOT_FOUND') { return setStatus(404, { message: parsedError.message }) } @@ -69,7 +76,11 @@ app const storage = useHttpStorage() const result = storage.requests.getRequests(query) - return result as HttpRequestsResponse + // body не сериализуется в список (остаётся ленивым на диске): полная + // запись выбранного запроса загружается через GET /http-requests/:id. + return result.map( + ({ body: _body, ...request }) => request, + ) as HttpRequestsResponse }, { query: 'httpRequestsQuery', diff --git a/src/main/api/routes/internal-links.ts b/src/main/api/routes/internal-links.ts new file mode 100644 index 000000000..dcf58d5eb --- /dev/null +++ b/src/main/api/routes/internal-links.ts @@ -0,0 +1,163 @@ +import type { InternalLinkLookupItem } from '../../../shared/notes/internalLinks' +import type { InternalLinksResolveResponse } from '../dto/internal-links' +import Elysia from 'elysia' +import { buildNoteFolderPathMap } from '../../../shared/notes/folderPath' +import { useHttpStorage, useNotesStorage, useStorage } from '../../storage' +import { createInternalLinkResolver } from '../../storage/providers/markdown/notes/runtime/internalLinkResolver' +import { internalLinksDTO } from '../dto/internal-links' + +const NOTE_CONTENT_EXCERPT_LENGTH = 400 + +const app = new Elysia({ prefix: '/internal-links' }) + +app.use(internalLinksDTO).post( + '/resolve', + ({ body }) => { + // Один batch-запрос вместо пакета из 5 запросов на каждую ссылку: + // резолв и данные превью собираются в main по runtime-кэшу. + const titles = [ + ...new Set(body.titles.map(t => t.trim()).filter(Boolean)), + ] + + if (!titles.length) { + return [] as InternalLinksResolveResponse + } + + const storage = useStorage() + const notesStorage = useNotesStorage() + const httpStorage = useHttpStorage() + + const snippets = storage.snippets.getSnippets({ isDeleted: 0 }) + const notes = notesStorage.notes.getNotes({ isDeleted: 0 }) + const noteFolders = notesStorage.folders.getFolders() + const httpRequests = httpStorage.requests.getRequests() + const httpFolders = httpStorage.folders.getFolders() + + const noteFolderPathById = buildNoteFolderPathMap(noteFolders) + const httpFolderPathById = buildNoteFolderPathMap(httpFolders) + + const resolver = createInternalLinkResolver([ + ...snippets.map(snippet => ({ + id: snippet.id, + name: snippet.name, + type: 'snippet', + })), + ...notes.map(note => ({ + folderPath: note.folder + ? noteFolderPathById.get(note.folder.id) + : undefined, + id: note.id, + name: note.name, + type: 'note', + })), + ...httpRequests.map(request => ({ + folderPath: + request.folderId === null + ? '' + : (httpFolderPathById.get(request.folderId) ?? ''), + id: request.id, + name: request.name, + type: 'http-request', + })), + ]) + + const snippetById = new Map(snippets.map(s => [s.id, s])) + const noteById = new Map(notes.map(n => [n.id, n])) + const requestById = new Map(httpRequests.map(r => [r.id, r])) + + return titles.map((title) => { + const target = resolver.resolve(title) + + if (!target) { + return { title, resolved: null } + } + + if (target.type === 'snippet') { + // getSnippetById дочитывает ленивое тело: превью нужен фрагмент. + const snippet = snippetById.has(target.id) + ? storage.snippets.getSnippetById(target.id) + : null + + if (!snippet) { + return { title, resolved: null } + } + + return { + title, + resolved: { + type: 'snippet' as const, + id: snippet.id, + name: snippet.name, + folder: snippet.folder, + isDeleted: snippet.isDeleted, + firstContent: snippet.contents[0] + ? { + language: snippet.contents[0].language, + value: snippet.contents[0].value, + } + : null, + }, + } + } + + if (target.type === 'http-request') { + // getRequestById дочитывает ленивые детали: превью нужен description. + const request = requestById.has(target.id) + ? httpStorage.requests.getRequestById(target.id) + : null + + if (!request) { + return { title, resolved: null } + } + + return { + title, + resolved: { + type: 'http-request' as const, + id: request.id, + name: request.name, + folder: null, + isDeleted: request.isDeleted, + request: { + method: request.method, + url: request.url, + description: request.description, + }, + }, + } + } + + // getNoteById дочитывает ленивое тело: превью нужен excerpt. + const note = noteById.has(target.id) + ? notesStorage.notes.getNoteById(target.id) + : null + + if (!note) { + return { title, resolved: null } + } + + return { + title, + resolved: { + type: 'note' as const, + id: note.id, + name: note.name, + folder: note.folder, + isDeleted: note.isDeleted, + contentExcerpt: note.content + .slice(0, NOTE_CONTENT_EXCERPT_LENGTH) + .trim(), + }, + } + }) as InternalLinksResolveResponse + }, + { + body: 'internalLinksResolveBody', + response: 'internalLinksResolveResponse', + detail: { + tags: ['InternalLinks'], + }, + }, +) + +export default app diff --git a/src/main/api/routes/note-folders.ts b/src/main/api/routes/note-folders.ts index 347937031..64d4b398d 100644 --- a/src/main/api/routes/note-folders.ts +++ b/src/main/api/routes/note-folders.ts @@ -39,6 +39,13 @@ function mapStorageError(status: unknown, error: unknown): never { return setStatus(409, { message: parsedError.message }) } + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + if (parsedError.code === 'FOLDER_NOT_FOUND') { return setStatus(404, { message: parsedError.message }) } diff --git a/src/main/api/routes/notes-dashboard.ts b/src/main/api/routes/notes-dashboard.ts index 05cc78b45..ee7ea964a 100644 --- a/src/main/api/routes/notes-dashboard.ts +++ b/src/main/api/routes/notes-dashboard.ts @@ -57,7 +57,12 @@ app.use(notesDashboardDTO).get( () => { const notesStorage = useNotesStorage() const storage = useStorage() - const notes = notesStorage.notes.getNotes({ isDeleted: 0 }) + // Дашборд считает слова и строит граф по телам: ленивые записи + // дочитываются. + const notes = notesStorage.notes.getNotes({ + isDeleted: 0, + withContent: true, + }) const folders = notesStorage.folders.getFolders() const tags = notesStorage.tags.getTags() const snippets = storage.snippets.getSnippets({ isDeleted: 0 }) diff --git a/src/main/api/routes/notes-graph.ts b/src/main/api/routes/notes-graph.ts index 9e5f6788d..2ede71813 100644 --- a/src/main/api/routes/notes-graph.ts +++ b/src/main/api/routes/notes-graph.ts @@ -11,7 +11,11 @@ app.use(notesDashboardDTO).get( () => { const notesStorage = useNotesStorage() const storage = useStorage() - const notes = notesStorage.notes.getNotes({ isDeleted: 0 }) + // Граф строится по [[ссылкам]] в телах: ленивые записи дочитываются. + const notes = notesStorage.notes.getNotes({ + isDeleted: 0, + withContent: true, + }) const snippets = storage.snippets.getSnippets({ isDeleted: 0 }) return buildNotesGraph({ diff --git a/src/main/api/routes/notes.ts b/src/main/api/routes/notes.ts index 4c469e779..d69ba9ce1 100644 --- a/src/main/api/routes/notes.ts +++ b/src/main/api/routes/notes.ts @@ -1,6 +1,7 @@ import type { NoteItemResponse, NotesResponse } from '../dto/notes' import Elysia from 'elysia' import { useNotesStorage } from '../../storage' +import { runTasksCleanupNow } from '../../tasks' import { commonAddResponse, commonMessageResponse, @@ -42,6 +43,13 @@ function mapStorageError(status: unknown, error: unknown): never { return setStatus(409, { message: parsedError.message }) } + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + if ( parsedError.code === 'FOLDER_NOT_FOUND' || parsedError.code === 'NOTE_NOT_FOUND' @@ -69,7 +77,11 @@ app const storage = useNotesStorage() const result = storage.notes.getNotes(query) - return result as NotesResponse + // Контент заметок не сериализуется в список: контент выбранной + // заметки загружается через GET /notes/:id. + return result.map( + ({ content: _content, ...note }) => note, + ) as NotesResponse }, { query: 'notesQuery', @@ -170,16 +182,21 @@ app '/:id/content', ({ params, body, status }) => { const storage = useNotesStorage() - const { notFound } = storage.notes.updateNoteContent( - Number(params.id), - body.content, - ) + try { + const { notFound } = storage.notes.updateNoteContent( + Number(params.id), + body.content, + ) - if (notFound) { - return status(404, { message: 'Note not found' }) - } + if (notFound) { + return status(404, { message: 'Note not found' }) + } - return { message: 'Note content updated' } + return { message: 'Note content updated' } + } + catch (error) { + return mapStorageError(status, error) + } }, { body: 'notesContentUpdate', @@ -188,6 +205,37 @@ app }, }, ) + .patch( + '/:id/properties', + ({ params, body, status }) => { + const storage = useNotesStorage() + try { + const { invalidInput, notFound } = storage.notes.updateNoteProperties( + Number(params.id), + body, + ) + + if (invalidInput) { + return status(400, { message: 'Need at least one field to update' }) + } + + if (notFound) { + return status(404, { message: 'Note not found' }) + } + + return { message: 'Note properties updated' } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'notePropertiesUpdate', + detail: { + tags: ['Notes'], + }, + }, + ) .post( '/:id/tags/:tagId', ({ params, status }) => { @@ -283,5 +331,18 @@ app }, }, ) + .post( + '/tasks/cleanup', + () => { + const count = runTasksCleanupNow() + return { count } + }, + { + response: 'notesTasksCleanupResponse', + detail: { + tags: ['Notes'], + }, + }, + ) export default app diff --git a/src/main/api/routes/snippets.ts b/src/main/api/routes/snippets.ts index eef9937fb..f0fb3f1ad 100644 --- a/src/main/api/routes/snippets.ts +++ b/src/main/api/routes/snippets.ts @@ -46,6 +46,13 @@ function mapStorageError(status: unknown, error: unknown): never { return setStatus(409, { message: parsedError.message }) } + if ( + parsedError.code === 'VAULT_HYDRATING' + || parsedError.code === 'CLOUD_FILE_NOT_DOWNLOADED' + ) { + return setStatus(503, { message: parsedError.message }) + } + if ( parsedError.code === 'FOLDER_NOT_FOUND' || parsedError.code === 'SNIPPET_NOT_FOUND' @@ -74,7 +81,16 @@ app const storage = useStorage() const result = storage.snippets.getSnippets(query) - return result as SnippetsResponse + // Тела фрагментов не сериализуются в список: контент выбранного + // сниппета загружается через GET /snippets/:id. + return result.map(snippet => ({ + ...snippet, + contents: snippet.contents.map(({ id, label, language }) => ({ + id, + label, + language, + })), + })) as SnippetsResponse }, { query: 'snippetsQuery', @@ -337,6 +353,7 @@ app ({ params, status }) => { const storage = useStorage() const { deleted } = storage.snippets.deleteSnippetContent( + Number(params.id), Number(params.contentId), ) diff --git a/src/main/api/routes/system.ts b/src/main/api/routes/system.ts index 81435fe1a..3bfd62da1 100644 --- a/src/main/api/routes/system.ts +++ b/src/main/api/routes/system.ts @@ -1,8 +1,13 @@ import { Elysia } from 'elysia' import { resetRuntimeCache } from '../../storage/providers/markdown' +import { + applyVaultDoctor, + previewVaultDoctor, +} from '../../storage/providers/markdown/doctor' import { getVaultPath } from '../../storage/providers/markdown/runtime' +import { vaultDoctorDTO } from '../dto/vault-doctor' -const app = new Elysia({ prefix: '/system' }) +const app = new Elysia({ prefix: '/system' }).use(vaultDoctorDTO) app.get( '/storage-vault-path', @@ -34,4 +39,32 @@ app.post( }, ) +app.post( + '/vault-doctor/preview', + ({ body }) => { + return previewVaultDoctor(body) + }, + { + body: 'vaultDoctorInput', + response: 'vaultDoctorResponse', + detail: { + tags: ['System'], + }, + }, +) + +app.post( + '/vault-doctor/apply', + ({ body }) => { + return applyVaultDoctor(body) + }, + { + body: 'vaultDoctorInput', + response: 'vaultDoctorResponse', + detail: { + tags: ['System'], + }, + }, +) + export default app diff --git a/src/main/dockBadge/__tests__/counts.test.ts b/src/main/dockBadge/__tests__/counts.test.ts new file mode 100644 index 000000000..5d0e867db --- /dev/null +++ b/src/main/dockBadge/__tests__/counts.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { countDockBadge, getLocalDateOnly, isValidDateOnly } from '../counts' + +const emptyInput = { notes: [], snippets: [] } + +describe('dock badge counts', () => { + it('counts only active Code Inbox snippets', () => { + expect( + countDockBadge( + 'codeInbox', + { + notes: [], + snippets: [ + { folderId: null, isDeleted: 0 }, + { folderId: 1, isDeleted: 0 }, + { folderId: null, isDeleted: 1 }, + ], + }, + '2026-07-12', + ), + ).toBe(1) + }) + + it('counts all active Notes Inbox notes including tasks', () => { + expect( + countDockBadge( + 'notesInbox', + { + notes: [ + { folderId: null, isDeleted: 0, properties: {} }, + { + folderId: null, + isDeleted: 0, + properties: { type: 'task' }, + }, + { folderId: 1, isDeleted: 0, properties: {} }, + { folderId: null, isDeleted: 1, properties: {} }, + ], + snippets: [], + }, + '2026-07-12', + ), + ).toBe(2) + }) + + it('counts incomplete overdue and today tasks', () => { + expect( + countDockBadge( + 'tasksDue', + { + notes: [ + { + folderId: null, + isDeleted: 0, + properties: { type: 'task', status: 'todo', due: '2026-07-11' }, + }, + { + folderId: 1, + isDeleted: 0, + properties: { + type: 'task', + status: 'inProgress', + due: '2026-07-12', + }, + }, + { + folderId: 1, + isDeleted: 0, + properties: { + type: 'task', + status: 'blocked', + due: '2026-07-12', + }, + }, + { + folderId: 1, + isDeleted: 0, + properties: { type: 'task', due: '2026-07-12' }, + }, + { + folderId: 1, + isDeleted: 0, + properties: { + type: 'task', + status: 'unknown', + due: '2026-07-12', + }, + }, + ], + snippets: [], + }, + '2026-07-12', + ), + ).toBe(5) + }) + + it('excludes completed, future, undated, invalid, deleted and normal notes', () => { + expect( + countDockBadge( + 'tasksDue', + { + notes: [ + { + folderId: null, + isDeleted: 0, + properties: { type: 'task', status: 'done', due: '2026-07-12' }, + }, + { + folderId: null, + isDeleted: 0, + properties: { type: 'task', due: '2026-07-13' }, + }, + { folderId: null, isDeleted: 0, properties: { type: 'task' } }, + { + folderId: null, + isDeleted: 0, + properties: { type: 'task', due: '2026-02-30' }, + }, + { + folderId: null, + isDeleted: 1, + properties: { type: 'task', due: '2026-07-12' }, + }, + { + folderId: null, + isDeleted: 0, + properties: { type: 'note', due: '2026-07-12' }, + }, + ], + snippets: [], + }, + '2026-07-12', + ), + ).toBe(0) + }) + + it('returns zero for none', () => { + expect(countDockBadge('none', emptyInput, '2026-07-12')).toBe(0) + }) +}) + +describe('date-only helpers', () => { + it('accepts only strict real calendar dates', () => { + expect(isValidDateOnly('2024-02-29')).toBe(true) + expect(isValidDateOnly('2026-02-29')).toBe(false) + expect(isValidDateOnly('2026-2-03')).toBe(false) + expect(isValidDateOnly('2026-02-03T00:00:00Z')).toBe(false) + }) + + it('formats a date using local calendar fields', () => { + expect(getLocalDateOnly(new Date(2026, 6, 12, 23, 59))).toBe('2026-07-12') + }) +}) diff --git a/src/main/dockBadge/__tests__/index.test.ts b/src/main/dockBadge/__tests__/index.test.ts new file mode 100644 index 000000000..839d634e3 --- /dev/null +++ b/src/main/dockBadge/__tests__/index.test.ts @@ -0,0 +1,143 @@ +import type { DockBadgeSource } from '../../store/types' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDockBadgeController } from '../index' + +vi.mock('electron', () => ({ + app: { + isReady: vi.fn(() => true), + setBadgeCount: vi.fn(), + }, +})) + +vi.mock('../../store', () => ({ + store: { + preferences: { + get: vi.fn(() => 'none'), + }, + }, +})) + +vi.mock('../../storage/providers/markdown/notes/runtime/constants', () => ({ + peekNotesRuntimeCache: vi.fn(() => null), +})) + +vi.mock('../../storage/providers/markdown/runtime/cache', () => ({ + peekRuntimeCache: vi.fn(() => null), +})) + +afterEach(() => { + vi.useRealTimers() +}) + +function createContext(source: DockBadgeSource = 'none') { + const setBadgeCount = vi.fn(() => true) + const peekNotesCache = vi.fn(() => null) + const peekSnippetsCache = vi.fn(() => null) + let currentSource = source + + const controller = createDockBadgeController({ + app: { isReady: () => true, setBadgeCount }, + clearTimer: clearTimeout, + getSource: () => currentSource, + now: () => new Date(), + peekNotesCache, + peekSnippetsCache, + platform: 'darwin', + setTimer: setTimeout, + }) + + return { + controller, + peekNotesCache, + peekSnippetsCache, + setBadgeCount, + setSource: (value: DockBadgeSource) => { + currentSource = value + }, + } +} + +describe('dock badge controller', () => { + it('refreshes immediately from only the selected cache', () => { + const context = createContext('codeInbox') + context.peekSnippetsCache.mockReturnValue({ + snippets: [{ folderId: null, isDeleted: 0 }], + } as any) + + expect(context.controller.refresh()).toEqual({ applied: true, count: 1 }) + expect(context.setBadgeCount).toHaveBeenCalledWith(1) + expect(context.peekSnippetsCache).toHaveBeenCalledTimes(1) + expect(context.peekNotesCache).not.toHaveBeenCalled() + }) + + it('reports when macOS rejects the badge update', () => { + const context = createContext('notesInbox') + context.setBadgeCount.mockReturnValue(false) + + expect(context.controller.refresh()).toEqual({ + applied: false, + count: 0, + }) + }) + + it('does not touch Electron before readiness or outside macOS', () => { + const setBadgeCount = vi.fn() + const base = { + app: { isReady: () => false, setBadgeCount }, + clearTimer: clearTimeout, + getSource: () => 'none' as const, + now: () => new Date(), + peekNotesCache: () => null, + peekSnippetsCache: () => null, + setTimer: setTimeout, + } + + expect( + createDockBadgeController({ ...base, platform: 'darwin' }).refresh(), + ).toEqual({ applied: false, count: 0 }) + expect( + createDockBadgeController({ ...base, platform: 'linux' }).refresh(), + ).toEqual({ applied: false, count: 0 }) + expect(setBadgeCount).not.toHaveBeenCalled() + }) + + it('debounces scheduled refreshes', () => { + vi.useFakeTimers() + const context = createContext('none') + + context.controller.scheduleRefresh() + context.controller.scheduleRefresh() + vi.advanceTimersByTime(149) + expect(context.setBadgeCount).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(context.setBadgeCount).toHaveBeenCalledTimes(1) + }) + + it('refreshes tasks at each next local midnight', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 12, 23, 59, 59, 900)) + const context = createContext('tasksDue') + + context.controller.refresh() + expect(context.setBadgeCount).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(100) + expect(context.setBadgeCount).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(24 * 60 * 60 * 1000) + expect(context.setBadgeCount).toHaveBeenCalledTimes(3) + }) + + it('clears timers and badge during cleanup', () => { + vi.useFakeTimers() + const context = createContext('tasksDue') + context.controller.refresh() + context.controller.scheduleRefresh() + + context.controller.cleanup() + vi.runAllTimers() + + expect(context.setBadgeCount.mock.calls).toEqual([[0], [0]]) + }) +}) diff --git a/src/main/dockBadge/counts.ts b/src/main/dockBadge/counts.ts new file mode 100644 index 000000000..69c5d6021 --- /dev/null +++ b/src/main/dockBadge/counts.ts @@ -0,0 +1,81 @@ +import type { DockBadgeSource } from '../store/types' + +interface BadgeSnippet { + folderId: number | null + isDeleted: number +} + +interface BadgeNote extends BadgeSnippet { + properties: Record +} + +export interface DockBadgeCountsInput { + notes: BadgeNote[] + snippets: BadgeSnippet[] +} + +const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/ + +export function getLocalDateOnly(date: Date): string { + const year = String(date.getFullYear()).padStart(4, '0') + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + return `${year}-${month}-${day}` +} + +export function isValidDateOnly(value: unknown): value is string { + if (typeof value !== 'string') { + return false + } + + const match = DATE_ONLY_RE.exec(value) + if (!match) { + return false + } + + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const date = new Date(year, month - 1, day) + + return ( + date.getFullYear() === year + && date.getMonth() === month - 1 + && date.getDate() === day + ) +} + +export function countDockBadge( + source: DockBadgeSource, + input: DockBadgeCountsInput, + localToday: string, +): number { + if (source === 'none') { + return 0 + } + + if (source === 'codeInbox') { + return input.snippets.filter( + snippet => snippet.folderId === null && snippet.isDeleted === 0, + ).length + } + + if (source === 'notesInbox') { + return input.notes.filter( + note => note.folderId === null && note.isDeleted === 0, + ).length + } + + return input.notes.filter((note) => { + const due = note.properties.due + + return ( + note.isDeleted === 0 + && note.properties.type === 'task' + && note.properties.status !== 'done' + && isValidDateOnly(due) + && due <= localToday + ) + }).length +} diff --git a/src/main/dockBadge/index.ts b/src/main/dockBadge/index.ts new file mode 100644 index 000000000..82308be24 --- /dev/null +++ b/src/main/dockBadge/index.ts @@ -0,0 +1,138 @@ +/* eslint-disable node/prefer-global/process */ +import type { NotesRuntimeCache } from '../storage/providers/markdown/notes/runtime/types' +import type { MarkdownRuntimeCache } from '../storage/providers/markdown/runtime/types' +import type { DockBadgeSource } from '../store/types' +import { app } from 'electron' +import { peekNotesRuntimeCache } from '../storage/providers/markdown/notes/runtime/constants' +import { peekRuntimeCache } from '../storage/providers/markdown/runtime/cache' +import { store } from '../store' +import { countDockBadge, getLocalDateOnly } from './counts' + +const REFRESH_DELAY_MS = 150 + +interface DockBadgeControllerDependencies { + app: Pick + clearTimer: (timer: ReturnType) => void + getSource: () => DockBadgeSource + now: () => Date + peekNotesCache: () => NotesRuntimeCache | null + peekSnippetsCache: () => MarkdownRuntimeCache | null + platform: NodeJS.Platform + setTimer: ( + callback: () => void, + delay: number, + ) => ReturnType +} + +export interface DockBadgeController { + cleanup: () => void + refresh: () => DockBadgeRefreshResult + scheduleRefresh: () => void +} + +export interface DockBadgeRefreshResult { + applied: boolean + count: number +} + +export function createDockBadgeController( + dependencies: DockBadgeControllerDependencies, +): DockBadgeController { + let refreshTimer: ReturnType | null = null + let midnightTimer: ReturnType | null = null + + function clearRefreshTimer(): void { + if (refreshTimer) { + dependencies.clearTimer(refreshTimer) + refreshTimer = null + } + } + + function clearMidnightTimer(): void { + if (midnightTimer) { + dependencies.clearTimer(midnightTimer) + midnightTimer = null + } + } + + function scheduleMidnightRefresh(source: DockBadgeSource): void { + clearMidnightTimer() + if (source !== 'tasksDue') { + return + } + + const now = dependencies.now() + const nextMidnight = new Date(now) + nextMidnight.setHours(24, 0, 0, 0) + midnightTimer = dependencies.setTimer(() => { + midnightTimer = null + refresh() + }, nextMidnight.getTime() - now.getTime()) + } + + function refresh(): DockBadgeRefreshResult { + clearRefreshTimer() + if (dependencies.platform !== 'darwin' || !dependencies.app.isReady()) { + clearMidnightTimer() + return { applied: false, count: 0 } + } + + const source = dependencies.getSource() + const snippets + = source === 'codeInbox' + ? (dependencies.peekSnippetsCache()?.snippets ?? []) + : [] + const notes + = source === 'notesInbox' || source === 'tasksDue' + ? (dependencies.peekNotesCache()?.notes ?? []) + : [] + const count = countDockBadge( + source, + { notes, snippets }, + getLocalDateOnly(dependencies.now()), + ) + + const applied = dependencies.app.setBadgeCount(count) + scheduleMidnightRefresh(source) + + return { applied, count } + } + + function scheduleRefresh(): void { + if (dependencies.platform !== 'darwin') { + return + } + + clearRefreshTimer() + refreshTimer = dependencies.setTimer(() => { + refreshTimer = null + refresh() + }, REFRESH_DELAY_MS) + } + + function cleanup(): void { + clearRefreshTimer() + clearMidnightTimer() + if (dependencies.platform === 'darwin' && dependencies.app.isReady()) { + dependencies.app.setBadgeCount(0) + } + } + + return { cleanup, refresh, scheduleRefresh } +} + +const controller = createDockBadgeController({ + app, + clearTimer: clearTimeout, + getSource: () => + store.preferences.get('appearance.dockBadgeSource') as DockBadgeSource, + now: () => new Date(), + peekNotesCache: peekNotesRuntimeCache, + peekSnippetsCache: peekRuntimeCache, + platform: process.platform, + setTimer: setTimeout, +}) + +export const cleanupDockBadge = controller.cleanup +export const refreshDockBadge = controller.refresh +export const scheduleDockBadgeRefresh = controller.scheduleRefresh diff --git a/src/main/folderIcons.ts b/src/main/folderIcons.ts new file mode 100644 index 000000000..d20af8036 --- /dev/null +++ b/src/main/folderIcons.ts @@ -0,0 +1,433 @@ +import type { NativeImage } from 'electron' +import type { + FolderIconSetPayload, + FolderIconSpaceId, + FolderIconTarget, + FolderIconWritePayload, +} from './types/ipc' +import { Buffer } from 'node:buffer' +import { createHash, randomBytes } from 'node:crypto' +import path from 'node:path' +import { nativeImage } from 'electron' +import fs from 'fs-extra' +import { useHttpStorage, useNotesStorage, useStorage } from './storage' +import { prioritizeCloudDownload } from './storage/providers/markdown/cloudDownloads' +import { getHttpPaths } from './storage/providers/markdown/http' +import { getNotesPaths } from './storage/providers/markdown/notes' +import { getPaths, getVaultPath } from './storage/providers/markdown/runtime' +import { rememberAppFileChange } from './storage/providers/markdown/runtime/shared/appChanges' +import { getFileAvailability } from './storage/providers/markdown/runtime/shared/cloudFiles' +import { buildFolderPathMap } from './storage/providers/markdown/runtime/shared/folderIndex' + +export const FOLDER_ICON_FILE_NAME = '.icon.png' +export const FOLDER_ICON_MAX_BYTES = 10 * 1024 * 1024 +export const FOLDER_ICON_SIZE = 128 + +const FOLDER_ICON_SPACES = new Set([ + 'code', + 'notes', + 'http', +]) +const pendingFolderIconMutations = new Map>() +const emojiSegmenter = new Intl.Segmenter(undefined, { + granularity: 'grapheme', +}) + +function isSingleEmojiGrapheme(value: string): boolean { + if (value.length === 0 || value.length > 32) + return false + + const segments = [...emojiSegmenter.segment(value)] + if (segments.length !== 1 || segments[0]?.segment !== value) + return false + + return ( + /\p{Extended_Pictographic}/u.test(value) + || /^\p{Regional_Indicator}{2}$/u.test(value) + || /^[#*0-9]\uFE0F?\u20E3$/u.test(value) + ) +} + +function isSupportedImageSignature(buffer: Buffer): boolean { + const isPng + = buffer.length >= 8 + && buffer + .subarray(0, 8) + .equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) + const isJpeg + = buffer.length >= 3 + && buffer[0] === 0xFF + && buffer[1] === 0xD8 + && buffer[2] === 0xFF + + return isPng || isJpeg +} + +export function parseFolderIconTarget(value: unknown): FolderIconTarget | null { + if (!value || typeof value !== 'object') + return null + + const { folderId, spaceId } = value as Record + if ( + !Number.isInteger(folderId) + || (folderId as number) <= 0 + || typeof spaceId !== 'string' + || !FOLDER_ICON_SPACES.has(spaceId as FolderIconSpaceId) + ) { + return null + } + + return { + folderId: folderId as number, + spaceId: spaceId as FolderIconSpaceId, + } +} + +export function parseFolderIconWritePayload( + value: unknown, +): FolderIconWritePayload | null { + const target = parseFolderIconTarget(value) + if (!target) + return null + + const { buffer } = value as { buffer?: unknown } + if ( + !(buffer instanceof ArrayBuffer) + || buffer.byteLength === 0 + || buffer.byteLength > FOLDER_ICON_MAX_BYTES + ) { + return null + } + + const bytes = Buffer.from(buffer) + if (!isSupportedImageSignature(bytes)) + return null + + return { ...target, buffer } +} + +export function parseFolderIconSetPayload( + value: unknown, +): FolderIconSetPayload | null { + const target = parseFolderIconTarget(value) + if (!target) + return null + + const { icon } = value as { icon?: unknown } + if (icon === null) + return { ...target, icon } + + if ( + typeof icon !== 'string' + || icon.length === 0 + || icon.length > 256 + || icon.startsWith('custom:') + ) { + return null + } + + if (icon.startsWith('emoji:') && !isSingleEmojiGrapheme(icon.slice(6))) + return null + + return { ...target, icon } +} + +async function runFolderIconMutation( + target: FolderIconTarget, + mutation: () => Promise, +): Promise { + const key = `${target.spaceId}:${target.folderId}` + const previous = pendingFolderIconMutations.get(key) ?? Promise.resolve() + const current = previous.catch(() => undefined).then(mutation) + pendingFolderIconMutations.set(key, current) + + try { + return await current + } + finally { + if (pendingFolderIconMutations.get(key) === current) + pendingFolderIconMutations.delete(key) + } +} + +function getSpaceFoldersAndRoot(spaceId: FolderIconSpaceId) { + const vaultPath = getVaultPath() + + if (spaceId === 'notes') { + const storage = useNotesStorage() + return { + folders: storage.folders.getFolders(), + root: getNotesPaths(vaultPath).notesRoot, + updateIcon: (folderId: number, icon: string | null) => + storage.folders.updateFolder(folderId, { icon }), + } + } + + if (spaceId === 'http') { + const storage = useHttpStorage() + return { + folders: storage.folders.getFolders(), + root: getHttpPaths(vaultPath).httpRoot, + updateIcon: (folderId: number, icon: string | null) => + storage.folders.updateFolder(folderId, { icon }), + } + } + + const storage = useStorage() + return { + folders: storage.folders.getFolders(), + root: getPaths(vaultPath).vaultPath, + updateIcon: (folderId: number, icon: string | null) => + storage.folders.updateFolder(folderId, { icon }), + } +} + +export function resolveFolderIconPath( + spaceId: FolderIconSpaceId, + folderId: number, +): string | null { + if ( + !FOLDER_ICON_SPACES.has(spaceId) + || !Number.isInteger(folderId) + || folderId <= 0 + ) { + return null + } + + const { folders, root } = getSpaceFoldersAndRoot(spaceId) + const relativeFolderPath = buildFolderPathMap(folders).get(folderId) + if (!relativeFolderPath) + return null + + const resolvedRoot = path.resolve(root) + const folderPath = path.resolve(resolvedRoot, relativeFolderPath) + if (!folderPath.startsWith(`${resolvedRoot}${path.sep}`)) + return null + + try { + const realRoot = fs.realpathSync(resolvedRoot) + const realFolderPath = fs.realpathSync(folderPath) + if (!realFolderPath.startsWith(`${realRoot}${path.sep}`)) + return null + } + catch { + return null + } + + return path.join(folderPath, FOLDER_ICON_FILE_NAME) +} + +export function createFolderIconPng( + input: Buffer, + decode: (buffer: Buffer) => NativeImage = nativeImage.createFromBuffer, +): Buffer { + const image = decode(input) + if (image.isEmpty()) + throw new TypeError('Image could not be decoded') + + const { height, width } = image.getSize() + if (height <= 0 || width <= 0) + throw new TypeError('Image has invalid dimensions') + + const side = Math.min(width, height) + const square = image.crop({ + height: side, + width: side, + x: Math.floor((width - side) / 2), + y: Math.floor((height - side) / 2), + }) + const png = square + .resize({ + height: FOLDER_ICON_SIZE, + quality: 'best', + width: FOLDER_ICON_SIZE, + }) + .toPNG() + + if (png.length === 0) + throw new TypeError('Image could not be encoded') + + return png +} + +async function replaceFolderIconFile(tempPath: string, iconPath: string) { + try { + await fs.rename(tempPath, iconPath) + } + catch (error) { + const code = (error as NodeJS.ErrnoException).code + if ( + (code !== 'EEXIST' && code !== 'EPERM') + || !(await fs.pathExists(iconPath)) + ) { + throw error + } + + // Windows can reject rename-over-existing. Keep the normal path atomic + // and use a short remove+rename fallback only on that platform behavior. + await fs.remove(iconPath) + await fs.rename(tempPath, iconPath) + } +} + +async function writeFolderIconUnlocked(payload: FolderIconWritePayload) { + const context = getSpaceFoldersAndRoot(payload.spaceId) + const folder = context.folders.find(item => item.id === payload.folderId) + if (!folder) + throw new TypeError('Folder was not found') + const previousIconValue = folder.icon + + const iconPath = resolveFolderIconPath(payload.spaceId, payload.folderId) + if (!iconPath) + throw new TypeError('Folder was not found') + + const png = createFolderIconPng(Buffer.from(payload.buffer)) + const iconValue = `custom:${createHash('sha256').update(png).digest('hex').slice(0, 16)}` + const tempPath = `${iconPath}.${randomBytes(6).toString('hex')}.tmp` + let previousPng: Buffer | null = null + let metadataUpdateAttempted = false + const previousAvailability = getFileAvailability(iconPath) + + if (previousAvailability.isCloudPlaceholder) { + prioritizeCloudDownload(iconPath) + throw new TypeError('Folder icon is temporarily unavailable') + } + + if (previousAvailability.exists) { + const stats = await fs.lstat(iconPath) + if (!stats.isFile() || stats.isSymbolicLink()) + throw new TypeError('Folder icon path is not a regular file') + + previousPng = await fs.readFile(iconPath) + } + + try { + await fs.writeFile(tempPath, png, { flag: 'wx' }) + await replaceFolderIconFile(tempPath, iconPath) + rememberAppFileChange(iconPath) + + metadataUpdateAttempted = true + const result = context.updateIcon(payload.folderId, iconValue) + if (result.invalidInput || result.notFound) + throw new TypeError('Folder icon metadata could not be updated') + } + catch (error) { + if (metadataUpdateAttempted) { + try { + context.updateIcon(payload.folderId, previousIconValue) + } + catch { + // Preserve the original failure; storage sync can reconcile metadata. + } + } + + if (previousPng) { + await fs.writeFile(iconPath, previousPng) + rememberAppFileChange(iconPath) + } + else { + await fs.remove(iconPath) + rememberAppFileChange(iconPath) + } + throw error + } + finally { + await fs.remove(tempPath).catch(() => undefined) + rememberAppFileChange(tempPath) + } + + return iconValue +} + +async function removeFolderIconFile(target: FolderIconTarget): Promise { + const iconPath = resolveFolderIconPath(target.spaceId, target.folderId) + if (iconPath) { + await fs.remove(iconPath) + rememberAppFileChange(iconPath) + } +} + +export function writeFolderIcon(payload: FolderIconWritePayload) { + return runFolderIconMutation(payload, () => writeFolderIconUnlocked(payload)) +} + +export function setFolderIcon(payload: FolderIconSetPayload) { + return runFolderIconMutation(payload, async () => { + const context = getSpaceFoldersAndRoot(payload.spaceId) + const folder = context.folders.find(item => item.id === payload.folderId) + if (!folder) + throw new TypeError('Folder was not found') + + const previousIcon = folder.icon + const result = context.updateIcon(payload.folderId, payload.icon) + if (result.invalidInput || result.notFound) + throw new TypeError('Folder icon metadata could not be updated') + + if (previousIcon?.startsWith('custom:')) { + try { + await removeFolderIconFile(payload) + } + catch (error) { + try { + context.updateIcon(payload.folderId, previousIcon) + } + catch { + // Preserve the cleanup failure; storage sync can reconcile metadata. + } + throw error + } + } + }) +} + +export async function resolveFolderIconResponse( + spaceId: string, + rawFolderId: string, +): Promise { + if (!/^[1-9]\d*$/.test(rawFolderId)) + return new Response('Not found', { status: 404 }) + + const target = parseFolderIconTarget({ + folderId: Number(rawFolderId), + spaceId, + }) + const iconPath + = target && resolveFolderIconPath(target.spaceId, target.folderId) + + if (!iconPath) + return new Response('Not found', { status: 404 }) + + try { + const stats = await fs.lstat(iconPath) + if (!stats.isFile() || stats.isSymbolicLink()) + return new Response('Not found', { status: 404 }) + + const availability = getFileAvailability(iconPath) + if (!availability.exists || !availability.stats?.isFile()) + return new Response('Not found', { status: 404 }) + + if (availability.isCloudPlaceholder) { + prioritizeCloudDownload(iconPath) + return new Response('Temporarily unavailable', { + headers: { + 'Cache-Control': 'no-store', + 'Retry-After': '1', + 'X-Content-Type-Options': 'nosniff', + }, + status: 503, + }) + } + + const icon = await fs.readFile(iconPath) + return new Response(icon, { + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'image/png', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } + catch { + return new Response('Not found', { status: 404 }) + } +} diff --git a/src/main/i18n/locales/cs_CZ/preferences.json b/src/main/i18n/locales/cs_CZ/preferences.json index c39de89b7..d355a1ed4 100644 --- a/src/main/i18n/locales/cs_CZ/preferences.json +++ b/src/main/i18n/locales/cs_CZ/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderer bloků kódu" }, "api": { - "label": "API Port", + "label": "API", "port": { "label": "API Port", "description": "Číslo portu pro API server (vyžaduje restart aplikace). Platný rozsah: 1024-65535." diff --git a/src/main/i18n/locales/de_DE/preferences.json b/src/main/i18n/locales/de_DE/preferences.json index f35481272..dca5e46c3 100644 --- a/src/main/i18n/locales/de_DE/preferences.json +++ b/src/main/i18n/locales/de_DE/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Code-Block Renderer" }, "api": { - "label": "API-Port", + "label": "API", "port": { "label": "API-Port", "description": "Portnummer für den API-Server (erfordert Neustart der App). Gültiger Bereich: 1024-65535." diff --git a/src/main/i18n/locales/el_GR/preferences.json b/src/main/i18n/locales/el_GR/preferences.json index 35034f287..a097c509c 100644 --- a/src/main/i18n/locales/el_GR/preferences.json +++ b/src/main/i18n/locales/el_GR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Code block Renderer" }, "api": { - "label": "Θύρα API", + "label": "API", "port": { "label": "Θύρα API", "description": "Αριθμός θύρας για τον διακομιστή API (απαιτείται επανεκκίνηση της εφαρμογής). Έγκυρο εύρος: 1024-65535." diff --git a/src/main/i18n/locales/en_US/devtools.json b/src/main/i18n/locales/en_US/devtools.json index 7083182f3..ecd4691fe 100644 --- a/src/main/i18n/locales/en_US/devtools.json +++ b/src/main/i18n/locales/en_US/devtools.json @@ -115,6 +115,10 @@ "asciiToText": "ASCII → Text" } }, + "lineBreakNormalizer": { + "label": "Line Break Normalizer", + "description": "Remove hard line wraps and normalize pasted terminal text" + }, "base64": { "label": "Base64 Encoder / Decoder", "description": "Encode or decode text to Base64", diff --git a/src/main/i18n/locales/en_US/menu.json b/src/main/i18n/locales/en_US/menu.json index 69e24d76b..8c7546457 100644 --- a/src/main/i18n/locales/en_US/menu.json +++ b/src/main/i18n/locales/en_US/menu.json @@ -47,12 +47,18 @@ "editorOnly": "Editor Only" }, "sortBy": { - "label": "Sort Snippets By", + "label": "Sort By", "dateModified": "Date Modified", "dateCreated": "Date Created", "name": "Name" }, - "compactMode": "Compact List" + "sortOrder": { + "label": "Sort Order", + "ascending": "Ascending", + "descending": "Descending" + }, + "compactMode": "Compact List", + "hideCompletedTasks": "Hide Completed Tasks" }, "edit": { "label": "Edit", @@ -63,6 +69,7 @@ "copySnippet": "Copy Snippet", "copyNote": "Copy Note", "format": "Format", + "normalizeTerminalOutput": "Normalize Line Breaks", "mode": "Mode", "modeRaw": "Raw", "modeLivePreview": "Live Preview", diff --git a/src/main/i18n/locales/en_US/messages.json b/src/main/i18n/locales/en_US/messages.json index fd2d36072..2edf0ced8 100644 --- a/src/main/i18n/locales/en_US/messages.json +++ b/src/main/i18n/locales/en_US/messages.json @@ -6,6 +6,7 @@ "deletePermanently": "Are you sure you want to permanently delete \"{{name}}\"?", "deleteConfirmMultipleSnippets": "Are you sure you want to permanently delete {{count}} selected snippets?", "emptyTrash": "Are you sure you want to permanently delete all snippets in Trash?", + "convertTaskToNote": "Convert \"{{name}}\" back to a note?", "moveVaultOverwrite": [ "Overwrite selected directory?", "The selected directory is not empty. Its contents will be overwritten by the current vault." @@ -13,24 +14,36 @@ "migrateToMarkdown": [ "Migrate to Markdown Vault?", "The selected vault will be overwritten with the current SQLite library." + ], + "vaultDoctorApply": [ + "Apply vault fixes?", + "massCode will update safe metadata fixes and selected duplicate-id decisions. Unselected conflicts will be skipped." ] }, "success": { "copied": "Copied to clipboard", "migrateToMarkdown": "Migrated to Markdown Vault. Folders: {{folders}}, snippets: {{snippets}}, tags: {{tags}}.", "vaultMoved": "Vault successfully moved.", - "vaultLoaded": "Vault successfully loaded." + "vaultLoaded": "Vault successfully loaded.", + "licenseActivated": "License activated. Thank you for supporting massCode!", + "vaultDoctorApplied": "Vault Doctor applied {{count}} fixes.", + "vaultDoctorClean": "Vault Doctor found no issues." }, "warning": { "noUndo": "You cannot undo this action.", "allSnippetsMoveToTrash": "All snippets in this folder will be moved to trash.", + "taskPropertiesRemoved": "Task status, priority, and due date will be removed.", "deleteTag": "This will also cause all snippets to have that tag removed.", "createDb": "Please select another folder", "htmlCssPreview": "Add HTML fragment to see the result. Add CSS for styling and JavaScript for interactivity.", "codeBlockRenderer": [ "When using Codemirror, the language to be set for the code block must correspond to one of the values of the", "languages" - ] + ], + "vaultDoctorConflicts": "Vault: {{count}} sync conflict(s) need attention", + "vaultDoctorReview": "Review", + "vaultDoctorNotReady": "Vault is still syncing with cloud storage. Try scanning again in a moment.", + "cloudFileNotReady": "This item is still syncing from cloud storage. Try again in a moment." }, "error": { "migration": "Auto-migration from SQLite failed: {{error}}", @@ -42,7 +55,9 @@ "entryNameNoteConflict": "A note with this name already exists in this folder.", "entryNameSnippetConflict": "A snippet with this name already exists in this folder.", "entryNameRequestConflict": "A request with this name already exists in this folder.", - "entryNameFolderConflict": "A folder with this name already exists at this level." + "entryNameFolderConflict": "A folder with this name already exists at this level.", + "vaultPathRequired": "Vault path is required.", + "licenseInvalid": "Invalid license key" }, "description": { "storageVault": "Choose the vault directory. To sync between devices, select a folder in iCloud Drive, Google Drive or Dropbox.", @@ -59,13 +74,15 @@ "http": "{{count}} HTTP items copied 🚀", "notes": "{{count}} notes copied 🚀", "math": "{{count}} math results copied 🚀", - "tools": "{{count}} tool outputs copied 🚀" + "tools": "{{count}} tool outputs copied 🚀", + "drawings": "{{count}} drawing items copied 🚀" }, "created": { "code": "{{count}} snippets created 🎉", "http": "{{count}} HTTP requests created 🎉", "notes": "{{count}} notes created 🎉", - "math": "{{count}} sheets created 🎉" + "math": "{{count}} sheets created 🎉", + "drawings": "{{count}} drawings created 🎉" }, "sent": { "http": "{{count}} HTTP requests sent 🚀" @@ -92,6 +109,13 @@ "release": {}, "update": { "available": "Version {{newVersion}} is now available for download.\nYour version is {{oldVersion}}.", - "noAvailable": "There are currently no updates available." + "noAvailable": "There are currently no updates available.", + "downloading": "Update v{{version}} is downloading in the background. You will be prompted to restart when it is ready.", + "availableToast": "Update available", + "goToGitHub": "Go to GitHub", + "downloaded": "Update v{{version}} is ready. Restart massCode to apply it.", + "restart": "Restart", + "whatsNewToast": "See what's new in massCode v{{version}}", + "releaseNotes": "Release notes" } } diff --git a/src/main/i18n/locales/en_US/preferences.json b/src/main/i18n/locales/en_US/preferences.json index 5818b1849..8242e8cc7 100644 --- a/src/main/i18n/locales/en_US/preferences.json +++ b/src/main/i18n/locales/en_US/preferences.json @@ -10,7 +10,62 @@ "moveVault": "Move Vault", "movingVault": "Moving vault...", "count": "Count", - "vaultPath": "Vault Path" + "vaultPath": "Vault Path", + "vaultDoctor": { + "label": "Vault Doctor", + "description": "Scan the vault for metadata issues, sync conflicts, and safe repairs.", + "scan": { + "label": "Check vault", + "action": "Scan vault", + "scanning": "Scanning..." + }, + "apply": { + "action": "Apply fixes", + "applying": "Applying..." + }, + "summary": { + "safe": "Safe fixes", + "conflicts": "Conflicts", + "blocked": "Blocked", + "warnings": "Warnings" + }, + "conflicts": "Needs decision", + "decisions": { + "duplicateId": "Duplicate id: {{id}}", + "files": "Files: {{count}}", + "progress": "{{selected}}/{{total}} resolved", + "choose": "Choose one file to keep this id. The other {{count}} will get new unique ids.", + "original": "Original", + "copy": "Copy", + "keepsId": "keeps id {{id}}", + "newId": "new id" + }, + "reason": { + "merge-markers": "Merge markers", + "invalid-frontmatter": "Invalid frontmatter", + "conflicted-copy": "Conflicted copy" + }, + "assetWarnings": { + "NOTES_LEGACY_ASSET": { + "message": "{{assetName}} is still stored in the legacy Notes assets folder.", + "recommendation": "Keep the whole vault synchronized and scan again after migration completes." + }, + "NOTES_ASSET_MIGRATION_PENDING": { + "message": "{{assetName}} is not available locally for migration inspection.", + "recommendation": "Make the asset available locally, then scan the vault again." + }, + "NOTES_ASSET_DESTINATION_CONFLICT": { + "message": "{{assetName}} differs between the legacy and current Notes assets folders.", + "recommendation": "Compare both files and keep the correct copy before removing either one." + }, + "NOTES_ASSET_MISSING": { + "message": "{{assetName}} is missing from both Notes assets folders.", + "recommendation": "Restore the asset to the vault or remove its reference from the note." + } + }, + "moreWarnings": "+{{count}} more warnings", + "warnings": "Warnings" + } }, "editor": { "label": "Code Editor", @@ -62,7 +117,7 @@ } }, "notesEditor": { - "label": "Note Editor", + "label": "Notes", "fontSize": { "label": "Font Size", "description": "Base font size in pixels for the notes editor." @@ -90,6 +145,10 @@ "label": "Limit Width", "description": "Maximum content width for comfortable reading (700px)." }, + "wrapTables": { + "label": "Wrap Table Cells", + "description": "Wrap text in table cells to keep wide tables within the editor. Narrow tables keep their natural width." + }, "lineNumbers": { "label": "Line Numbers", "description": "Line numbers in the gutter. Only available in Raw mode." @@ -97,6 +156,14 @@ }, "appearance": { "label": "Appearance", + "dockBadge": { + "label": "Dock Icon Counter", + "none": "Don't Show", + "codeInbox": "Code Inbox", + "notesInbox": "Notes Inbox", + "tasksDue": "Tasks Due Today", + "permissionDenied": "macOS blocked the Dock counter. Allow notifications and badges for massCode in System Settings. Unsigned development builds may not support Dock badges." + }, "theme": { "label": "Theme", "builtIn": "Built-in", @@ -125,7 +192,7 @@ }, "decimalPlaces": { "label": "Decimal Places", - "description": "Maximum decimal places in results (0\u201314)." + "description": "Maximum decimal places in results (0–14)." }, "dateFormat": { "label": "Date Format", @@ -148,6 +215,22 @@ "rateLimited": "Rate limit exceeded. Try again later." } }, + "tasks": { + "label": "Tasks", + "autoCleanup": { + "label": "Auto-clean completed", + "description": "Move completed tasks to trash on a schedule. They stay in trash until you empty it.", + "never": "Never", + "1d": "Every day", + "7d": "Every 7 days", + "30d": "Every 30 days" + }, + "cleanupNow": { + "label": "Clean up now", + "button": "Move completed to trash", + "description": "Move all completed tasks to trash right now." + } + }, "http": { "label": "HTTP Client", "wrapLines": { @@ -163,13 +246,61 @@ "autoSwitchToResponse": { "label": "Show Response After Send", "description": "Switch the bottom panel to Response when a request starts or finishes." + }, + "sslCertificateVerification": { + "label": "SSL Certificate Verification", + "description": "Verify SSL certificates when sending HTTPS requests. Turn off to allow invalid or self-signed certificates." } }, "api": { - "label": "API Port", + "label": "API", "port": { "label": "API Port", "description": "Port number for the API server (requires app restart to take effect). Valid range: 1024-65535." + }, + "integrations": { + "label": "Integrations", + "enabled": { + "label": "Enable API integrations", + "description": "Allow external local clients to use protected integration endpoints." + }, + "token": { + "label": "API token", + "description": "Use this token in external clients. The full token is shown only after generation.", + "empty": "No token generated", + "generate": "Generate token", + "revoke": "Revoke token" + } + } + }, + "supporter": { + "label": "Supporter", + "status": { + "label": "Status", + "active": "Supporter license is active.", + "activeFor": "Supporter license is active for {{name}}.", + "description": "Thank you for supporting massCode! Donation prompts are turned off." + }, + "key": { + "label": "License key", + "placeholder": "Paste your supporter key", + "activate": "Activate", + "description": "Paste the key you received after donating. Activation works offline and removes donation prompts." + }, + "request": { + "label": "No key yet?", + "action": "Request a key", + "description": "If you have ever donated to massCode through any channel, send any proof of donation by email and you will receive a key." + } + }, + "updates": { + "label": "Updates", + "autoUpdate": { + "label": "Automatic updates", + "description": "Download updates in the background and install them on restart. When disabled, massCode only notifies you about new versions. Major versions are never installed automatically." + }, + "version": { + "label": "Current version" } } } diff --git a/src/main/i18n/locales/en_US/ui.json b/src/main/i18n/locales/en_US/ui.json index 985a8fa7e..aa8b419ad 100644 --- a/src/main/i18n/locales/en_US/ui.json +++ b/src/main/i18n/locales/en_US/ui.json @@ -16,12 +16,14 @@ "commandPlaceholder": "Run command...", "scopedPlaceholder": "Search in {{space}}...", "clearScope": "Clear scope", + "clearFilter": "Clear filter: {{filter}}", "searching": "Searching...", "empty": "No results found", "groups": { "recent": "Recent", "actions": "Actions", "spaces": "Spaces", + "filters": "Filters", "results": "Results", "snippets": "Snippets", "notes": "Notes", @@ -50,14 +52,18 @@ "openPreferencesSubtitle": "Change massCode settings" }, "fallbacks": { - "createSnippet": "Create snippet \"{{query}}\"", + "createSnippet": "Create snippet \"{{- query}}\"", "createSnippetSubtitle": "Create in Code Inbox", - "createNote": "Create note \"{{query}}\"", + "createNote": "Create note \"{{- query}}\"", "createNoteSubtitle": "Create in Notes Inbox", - "createHttpRequest": "Create HTTP request \"{{query}}\"", + "createHttpRequest": "Create HTTP request \"{{- query}}\"", "createHttpRequestFromUrl": "Create HTTP request from URL", "createHttpRequestSubtitle": "Create in HTTP" }, + "suggestions": { + "tagSubtitle": "Filter {{space}} by tag", + "folderSubtitle": "Filter {{space}} by folder" + }, "actionPanel": { "heading": "Actions", "open": "Open", @@ -70,6 +76,7 @@ "open": "Open", "jump": "Jump", "actions": "Actions", + "apply": "Apply", "run": "Run", "back": "Back" } @@ -183,8 +190,26 @@ "title": "Math Notebook", "sheetList": "Sheet List", "newSheet": "New Sheet", + "searchPlaceholder": "Search sheets…", + "noResults": "No sheets found", "untitled": "Untitled", - "currencyUnavailable": "Currency rates service unavailable" + "currencyUnavailable": "Currency rates service unavailable", + "cloudSyncing": "Syncing from cloud storage…" + }, + "drawings": { + "label": "Drawings", + "tooltip": "Excalidraw drawings", + "title": "Drawings", + "drawingList": "Drawing List", + "newDrawing": "New Drawing", + "searchPlaceholder": "Search drawings…", + "noResults": "No drawings found", + "fitToContent": "Fit to content", + "exportImage": "Export image…", + "copyLinkForNote": "Copy Link for Note", + "openInSpace": "Open in Drawings", + "notFound": "Drawing not found", + "noSelected": "No drawing selected" }, "http": { "label": "HTTP", @@ -333,6 +358,82 @@ "symbols": "Symbols", "selectedMultiple": "{{count}} Notes Selected", "noSelected": "No Note Selected", + "editor": { + "menu": { + "format": { + "title": "Format", + "bold": "Bold", + "italic": "Italic", + "strikethrough": "Strikethrough", + "highlight": "Highlight", + "code": "Code", + "link": "Link", + "clearFormatting": "Clear formatting" + }, + "paragraph": { + "title": "Paragraph", + "bulletList": "Bullet list", + "numberedList": "Numbered list", + "taskList": "Task list", + "heading": "Heading {{level}}", + "body": "Body", + "quote": "Quote" + }, + "insert": { + "title": "Insert", + "table": "Table", + "callout": "Callout", + "horizontalRule": "Horizontal rule", + "codeBlock": "Code block" + }, + "table": { + "title": "Table", + "insertRowAbove": "Insert row above", + "insertRowBelow": "Insert row below", + "deleteRow": "Delete row", + "insertColumnLeft": "Insert column left", + "insertColumnRight": "Insert column right", + "deleteColumn": "Delete column", + "alignLeft": "Align left", + "alignCenter": "Align center", + "alignRight": "Align right" + } + }, + "table": { + "addColumn": "Add column to the right", + "addRow": "Add row below" + } + }, + "tasks": { + "title": "Tasks", + "today": "Today", + "upcoming": "Upcoming", + "completed": "Completed", + "convertToTask": "Convert to task", + "convertToNote": "Convert to note", + "duePlaceholder": "Due", + "clearDue": "Clear due date", + "noDue": "No due", + "cleanupCompleted": "Clean up completed tasks", + "cleanupConfirmTitle": "Clean up completed tasks?", + "cleanupConfirmMessage": "All completed tasks will be moved to trash. You can restore them until you empty the trash.", + "cleanupDone": "Moved {{count}} completed task(s) to trash", + "cleanupEmpty": "No completed tasks to clean up", + "cleanupError": "Failed to clean up completed tasks", + "status": { + "todo": "Todo", + "inProgress": "In progress", + "done": "Done", + "blocked": "Blocked" + }, + "priority": { + "label": "Priority", + "none": "No priority", + "low": "Low", + "medium": "Medium", + "high": "High" + } + }, "dashboard": { "label": "Dashboard", "title": "Notes Dashboard", @@ -397,7 +498,37 @@ "untitled": "Untitled folder", "iconPicker": { "searchPlaceholder": "Search icons...", + "emojiSearchPlaceholder": "Search emoji...", + "iconSearchPlaceholder": "Search icons...", "emptyResults": "No icons found", + "emptyEmojiResults": "No emoji found", + "uploadImage": "Upload JPG or PNG", + "replaceImage": "Choose another", + "applyImage": "Use image", + "uploading": "Processing image...", + "uploadTitle": "Add a custom image", + "uploadHint": "Drop a JPG or PNG here, or choose a file. Maximum size: 10 MB.", + "tabs": { + "emoji": "Emoji", + "icons": "Icons", + "upload": "Upload" + }, + "emojiCategories": { + "people": "People", + "nature": "Nature", + "food": "Food & Drink", + "activities": "Activities", + "travel": "Travel & Places", + "objects": "Objects", + "symbols": "Symbols", + "flags": "Flags" + }, + "errors": { + "unsupportedFormat": "Choose a JPG or PNG image.", + "fileTooLarge": "The image must be 10 MB or smaller.", + "processingFailed": "The image could not be processed.", + "updateFailed": "The folder icon could not be updated." + }, "filters": { "all": "All", "material": "Material", @@ -411,6 +542,7 @@ "confirm": "Confirm", "copy": "Copy", "fit": "Fit", + "generate": "Generate", "saveAs": "Save as", "update": [ "Go to GitHub", @@ -431,6 +563,7 @@ }, "action": { "close": "Close", + "clearSearch": "Clear Search", "defaultLanguage": "Default Language", "duplicate": "Duplicate", "rename": "Rename", @@ -448,8 +581,10 @@ "folder": "New Folder", "snippet": "New Snippet", "fragment": "New Fragment", - "note": "New Note" + "note": "New Note", + "task": "New Task" }, + "createOptions": "Create options", "add": { "description": "Add Description", "tag": "Add Tag", @@ -522,8 +657,16 @@ "emptyNotesList": "No Notes", "searchNotes": "Search notes...", "emptySheetList": "No Sheets", + "emptyDrawingsList": "No Drawings", "search": "Search", + "searchIn": "Search in {{- context}}", "addTag": "Add Tag", "selectLanguage": "Select Language" + }, + "cloudDownloads": { + "downloading": "Downloading files from cloud storage: {{count}} left", + "failed": "Failed to download files from cloud storage: {{count}}", + "itemPending": "Downloading from cloud storage…", + "label": "Cloud downloads" } } diff --git a/src/main/i18n/locales/es_ES/preferences.json b/src/main/i18n/locales/es_ES/preferences.json index c34620405..f2d7d6baf 100644 --- a/src/main/i18n/locales/es_ES/preferences.json +++ b/src/main/i18n/locales/es_ES/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderizador de Bloques de Código" }, "api": { - "label": "Puerto API", + "label": "API", "port": { "label": "Puerto API", "description": "Número de puerto para el servidor API (requiere reiniciar la aplicación). Rango válido: 1024-65535." diff --git a/src/main/i18n/locales/fa_IR/preferences.json b/src/main/i18n/locales/fa_IR/preferences.json index b42ea449b..aa1db52a5 100644 --- a/src/main/i18n/locales/fa_IR/preferences.json +++ b/src/main/i18n/locales/fa_IR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "رندرکننده بلوک کد" }, "api": { - "label": "پورت API", + "label": "API", "port": { "label": "پورت API", "description": "شماره پورت برای سرور API (نیاز به راه‌اندازی مجدد برنامه دارد). محدوده معتبر: 1024-65535." diff --git a/src/main/i18n/locales/fr_FR/preferences.json b/src/main/i18n/locales/fr_FR/preferences.json index 0b8b58dd2..8e24904e3 100644 --- a/src/main/i18n/locales/fr_FR/preferences.json +++ b/src/main/i18n/locales/fr_FR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Rendu des blocs de code" }, "api": { - "label": "Port API", + "label": "API", "port": { "label": "Port API", "description": "Numéro de port pour le serveur API (nécessite un redémarrage de l'application). Plage valide: 1024-65535." diff --git a/src/main/i18n/locales/ja_JP/preferences.json b/src/main/i18n/locales/ja_JP/preferences.json index 6a68f11df..646a16904 100644 --- a/src/main/i18n/locales/ja_JP/preferences.json +++ b/src/main/i18n/locales/ja_JP/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "コードブロックレンダラー" }, "api": { - "label": "APIポート", + "label": "API", "port": { "label": "APIポート", "description": "APIサーバーのポート番号(アプリの再起動が必要です)。有効範囲:1024-65535。" diff --git a/src/main/i18n/locales/pl_PL/preferences.json b/src/main/i18n/locales/pl_PL/preferences.json index 81f479bf8..fb2776c29 100644 --- a/src/main/i18n/locales/pl_PL/preferences.json +++ b/src/main/i18n/locales/pl_PL/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderer bloków kodu" }, "api": { - "label": "Port API", + "label": "API", "port": { "label": "Port API", "description": "Numer portu dla serwera API (wymaga ponownego uruchomienia aplikacji). Prawidłowy zakres: 1024-65535." diff --git a/src/main/i18n/locales/pt_BR/preferences.json b/src/main/i18n/locales/pt_BR/preferences.json index 440693229..e2cf6ee4a 100644 --- a/src/main/i18n/locales/pt_BR/preferences.json +++ b/src/main/i18n/locales/pt_BR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderizador de Bloco de Código" }, "api": { - "label": "Porta API", + "label": "API", "port": { "label": "Porta API", "description": "Número da porta para o servidor API (requer reinicialização do aplicativo). Faixa válida: 1024-65535." diff --git a/src/main/i18n/locales/ro_RO/preferences.json b/src/main/i18n/locales/ro_RO/preferences.json index f33e4fb77..d1a028d6c 100644 --- a/src/main/i18n/locales/ro_RO/preferences.json +++ b/src/main/i18n/locales/ro_RO/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderer Bloc de Cod" }, "api": { - "label": "Port API", + "label": "API", "port": { "label": "Port API", "description": "Numărul portului pentru serverul API (necesită repornirea aplicației). Interval valid: 1024-65535." diff --git a/src/main/i18n/locales/ru_RU/devtools.json b/src/main/i18n/locales/ru_RU/devtools.json index 9e8d5e84f..d87b6d286 100644 --- a/src/main/i18n/locales/ru_RU/devtools.json +++ b/src/main/i18n/locales/ru_RU/devtools.json @@ -115,6 +115,10 @@ "asciiToText": "ASCII → Текст" } }, + "lineBreakNormalizer": { + "label": "Нормализатор переносов строк", + "description": "Удаление жёстких переносов строк и нормализация текста из терминала" + }, "base64": { "label": "Base64 Encoder / Decoder", "description": "Кодирование или декодирование текста в Base64", diff --git a/src/main/i18n/locales/ru_RU/menu.json b/src/main/i18n/locales/ru_RU/menu.json index 563cd5eba..37dcdf2f9 100644 --- a/src/main/i18n/locales/ru_RU/menu.json +++ b/src/main/i18n/locales/ru_RU/menu.json @@ -47,12 +47,18 @@ "editorOnly": "Только редактор" }, "sortBy": { - "label": "Сортировать сниппеты по", + "label": "Сортировать по", "dateModified": "Дате изменения", "dateCreated": "Дате создания", "name": "Имени" }, - "compactMode": "Компактный список" + "sortOrder": { + "label": "Порядок сортировки", + "ascending": "По возрастанию", + "descending": "По убыванию" + }, + "compactMode": "Компактный список", + "hideCompletedTasks": "Скрывать завершённые задачи" }, "edit": { "label": "Правка", @@ -63,6 +69,7 @@ "copySnippet": "Копировать сниппет", "copyNote": "Копировать заметку", "format": "Форматировать", + "normalizeTerminalOutput": "Нормализовать переносы строк", "mode": "Режим", "modeRaw": "Исходный", "modeLivePreview": "Живой предпросмотр", diff --git a/src/main/i18n/locales/ru_RU/messages.json b/src/main/i18n/locales/ru_RU/messages.json index 585389a62..0d016f1de 100644 --- a/src/main/i18n/locales/ru_RU/messages.json +++ b/src/main/i18n/locales/ru_RU/messages.json @@ -6,26 +6,39 @@ "deletePermanently": "Вы уверены, что хотите навсегда удалить \"{{name}}\"?", "deleteConfirmMultipleSnippets": "Вы уверены, что хотите навсегда удалить {{count}} выбранных сниппетов?", "emptyTrash": "Вы уверены, что хотите навсегда удалить все сниппеты из Корзины?", + "convertTaskToNote": "Преобразовать \"{{name}}\" обратно в заметку?", "migrateToMarkdown": [ "Мигрировать в Markdown Vault?", "Выбранный vault будет перезаписан текущей библиотекой SQLite." + ], + "vaultDoctorApply": [ + "Применить исправления vault?", + "massCode обновит безопасные metadata-исправления и выбранные решения по duplicate-id. Невыбранные конфликты будут пропущены." ] }, "success": { "copied": "Скопировано в буфер обмена", "migrateToMarkdown": "Выполнена миграция в Markdown Vault. Папок: {{folders}}, сниппетов: {{snippets}}, тегов: {{tags}}.", - "vaultLoaded": "Хранилище успешно загружено." + "vaultLoaded": "Хранилище успешно загружено.", + "licenseActivated": "Лицензия активирована. Спасибо за поддержку massCode!", + "vaultDoctorApplied": "Vault Doctor применил {{count}} исправлений.", + "vaultDoctorClean": "Vault Doctor не нашёл проблем." }, "warning": { "noUndo": "Это действие нельзя отменить.", "allSnippetsMoveToTrash": "Все сниппеты в этой папке будут перемещены в корзину.", + "taskPropertiesRemoved": "Статус задачи, приоритет и срок будут удалены.", "deleteTag": "Это также приведет к удалению этого тега со всех сниппетов.", "createDb": "Пожалуйста, выберите другую папку", "htmlCssPreview": "Добавьте HTML-фрагмент, чтобы увидеть результат. Добавьте CSS для стилизации и JavaScript для интерактивности.", "codeBlockRenderer": [ "При использовании Codemirror язык, который будет установлен для блока кода, должен соответствовать одному из значений", "languages" - ] + ], + "vaultDoctorConflicts": "Vault: {{count}} конфликтов требуют внимания", + "vaultDoctorReview": "Разобрать", + "vaultDoctorNotReady": "Vault ещё синхронизируется с облачным хранилищем. Повторите сканирование чуть позже.", + "cloudFileNotReady": "Элемент ещё синхронизируется с облачным хранилищем. Попробуйте чуть позже." }, "error": { "migration": "Автомиграция из SQLite завершилась ошибкой: {{error}}", @@ -37,7 +50,9 @@ "entryNameNoteConflict": "В этой папке уже есть заметка с таким именем.", "entryNameSnippetConflict": "В этой папке уже есть сниппет с таким именем.", "entryNameRequestConflict": "В этой папке уже есть запрос с таким именем.", - "entryNameFolderConflict": "Папка с таким именем уже существует на этом уровне." + "entryNameFolderConflict": "Папка с таким именем уже существует на этом уровне.", + "vaultPathRequired": "Укажите путь к хранилищу.", + "licenseInvalid": "Недействительный лицензионный ключ" }, "description": { "storageVault": "Выберите директорию для хранилища. Для синхронизации между устройствами выберите папку в iCloud Drive, Google Drive или Dropbox.", @@ -54,13 +69,15 @@ "http": "Скопировано {{count}} HTTP-элементов 🚀", "notes": "Скопировано {{count}} заметок 🚀", "math": "Скопировано {{count}} вычислений 🚀", - "tools": "Скопировано {{count}} результатов из инструментов 🚀" + "tools": "Скопировано {{count}} результатов из инструментов 🚀", + "drawings": "Скопировано {{count}} элементов рисунков 🚀" }, "created": { "code": "Создано {{count}} сниппетов 🎉", "http": "Создано {{count}} HTTP-запросов 🎉", "notes": "Создано {{count}} заметок 🎉", - "math": "Создано {{count}} листов 🎉" + "math": "Создано {{count}} листов 🎉", + "drawings": "Создано {{count}} рисунков 🎉" }, "sent": { "http": "Отправлено {{count}} HTTP-запросов 🚀" @@ -87,6 +104,13 @@ "release": {}, "update": { "available": "Версия {{newVersion}} доступна для загрузки.\nВаша версия {{oldVersion}}.", - "noAvailable": "В настоящее время обновления недоступны." + "noAvailable": "В настоящее время обновления недоступны.", + "downloading": "Обновление v{{version}} скачивается в фоне. Когда оно будет готово, появится предложение перезапустить приложение.", + "availableToast": "Доступно обновление", + "goToGitHub": "Открыть GitHub", + "downloaded": "Обновление v{{version}} готово. Перезапустите massCode, чтобы применить его.", + "restart": "Перезапустить", + "whatsNewToast": "Узнайте, что нового в massCode v{{version}}", + "releaseNotes": "Release notes" } } diff --git a/src/main/i18n/locales/ru_RU/preferences.json b/src/main/i18n/locales/ru_RU/preferences.json index b3a743575..7878cb887 100644 --- a/src/main/i18n/locales/ru_RU/preferences.json +++ b/src/main/i18n/locales/ru_RU/preferences.json @@ -8,7 +8,62 @@ "label": "Хранилище", "migrateSqliteToMarkdown": "Мигрировать в Markdown Vault", "count": "Количество", - "vaultPath": "Путь к vault" + "vaultPath": "Путь к vault", + "vaultDoctor": { + "label": "Vault Doctor", + "description": "Проверяет vault на проблемы metadata, конфликты синхронизации и безопасные исправления.", + "scan": { + "label": "Проверка vault", + "action": "Сканировать vault", + "scanning": "Сканирование..." + }, + "apply": { + "action": "Применить исправления", + "applying": "Применение..." + }, + "summary": { + "safe": "Безопасные исправления", + "conflicts": "Конфликты", + "blocked": "Заблокировано", + "warnings": "Предупреждения" + }, + "conflicts": "Требует решения", + "decisions": { + "duplicateId": "Одинаковый id: {{id}}", + "files": "Файлов: {{count}}", + "progress": "Решено {{selected}}/{{total}}", + "choose": "Выберите один файл, который сохранит этот id. Остальные {{count}} получат новые уникальные id.", + "original": "Оригинал", + "copy": "Копия", + "keepsId": "сохранит id {{id}}", + "newId": "новый id" + }, + "reason": { + "merge-markers": "Merge-маркеры", + "invalid-frontmatter": "Битый frontmatter", + "conflicted-copy": "Конфликтная копия" + }, + "assetWarnings": { + "NOTES_LEGACY_ASSET": { + "message": "{{assetName}} всё ещё хранится в старой папке изображений Notes.", + "recommendation": "Синхронизируйте vault целиком и повторите проверку после завершения миграции." + }, + "NOTES_ASSET_MIGRATION_PENDING": { + "message": "{{assetName}} недоступен локально для проверки миграции.", + "recommendation": "Сделайте файл доступным локально и повторно проверьте vault." + }, + "NOTES_ASSET_DESTINATION_CONFLICT": { + "message": "{{assetName}} отличается в старой и текущей папках изображений Notes.", + "recommendation": "Сравните оба файла и сохраните правильную копию, прежде чем удалять любой из них." + }, + "NOTES_ASSET_MISSING": { + "message": "{{assetName}} отсутствует в обеих папках изображений Notes.", + "recommendation": "Восстановите файл в vault или удалите ссылку на него из заметки." + } + }, + "moreWarnings": "+{{count}} предупреждений", + "warnings": "Предупреждения" + } }, "editor": { "label": "Редактор", @@ -60,7 +115,7 @@ } }, "notesEditor": { - "label": "Редактор заметок", + "label": "Заметки", "fontSize": { "label": "Размер шрифта", "description": "Базовый размер шрифта в пикселях для редактора заметок." @@ -88,6 +143,10 @@ "label": "Ограничить ширину", "description": "Ограничить контент комфортной шириной для чтения (700px)." }, + "wrapTables": { + "label": "Перенос текста в таблицах", + "description": "Переносить текст в ячейках, чтобы широкие таблицы помещались в редакторе. Узкие таблицы сохраняют естественную ширину." + }, "lineNumbers": { "label": "Номера строк", "description": "Показывать номера строк. Доступно только в режиме Raw." @@ -95,6 +154,14 @@ }, "appearance": { "label": "Внешний вид", + "dockBadge": { + "label": "Счётчик на иконке в Dock", + "none": "Не показывать", + "codeInbox": "Code Inbox", + "notesInbox": "Notes Inbox", + "tasksDue": "Задачи на сегодня", + "permissionDenied": "macOS заблокировала счётчик Dock. Разрешите уведомления и значки для massCode в системных настройках. Неподписанные dev-сборки могут не поддерживать значки Dock." + }, "theme": { "label": "Тема", "builtIn": "Встроенные", @@ -115,6 +182,22 @@ "label": "Markdown", "codeRenderer": "Рендер блоков кода" }, + "tasks": { + "label": "Задачи", + "autoCleanup": { + "label": "Автоочистка завершённых", + "description": "Периодически перемещать завершённые задачи в корзину. Они остаются в корзине, пока вы её не очистите.", + "never": "Никогда", + "1d": "Каждый день", + "7d": "Каждые 7 дней", + "30d": "Каждые 30 дней" + }, + "cleanupNow": { + "label": "Очистить сейчас", + "button": "Переместить завершённые в корзину", + "description": "Переместить все завершённые задачи в корзину прямо сейчас." + } + }, "http": { "label": "HTTP Client", "wrapLines": { @@ -130,13 +213,61 @@ "autoSwitchToResponse": { "label": "Показывать ответ после отправки", "description": "Переключать нижнюю панель на Response, когда запрос начинается или завершается." + }, + "sslCertificateVerification": { + "label": "Проверка SSL-сертификата", + "description": "Проверять SSL-сертификаты при отправке HTTPS-запросов. Отключите, чтобы разрешить недействительные или самоподписанные сертификаты." } }, "api": { - "label": "Порт API", + "label": "API", "port": { "label": "Порт API", "description": "Номер порта для API-сервера (требуется перезапуск приложения). Допустимый диапазон: 1024-65535." + }, + "integrations": { + "label": "Интеграции", + "enabled": { + "label": "Включить API-интеграции", + "description": "Разрешить внешним локальным клиентам использовать защищённые endpoints интеграций." + }, + "token": { + "label": "API token", + "description": "Используйте этот token во внешних клиентах. Полный token показывается только после генерации.", + "empty": "Token не создан", + "generate": "Создать token", + "revoke": "Отозвать token" + } + } + }, + "supporter": { + "label": "Саппортер", + "status": { + "label": "Статус", + "active": "Лицензия саппортера активна.", + "activeFor": "Лицензия саппортера активна для {{name}}.", + "description": "Спасибо за поддержку massCode! Напоминания о донатах отключены." + }, + "key": { + "label": "Лицензионный ключ", + "placeholder": "Вставьте ключ саппортера", + "activate": "Активировать", + "description": "Вставьте ключ, полученный после доната. Активация работает офлайн и отключает напоминания о донатах." + }, + "request": { + "label": "Нет ключа?", + "action": "Запросить ключ", + "description": "Если вы когда-либо донатили massCode любым способом, отправьте любое подтверждение доната по почте и получите ключ." + } + }, + "updates": { + "label": "Обновления", + "autoUpdate": { + "label": "Автоматические обновления", + "description": "Скачивать обновления в фоне и устанавливать при перезапуске. Если выключено, massCode только уведомляет о новых версиях. Новые мажорные версии никогда не устанавливаются автоматически." + }, + "version": { + "label": "Текущая версия" } } } diff --git a/src/main/i18n/locales/ru_RU/ui.json b/src/main/i18n/locales/ru_RU/ui.json index 5a53022af..cb4184b24 100644 --- a/src/main/i18n/locales/ru_RU/ui.json +++ b/src/main/i18n/locales/ru_RU/ui.json @@ -16,12 +16,14 @@ "commandPlaceholder": "Выполнить команду...", "scopedPlaceholder": "Искать в {{space}}...", "clearScope": "Сбросить область", + "clearFilter": "Сбросить фильтр: {{filter}}", "searching": "Поиск...", "empty": "Ничего не найдено", "groups": { "recent": "Недавние", "actions": "Действия", "spaces": "Пространства", + "filters": "Фильтры", "results": "Результаты", "snippets": "Сниппеты", "notes": "Заметки", @@ -50,14 +52,18 @@ "openPreferencesSubtitle": "Изменить настройки massCode" }, "fallbacks": { - "createSnippet": "Создать сниппет \"{{query}}\"", + "createSnippet": "Создать сниппет \"{{- query}}\"", "createSnippetSubtitle": "Создать во входящих раздела Код", - "createNote": "Создать заметку \"{{query}}\"", + "createNote": "Создать заметку \"{{- query}}\"", "createNoteSubtitle": "Создать во входящих раздела Заметки", - "createHttpRequest": "Создать HTTP-запрос \"{{query}}\"", + "createHttpRequest": "Создать HTTP-запрос \"{{- query}}\"", "createHttpRequestFromUrl": "Создать HTTP-запрос из URL", "createHttpRequestSubtitle": "Создать в HTTP" }, + "suggestions": { + "tagSubtitle": "Фильтровать {{space}} по тегу", + "folderSubtitle": "Фильтровать {{space}} по папке" + }, "actionPanel": { "heading": "Действия", "open": "Открыть", @@ -70,6 +76,7 @@ "open": "Открыть", "jump": "Перейти", "actions": "Действия", + "apply": "Применить", "run": "Выполнить", "back": "Назад" } @@ -189,8 +196,23 @@ "title": "Математический блокнот", "sheetList": "Список листов", "newSheet": "Новый лист", + "searchPlaceholder": "Поиск листов…", + "noResults": "Листы не найдены", "untitled": "Без названия", - "currencyUnavailable": "Сервис курсов валют недоступен" + "currencyUnavailable": "Сервис курсов валют недоступен", + "cloudSyncing": "Синхронизация с облачным хранилищем…" + }, + "drawings": { + "label": "Рисунки", + "tooltip": "Рисунки Excalidraw", + "title": "Рисунки", + "drawingList": "Список рисунков", + "newDrawing": "Новый рисунок", + "exportImage": "Экспорт изображения…", + "copyLinkForNote": "Копировать ссылку для заметки", + "openInSpace": "Открыть в Рисунках", + "notFound": "Рисунок не найден", + "noSelected": "Рисунок не выбран" }, "http": { "label": "HTTP", @@ -339,6 +361,82 @@ "symbols": "Символы", "selectedMultiple": "Выбрано заметок: {{count}}", "noSelected": "Нет выбранных заметок", + "editor": { + "menu": { + "format": { + "title": "Форматирование", + "bold": "Полужирный", + "italic": "Курсив", + "strikethrough": "Зачёркнутый", + "highlight": "Выделение", + "code": "Код", + "link": "Ссылка", + "clearFormatting": "Очистить форматирование" + }, + "paragraph": { + "title": "Абзац", + "bulletList": "Маркированный список", + "numberedList": "Нумерованный список", + "taskList": "Список задач", + "heading": "Заголовок {{level}}", + "body": "Обычный текст", + "quote": "Цитата" + }, + "insert": { + "title": "Вставить", + "table": "Таблица", + "callout": "Выноска", + "horizontalRule": "Горизонтальная линия", + "codeBlock": "Блок кода" + }, + "table": { + "title": "Таблица", + "insertRowAbove": "Вставить строку выше", + "insertRowBelow": "Вставить строку ниже", + "deleteRow": "Удалить строку", + "insertColumnLeft": "Вставить столбец слева", + "insertColumnRight": "Вставить столбец справа", + "deleteColumn": "Удалить столбец", + "alignLeft": "По левому краю", + "alignCenter": "По центру", + "alignRight": "По правому краю" + } + }, + "table": { + "addColumn": "Добавить столбец справа", + "addRow": "Добавить строку снизу" + } + }, + "tasks": { + "title": "Задачи", + "today": "Сегодня", + "upcoming": "Предстоящие", + "completed": "Завершённые", + "convertToTask": "Преобразовать в задачу", + "convertToNote": "Преобразовать в заметку", + "duePlaceholder": "Срок", + "clearDue": "Очистить срок", + "noDue": "Нет срока", + "cleanupCompleted": "Очистить завершённые задачи", + "cleanupConfirmTitle": "Очистить завершённые задачи?", + "cleanupConfirmMessage": "Все завершённые задачи будут перемещены в корзину. Их можно восстановить, пока корзина не очищена.", + "cleanupDone": "Перемещено завершённых задач в корзину: {{count}}", + "cleanupEmpty": "Нет завершённых задач для очистки", + "cleanupError": "Не удалось очистить завершённые задачи", + "status": { + "todo": "К выполнению", + "inProgress": "В работе", + "done": "Готово", + "blocked": "Заблокировано" + }, + "priority": { + "label": "Приоритет", + "none": "Без приоритета", + "low": "Низкий", + "medium": "Средний", + "high": "Высокий" + } + }, "dashboard": { "label": "Дашборд", "title": "Дашборд заметок", @@ -403,7 +501,37 @@ "untitled": "Папка без названия", "iconPicker": { "searchPlaceholder": "Поиск иконок...", + "emojiSearchPlaceholder": "Поиск эмодзи...", + "iconSearchPlaceholder": "Поиск иконок...", "emptyResults": "Иконки не найдены", + "emptyEmojiResults": "Эмодзи не найдены", + "uploadImage": "Загрузить JPG или PNG", + "replaceImage": "Выбрать другое", + "applyImage": "Использовать", + "uploading": "Обработка изображения...", + "uploadTitle": "Добавить своё изображение", + "uploadHint": "Перетащите сюда JPG или PNG либо выберите файл. Максимальный размер — 10 МБ.", + "tabs": { + "emoji": "Эмодзи", + "icons": "Иконки", + "upload": "Загрузка" + }, + "emojiCategories": { + "people": "Люди", + "nature": "Природа", + "food": "Еда и напитки", + "activities": "Занятия", + "travel": "Путешествия и места", + "objects": "Предметы", + "symbols": "Символы", + "flags": "Флаги" + }, + "errors": { + "unsupportedFormat": "Выберите изображение JPG или PNG.", + "fileTooLarge": "Размер изображения не должен превышать 10 МБ.", + "processingFailed": "Не удалось обработать изображение.", + "updateFailed": "Не удалось обновить иконку папки." + }, "filters": { "all": "Все", "material": "Material", @@ -417,6 +545,7 @@ "confirm": "Подтвердить", "copy": "Копировать", "fit": "По размеру", + "generate": "Сгенерировать", "saveAs": "Сохранить как", "update": [ "Перейти на GitHub", @@ -437,6 +566,7 @@ }, "action": { "close": "Закрыть", + "clearSearch": "Очистить поиск", "defaultLanguage": "Язык по умолчанию", "duplicate": "Дублировать", "rename": "Переименовать", @@ -454,8 +584,10 @@ "folder": "Новая папка", "snippet": "Новый сниппет", "fragment": "Новый фрагмент", - "note": "Новая заметка" + "note": "Новая заметка", + "task": "Новая задача" }, + "createOptions": "Варианты создания", "add": { "description": "Добавить описание", "tag": "Добавить тег", @@ -528,8 +660,16 @@ "emptyNotesList": "Нет заметок", "searchNotes": "Поиск заметок...", "emptySheetList": "Нет листов", + "emptyDrawingsList": "Нет рисунков", "search": "Поиск", + "searchIn": "Поиск в {{- context}}", "addTag": "Добавить тег", "selectLanguage": "Выбрать язык" + }, + "cloudDownloads": { + "downloading": "Докачивание файлов из облака: осталось {{count}}", + "failed": "Не удалось скачать файлов из облака: {{count}}", + "label": "Загрузка из облака", + "itemPending": "Скачивается из облачного хранилища…" } } diff --git a/src/main/i18n/locales/tr_TR/preferences.json b/src/main/i18n/locales/tr_TR/preferences.json index 5a9c225d1..fb63667ac 100644 --- a/src/main/i18n/locales/tr_TR/preferences.json +++ b/src/main/i18n/locales/tr_TR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Kod Bloğu Görüntüleyici" }, "api": { - "label": "API Portu", + "label": "API", "port": { "label": "API Portu", "description": "API sunucusu için port numarası (uygulamayı yeniden başlatmak gerekir). Geçerli aralık: 1024-65535." diff --git a/src/main/i18n/locales/uk_UA/preferences.json b/src/main/i18n/locales/uk_UA/preferences.json index d7e61e9a5..be9a82d04 100644 --- a/src/main/i18n/locales/uk_UA/preferences.json +++ b/src/main/i18n/locales/uk_UA/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Відображення блоків коду" }, "api": { - "label": "Порт API", + "label": "API", "port": { "label": "Порт API", "description": "Номер порту для API-сервера (потребує перезапуску додатку). Допустимий діапазон: 1024-65535." diff --git a/src/main/i18n/locales/zh_CN/preferences.json b/src/main/i18n/locales/zh_CN/preferences.json index 33df8ad7a..6f7e0bc59 100644 --- a/src/main/i18n/locales/zh_CN/preferences.json +++ b/src/main/i18n/locales/zh_CN/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "代码块渲染器" }, "api": { - "label": "API 端口", + "label": "API", "port": { "label": "API 端口", "description": "API 服务器的端口号(需要重启应用程序才能生效)。有效范围:1024-65535。" diff --git a/src/main/i18n/locales/zh_HK/preferences.json b/src/main/i18n/locales/zh_HK/preferences.json index c4b84914d..495c87a77 100644 --- a/src/main/i18n/locales/zh_HK/preferences.json +++ b/src/main/i18n/locales/zh_HK/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "程式碼區塊渲染器" }, "api": { - "label": "API 端口", + "label": "API", "port": { "label": "API 端口", "description": "API 伺服器的端口號(需要重新啟動應用程式)。有效範圍:1024-65535。" diff --git a/src/main/i18n/locales/zh_TW/preferences.json b/src/main/i18n/locales/zh_TW/preferences.json index ea37b4d09..25aa41e38 100644 --- a/src/main/i18n/locales/zh_TW/preferences.json +++ b/src/main/i18n/locales/zh_TW/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "代碼塊渲染器" }, "api": { - "label": "API 連接埠", + "label": "API", "port": { "label": "API 連接埠", "description": "API 伺服器的連接埠號碼(需要重新啟動應用程式)。有效範圍:1024-65535。" diff --git a/src/main/index.ts b/src/main/index.ts index b5c05886f..30a958c37 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,21 +1,42 @@ /* eslint-disable node/prefer-global/process */ -import { readFile } from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' import { app, BrowserWindow, ipcMain, Menu, protocol, screen } from 'electron' import { initApi } from './api' +import { cleanupDockBadge, refreshDockBadge } from './dockBadge' +import { resolveFolderIconResponse } from './folderIcons' import { registerIPC } from './ipc' import { startThemeWatcher, stopThemeWatcher } from './ipc/handlers/theme' +import { validateStoredLicense } from './license' import { createMainMenu } from './menu/main' -import { startMarkdownWatcher, stopMarkdownWatcher } from './storage' +import { isQuitting, setQuitting } from './quitState' +import { + prepareMarkdownWatcher, + startMarkdownWatcher, + stopMarkdownWatcher, +} from './storage' +import { + getNotesPaths, + resolveNotesAsset, +} from './storage/providers/markdown/notes/runtime' +import { getVaultPath } from './storage/providers/markdown/runtime/paths' import { ensureFlatSpacesLayout } from './storage/providers/markdown/runtime/spaces' import { store } from './store' +import { startTasksCleanupScheduler, stopTasksCleanupScheduler } from './tasks' import { checkForUpdates } from './updates' import { isSqliteFile, log } from './utils' import { DEFAULT_WINDOW_BOUNDS, normalizeWindowBounds } from './windowBounds' process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' +// Отладка renderer через Chrome DevTools Protocol (только по явному env). +if (process.env.MASSCODE_REMOTE_DEBUG_PORT) { + app.commandLine.appendSwitch( + 'remote-debugging-port', + process.env.MASSCODE_REMOTE_DEBUG_PORT, + ) +} + const isDev = process.env.NODE_ENV === 'development' const gotTheLock = app.requestSingleInstanceLock() const lazyRequire = createRequire(__filename) @@ -23,7 +44,6 @@ const WINDOW_BOUNDS_SAVE_DELAY = 250 let mainWindow: BrowserWindow let saveWindowBoundsTimer: ReturnType | null = null -let isQuitting = false let migrationResult: { folders: number snippets: number @@ -116,7 +136,7 @@ function createWindow() { mainWindow.on('close', (event) => { flushWindowBoundsSave() - if (process.platform === 'darwin' && !isQuitting) { + if (process.platform === 'darwin' && !isQuitting()) { event.preventDefault() mainWindow.hide() return @@ -135,37 +155,14 @@ else { const url = new URL(request.url) if (url.hostname === 'notes-asset') { - const fileName = url.pathname.replace(/^\//, '') - const vaultPath - = (store.preferences.get('storage.vaultPath') as string | null) - || path.join( - store.preferences.get('storage.rootPath') as string, - 'markdown-vault', - ) - ensureFlatSpacesLayout(vaultPath) - const filePath = path.join(vaultPath, 'notes', 'assets', fileName) - - try { - const data = await readFile(filePath) - const ext = path.extname(fileName).toLowerCase() - const mimeTypes: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.bmp': 'image/bmp', - } - return new Response(data, { - headers: { - 'Content-Type': mimeTypes[ext] || 'application/octet-stream', - }, - }) - } - catch { - return new Response('Not found', { status: 404 }) - } + const paths = getNotesPaths(getVaultPath()) + return resolveNotesAsset(url.pathname.replace(/^\//, ''), paths) + } + + if (url.hostname === 'folder-icon') { + const [, spaceId, folderId, ...rest] = url.pathname.split('/') + if (rest.length === 0) + return resolveFolderIconResponse(spaceId, folderId) } return new Response('Not found', { status: 404 }) @@ -180,29 +177,44 @@ else { = (store.preferences.get('storage.vaultPath') as string | null) || path.join(storagePath, 'markdown-vault') ensureFlatSpacesLayout(vaultPath) - const { hasMarkdownVaultData } = lazyRequire( - './storage/providers/markdown', - ) as typeof import('./storage/providers/markdown') - const vaultHasData = hasMarkdownVaultData(vaultPath) - - if (!vaultHasData) { - const { closeDB } = lazyRequire('./db') as typeof import('./db') - const { migrateSqliteToMarkdownStorage } = lazyRequire( - './storage/providers/markdown', - ) as typeof import('./storage/providers/markdown') - - try { - migrationResult = migrateSqliteToMarkdownStorage() - store.preferences.delete('storage.engine' as any) - store.preferences.delete('backup' as any) + // Авто-миграция из SQLite выполняется только один раз. Флаг + // storage.sqliteMigrated персистентно блокирует повтор, иначе ручная + // очистка vault при сохранившемся massCode.db триггерила бы миграцию + // заново и возвращала удалённые данные. + const alreadyMigrated + = store.preferences.get('storage.sqliteMigrated') === true - // eslint-disable-next-line no-console - console.log('[Auto-migration complete]', migrationResult) - } - finally { - closeDB() + if (!alreadyMigrated) { + const { hasMarkdownVaultData } = lazyRequire( + './storage/providers/markdown', + ) as typeof import('./storage/providers/markdown') + const vaultHasData = hasMarkdownVaultData(vaultPath) + + if (!vaultHasData) { + const { closeDB } = lazyRequire('./db') as typeof import('./db') + const { migrateSqliteToMarkdownStorage } = lazyRequire( + './storage/providers/markdown', + ) as typeof import('./storage/providers/markdown') + + try { + migrationResult = migrateSqliteToMarkdownStorage() + + store.preferences.delete('storage.engine' as any) + store.preferences.delete('backup' as any) + + // eslint-disable-next-line no-console + console.log('[Auto-migration complete]', migrationResult) + } + finally { + closeDB() + } } + + // Помечаем миграцию как выполненную в обоих случаях: после успешной + // миграции и как backfill для пользователей, уже мигрировавших в + // прошлых версиях (vault с данными, но без флага). + store.preferences.set('storage.sqliteMigrated', true) } } } @@ -212,10 +224,10 @@ else { } try { - startMarkdownWatcher() + startTasksCleanupScheduler() } catch (error) { - log('Error starting markdown watcher', error) + log('Error starting tasks cleanup scheduler', error) } try { @@ -225,6 +237,20 @@ else { log('Error registering IPC', error) } + try { + prepareMarkdownWatcher() + } + catch (error) { + log('Error preparing markdown watcher', error) + } + + try { + validateStoredLicense() + } + catch (error) { + log('Error validating stored license', error) + } + try { createWindow() } @@ -232,6 +258,20 @@ else { log('Error creating window', error) } + // Первичный скан vault уходит из критического пути старта: окно + // появляется сразу, а скан (уже без блокирующих чтений облачных + // плейсхолдеров) выполняется следом. Ранние API/IPC-запросы renderer + // безопасны: они лениво триггерят тот же скан через getRuntimeCache. + setImmediate(() => { + try { + startMarkdownWatcher() + refreshDockBadge() + } + catch (error) { + log('Error starting markdown watcher', error) + } + }) + try { startThemeWatcher() } @@ -259,10 +299,12 @@ else { }) app.on('before-quit', () => { - isQuitting = true + setQuitting(true) flushWindowBoundsSave() stopThemeWatcher() stopMarkdownWatcher() + stopTasksCleanupScheduler() + cleanupDockBadge() }) app.on('window-all-closed', () => { diff --git a/src/main/ipc/handlers/__tests__/http.test.ts b/src/main/ipc/handlers/__tests__/http.test.ts index e9665de2b..532d4e787 100644 --- a/src/main/ipc/handlers/__tests__/http.test.ts +++ b/src/main/ipc/handlers/__tests__/http.test.ts @@ -1,14 +1,39 @@ +import { Readable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import { formatHttpRequestError } from '../http' +import { formatHttpRequestError, registerHttpHandlers } from '../http' + +const { AgentMock, handleMock, requestMock } = vi.hoisted(() => ({ + AgentMock: class { + options: unknown + + constructor(options: unknown) { + this.options = options + } + }, + handleMock: vi.fn(), + requestMock: vi.fn(), +})) vi.mock('electron', () => ({ ipcMain: { - handle: vi.fn(), + handle: handleMock, }, })) +vi.mock('undici', () => ({ + Agent: vi.fn(AgentMock), + request: requestMock, +})) + vi.mock('../../../storage', () => ({ - useHttpStorage: vi.fn(), + useHttpStorage: () => ({ + environments: { + getEnvironments: () => [], + }, + history: { + appendEntry: vi.fn(), + }, + }), })) describe('formatHttpRequestError', () => { @@ -67,3 +92,47 @@ describe('formatHttpRequestError', () => { ) }) }) + +describe('registerHttpHandlers', () => { + it('uses an insecure dispatcher when certificate verification is skipped', async () => { + requestMock.mockResolvedValueOnce({ + body: Readable.from([]), + headers: {}, + statusCode: 200, + }) + + registerHttpHandlers() + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'spaces:http:execute', + )?.[1] + + await handler(null, { + environmentId: null, + request: { + auth: { type: 'none' }, + body: null, + bodyType: 'none', + formData: [], + headers: [], + method: 'GET', + query: [], + url: 'https://example.test', + }, + requestId: null, + skipCertificateVerification: true, + }) + + expect(requestMock).toHaveBeenCalledWith( + 'https://example.test/', + expect.objectContaining({ + dispatcher: expect.objectContaining({ + options: { + connect: { + rejectUnauthorized: false, + }, + }, + }), + }), + ) + }) +}) diff --git a/src/main/ipc/handlers/__tests__/spaces.test.ts b/src/main/ipc/handlers/__tests__/spaces.test.ts new file mode 100644 index 000000000..29b9e2e44 --- /dev/null +++ b/src/main/ipc/handlers/__tests__/spaces.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const registeredHandlers = new Map unknown>() +const handle = vi.fn( + (channel: string, handler: (...args: any[]) => unknown) => { + registeredHandlers.set(channel, handler) + }, +) +const getVaultPath = vi.fn(() => '/fallback-vault') +const createDrawing = vi.fn(() => ({ + id: 'Untitled', + name: 'Untitled', + createdAt: 1, + updatedAt: 1, +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle, + }, +})) + +vi.mock('../../../storage/providers/markdown/drawings', () => ({ + createDrawing, + deleteDrawing: vi.fn(), + duplicateDrawing: vi.fn(), + listDrawings: vi.fn(), + readDrawing: vi.fn(), + renameDrawing: vi.fn(), + writeDrawing: vi.fn(), +})) + +vi.mock('../../../storage/providers/markdown/runtime/paths', () => ({ + getVaultPath, +})) + +vi.mock('../../../storage/providers/markdown/runtime/spaces', () => ({ + ensureSpaceDirectory: vi.fn(), + getSpaceStatePath: vi.fn(), +})) + +vi.mock('../../../storage/providers/markdown/runtime/spaceState', () => ({ + readSpaceState: vi.fn(), + writeSpaceState: vi.fn(), +})) + +vi.mock('../../../store', () => ({ + store: { + mathNotebook: { + get: vi.fn(), + }, + }, +})) + +beforeEach(() => { + registeredHandlers.clear() + vi.clearAllMocks() +}) + +describe('registerSpacesHandlers', () => { + it('creates drawings using the shared vault path resolver', async () => { + const { registerSpacesHandlers } = await import('../spaces') + + registerSpacesHandlers() + + const result = await registeredHandlers.get('spaces:drawings:create')?.( + undefined, + null, + ) + + expect(getVaultPath).toHaveBeenCalled() + expect(createDrawing).toHaveBeenCalledWith('/fallback-vault', undefined) + expect(result).toEqual({ + id: 'Untitled', + name: 'Untitled', + createdAt: 1, + updatedAt: 1, + }) + }) +}) diff --git a/src/main/ipc/handlers/__tests__/system.test.ts b/src/main/ipc/handlers/__tests__/system.test.ts index e2aead861..28a6da3b0 100644 --- a/src/main/ipc/handlers/__tests__/system.test.ts +++ b/src/main/ipc/handlers/__tests__/system.test.ts @@ -12,6 +12,8 @@ const openExternal = vi.fn() const showItemInFolder = vi.fn() const getDirectoryState = vi.fn() const moveVault = vi.fn() +const startMarkdownWatcher = vi.fn() +const stopMarkdownWatcher = vi.fn() const getVaultPath = vi.fn(() => '/current-vault') const getPaths = vi.fn((vaultPath: string) => ({ vaultPath })) interface SnippetFileLookup { @@ -23,12 +25,30 @@ const getRuntimeCache = vi.fn((): { snippets: SnippetFileLookup[] } => ({ snippets: [], })) const findSnippetById = vi.fn() +const i18nT = vi.fn() +const log = vi.fn() +const preferencesGet = vi.fn() const preferencesSet = vi.fn() +const refreshDockBadge = vi.fn(() => ({ applied: true, count: 3 })) +const scheduleDockBadgeRefresh = vi.fn() vi.mock('electron', () => ({ app: { relaunch, quit, + // Транзитивный импорт main-меню читает версию при инициализации модуля. + getVersion: vi.fn(() => '0.0.0-test'), + }, + BrowserWindow: { + getAllWindows: vi.fn(() => []), + getFocusedWindow: vi.fn(() => null), + }, + Menu: { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), + }, + dialog: { + showMessageBox: vi.fn(), }, ipcMain: { handle, @@ -45,6 +65,26 @@ vi.mock('../../../currencyRates', () => ({ refreshFiatRatesForced: vi.fn(), })) +vi.mock('../../../dockBadge', () => ({ + refreshDockBadge, + scheduleDockBadgeRefresh, +})) + +vi.mock('../../../i18n', () => ({ + default: { + t: i18nT, + }, +})) + +vi.mock('../../../utils', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + log, + } +}) + vi.mock('../../../storage/providers/markdown/notes/runtime', () => ({ findNoteById: vi.fn(), getNotesFolderPathById: vi.fn(), @@ -67,9 +107,16 @@ vi.mock('../../../storage/providers/markdown/runtime/paths', () => ({ getVaultPath, })) +vi.mock('../../../storage/providers/markdown/watcher', () => ({ + startMarkdownWatcher, + stopMarkdownWatcher, +})) + vi.mock('../../../store', () => ({ store: { preferences: { + // Транзитивный импорт i18n читает локаль при инициализации модуля. + get: preferencesGet, set: preferencesSet, }, }, @@ -78,6 +125,16 @@ vi.mock('../../../store', () => ({ beforeEach(() => { registeredHandlers.clear() vi.clearAllMocks() + moveVault.mockReset() + startMarkdownWatcher.mockReset() + i18nT.mockReset() + log.mockReset() + preferencesGet.mockReset() + preferencesSet.mockReset() + preferencesGet.mockImplementation((key: string) => + key === 'storage.vaultPath' ? '/current-vault' : undefined, + ) + i18nT.mockImplementation((key: string) => key) }) describe('registerSystemHandlers', () => { @@ -103,6 +160,32 @@ describe('registerSystemHandlers', () => { ) }) + it('registers a handler for switching the vault path', async () => { + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + expect(handle).toHaveBeenCalledWith( + 'system:set-vault-path', + expect.any(Function), + ) + }) + + it('registers and delegates the Dock badge refresh handler', async () => { + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + expect(handle).toHaveBeenCalledWith( + 'system:refresh-dock-badge', + expect.any(Function), + ) + expect( + registeredHandlers.get('system:refresh-dock-badge')?.(undefined), + ).toEqual({ applied: true, count: 3 }) + expect(refreshDockBadge).toHaveBeenCalledTimes(1) + }) + it('delegates directory state lookup to the runtime helper', async () => { getDirectoryState.mockReturnValue({ exists: true, isEmpty: false }) const { registerSystemHandlers } = await import('../system') @@ -133,7 +216,157 @@ describe('registerSystemHandlers', () => { 'storage.vaultPath', '/next-vault', ) + expect(stopMarkdownWatcher).toHaveBeenCalledTimes(1) + expect(startMarkdownWatcher).toHaveBeenCalledTimes(1) + expect(stopMarkdownWatcher.mock.invocationCallOrder[0]).toBeLessThan( + moveVault.mock.invocationCallOrder[0], + ) + expect(moveVault.mock.invocationCallOrder[0]).toBeLessThan( + preferencesSet.mock.invocationCallOrder[0], + ) + expect(preferencesSet.mock.invocationCallOrder[0]).toBeLessThan( + startMarkdownWatcher.mock.invocationCallOrder[0], + ) + expect(result).toEqual({ vaultPath: '/next-vault' }) + }) + + it('switches the configured vault between watcher stop and restart', async () => { + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + const result = registeredHandlers.get('system:set-vault-path')?.( + undefined, + { vaultPath: '/next-vault' }, + ) + + expect(preferencesSet).toHaveBeenCalledWith( + 'storage.vaultPath', + '/next-vault', + ) + expect(stopMarkdownWatcher).toHaveBeenCalledTimes(1) + expect(startMarkdownWatcher).toHaveBeenCalledTimes(1) + expect(stopMarkdownWatcher.mock.invocationCallOrder[0]).toBeLessThan( + preferencesSet.mock.invocationCallOrder[0], + ) + expect(preferencesSet.mock.invocationCallOrder[0]).toBeLessThan( + startMarkdownWatcher.mock.invocationCallOrder[0], + ) + expect(result).toEqual({ vaultPath: '/next-vault' }) + }) + + it('localizes an invalid vault path without restarting storage', async () => { + i18nT.mockReturnValue('Localized vault path required') + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + expect(() => + registeredHandlers.get('system:set-vault-path')?.(undefined, { + vaultPath: ' ', + }), + ).toThrow('Localized vault path required') + expect(i18nT).toHaveBeenCalledWith('messages:error.vaultPathRequired') + expect(preferencesSet).not.toHaveBeenCalled() + expect(stopMarkdownWatcher).not.toHaveBeenCalled() + expect(startMarkdownWatcher).not.toHaveBeenCalled() + }) + + it('restores the previous vault when its watcher cannot start', async () => { + startMarkdownWatcher.mockImplementationOnce(() => { + throw new Error('watcher failed') + }) + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + expect(() => + registeredHandlers.get('system:set-vault-path')?.(undefined, { + vaultPath: '/next-vault', + }), + ).toThrow('watcher failed') + expect(preferencesSet.mock.calls).toEqual([ + ['storage.vaultPath', '/next-vault'], + ['storage.vaultPath', '/current-vault'], + ]) + expect(stopMarkdownWatcher).toHaveBeenCalledTimes(2) + expect(startMarkdownWatcher).toHaveBeenCalledTimes(2) + }) + + it('preserves the vault switch error when watcher recovery also fails', async () => { + const switchError = new Error('new watcher failed') + const recoveryError = new Error('old watcher recovery failed') + startMarkdownWatcher + .mockImplementationOnce(() => { + throw switchError + }) + .mockImplementationOnce(() => { + throw recoveryError + }) + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + expect(() => + registeredHandlers.get('system:set-vault-path')?.(undefined, { + vaultPath: '/next-vault', + }), + ).toThrow(switchError) + expect(log).toHaveBeenCalledWith( + 'storage:markdown:vault-switch-recovery', + recoveryError, + ) + }) + + it('retries the target watcher after a successful vault move', async () => { + startMarkdownWatcher.mockImplementationOnce(() => { + throw new Error('first target watcher start failed') + }) + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + const result = registeredHandlers.get('system:move-vault')?.(undefined, { + targetPath: '/next-vault', + }) + expect(result).toEqual({ vaultPath: '/next-vault' }) + expect(preferencesSet).toHaveBeenCalledWith( + 'storage.vaultPath', + '/next-vault', + ) + expect(stopMarkdownWatcher).toHaveBeenCalledTimes(2) + expect(startMarkdownWatcher).toHaveBeenCalledTimes(2) + expect(log).not.toHaveBeenCalled() + }) + + it('preserves the target watcher error when its retry also fails', async () => { + const startError = new Error('target watcher failed') + const recoveryError = new Error('target watcher recovery failed') + startMarkdownWatcher + .mockImplementationOnce(() => { + throw startError + }) + .mockImplementationOnce(() => { + throw recoveryError + }) + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + expect(() => + registeredHandlers.get('system:move-vault')?.(undefined, { + targetPath: '/next-vault', + }), + ).toThrow(startError) + expect(preferencesSet).toHaveBeenCalledWith( + 'storage.vaultPath', + '/next-vault', + ) + expect(log).toHaveBeenCalledWith( + 'storage:markdown:vault-move-watcher-recovery', + recoveryError, + ) }) it('does not update the configured path when moving the vault fails', async () => { @@ -150,6 +383,38 @@ describe('registerSystemHandlers', () => { }), ).toThrow('move failed') expect(preferencesSet).not.toHaveBeenCalled() + expect(stopMarkdownWatcher).toHaveBeenCalledTimes(1) + expect(startMarkdownWatcher).toHaveBeenCalledTimes(1) + expect(stopMarkdownWatcher.mock.invocationCallOrder[0]).toBeLessThan( + moveVault.mock.invocationCallOrder[0], + ) + expect(moveVault.mock.invocationCallOrder[0]).toBeLessThan( + startMarkdownWatcher.mock.invocationCallOrder[0], + ) + }) + + it('preserves a move error when restoring the old watcher also fails', async () => { + const moveError = new Error('move failed') + const recoveryError = new Error('old watcher recovery failed') + moveVault.mockImplementation(() => { + throw moveError + }) + startMarkdownWatcher.mockImplementation(() => { + throw recoveryError + }) + const { registerSystemHandlers } = await import('../system') + + registerSystemHandlers() + + expect(() => + registeredHandlers.get('system:move-vault')?.(undefined, { + targetPath: '/next-vault', + }), + ).toThrow(moveError) + expect(log).toHaveBeenCalledWith( + 'storage:markdown:vault-move-recovery', + recoveryError, + ) }) it('reveals a snippet file in the file manager', async () => { diff --git a/src/main/ipc/handlers/fs.ts b/src/main/ipc/handlers/fs.ts index ee667bc02..29926cc7f 100644 --- a/src/main/ipc/handlers/fs.ts +++ b/src/main/ipc/handlers/fs.ts @@ -1,5 +1,6 @@ import type { ImportMarkdownFolderResponse } from '../../types/ipc' import { Buffer } from 'node:buffer' +import { randomBytes } from 'node:crypto' import { extname, join, parse, relative } from 'node:path' import { BrowserWindow, dialog, ipcMain } from 'electron' import { @@ -10,13 +11,31 @@ import { realpath, writeFileSync, } from 'fs-extra' -import { nanoid } from 'nanoid' import slash from 'slash' +import { + parseFolderIconSetPayload, + parseFolderIconWritePayload, + setFolderIcon, + writeFolderIcon, +} from '../../folderIcons' +import { + getNotesPaths, + parseNotesAssetWritePayload, + writeNotesAsset, +} from '../../storage/providers/markdown/notes/runtime' import { ensureFlatSpacesLayout } from '../../storage/providers/markdown/runtime/spaces' import { store } from '../../store' const ASSETS_DIR = 'assets' +// A short, URL- and filesystem-safe id for asset filenames. Uses the +// built-in crypto module so the main process keeps no dependency on the +// ESM-only nanoid, which cannot be `require`d from the compiled CommonJS +// build. +function generateAssetId() { + return randomBytes(12).toString('base64url') +} + async function readMarkdownFolder( rootPath: string, ): Promise { @@ -88,6 +107,22 @@ async function readMarkdownFolder( } export function registerFsHandlers() { + ipcMain.handle('fs:folder-icon:write', async (_, payload: unknown) => { + const parsedPayload = parseFolderIconWritePayload(payload) + if (!parsedPayload) + throw new TypeError('Invalid folder icon payload') + + return writeFolderIcon(parsedPayload) + }) + + ipcMain.handle('fs:folder-icon:set', async (_, payload: unknown) => { + const parsedPayload = parseFolderIconSetPayload(payload) + if (!parsedPayload) + throw new TypeError('Invalid folder icon payload') + + await setFolderIcon(parsedPayload) + }) + ipcMain.handle('fs:assets', (event, { buffer, fileName }) => { const storagePath = store.preferences.get('storage.rootPath') as string @@ -96,7 +131,7 @@ export function registerFsHandlers() { const assetsPath = join(storagePath, ASSETS_DIR) const { ext } = parse(fileName) - const name = `${nanoid()}${ext}` + const name = `${generateAssetId()}${ext}` const dest = join(assetsPath, name) ensureDirSync(assetsPath) @@ -110,7 +145,12 @@ export function registerFsHandlers() { }) }) - ipcMain.handle('fs:notes-asset', (event, { buffer, ext }) => { + ipcMain.handle('fs:notes-asset', async (event, payload: unknown) => { + const parsedPayload = parseNotesAssetWritePayload(payload) + if (!parsedPayload) { + throw new TypeError('Invalid Notes asset payload') + } + const vaultPath = (store.preferences.get('storage.vaultPath') as string | null) || join( @@ -118,22 +158,12 @@ export function registerFsHandlers() { 'markdown-vault', ) - return new Promise((resolve, reject) => { - try { - ensureFlatSpacesLayout(vaultPath) - const assetsPath = join(vaultPath, 'notes', 'assets') - const name = `${nanoid()}${ext}` - const dest = join(assetsPath, name) - - ensureDirSync(assetsPath) - writeFileSync(dest, Buffer.from(buffer)) - - resolve(`masscode://notes-asset/${name}`) - } - catch (error) { - reject(error) - } - }) + ensureFlatSpacesLayout(vaultPath) + return writeNotesAsset( + getNotesPaths(vaultPath), + parsedPayload.buffer, + parsedPayload.ext, + ) }) ipcMain.handle('fs:import-markdown-folder', async () => { diff --git a/src/main/ipc/handlers/http.ts b/src/main/ipc/handlers/http.ts index 91f8762f4..16162d650 100644 --- a/src/main/ipc/handlers/http.ts +++ b/src/main/ipc/handlers/http.ts @@ -16,12 +16,17 @@ import { Buffer } from 'node:buffer' import { readFileSync } from 'node:fs' import { basename } from 'node:path' import { ipcMain } from 'electron' -import { request as undiciRequest } from 'undici' +import { Agent, request as undiciRequest } from 'undici' import { interpolateHttpVariables } from '../../../shared/httpVariables' import { useHttpStorage } from '../../storage' const RESPONSE_BODY_CAP_BYTES = 10 * 1024 * 1024 const DEFAULT_TIMEOUT_MS = 30_000 +const insecureCertificateDispatcher = new Agent({ + connect: { + rejectUnauthorized: false, + }, +}) export function interpolate( template: string, @@ -426,6 +431,9 @@ async function executeHttpRequest( body: built.body as Dispatcher.DispatchOptions['body'], signal: controller.signal, maxRedirections: 5, + ...(payload.skipCertificateVerification + ? { dispatcher: insecureCertificateDispatcher } + : {}), }) const { buffer, sizeBytes, truncated } = await readBodyCapped( diff --git a/src/main/ipc/handlers/spaces.ts b/src/main/ipc/handlers/spaces.ts index 513b93f74..3aec7b480 100644 --- a/src/main/ipc/handlers/spaces.ts +++ b/src/main/ipc/handlers/spaces.ts @@ -1,5 +1,17 @@ import type { MathNotebookStore } from '../../store/types' import { ipcMain } from 'electron' +import { + createDrawing, + deleteDrawing, + duplicateDrawing, + listDrawings, + readDrawing, + renameDrawing, + writeDrawing, +} from '../../storage/providers/markdown/drawings' +import { getVaultPath } from '../../storage/providers/markdown/runtime/paths' +import { getFileAvailability } from '../../storage/providers/markdown/runtime/shared/cloudFiles' +import { isCloudFileNotDownloadedError } from '../../storage/providers/markdown/runtime/shared/guardedRead' import { ensureSpaceDirectory, getSpaceStatePath, @@ -10,8 +22,10 @@ import { } from '../../storage/providers/markdown/runtime/spaceState' import { store } from '../../store' -function getVaultPath(): string | null { - return store.preferences.get('storage.vaultPath') as string | null +// Ответ math:read c облачным плейсхолдером вместо state: renderer получает +// пустой store с флагом pending и ретраит чтение после докачки. +interface MathNotebookReadResult extends MathNotebookStore { + pending?: boolean } export function registerSpacesHandlers() { @@ -26,7 +40,24 @@ export function registerSpacesHandlers() { ensureSpaceDirectory(vaultPath, 'math') const statePath = getSpaceStatePath(vaultPath, 'math') - const state = readSpaceState(statePath) + + let state: MathNotebookStore | null + try { + state = readSpaceState(statePath) + } + catch (error) { + if (!isCloudFileNotDownloadedError(error)) { + throw error + } + + // .state.yaml ещё не докачан из облака: guarded-чтение уже поставило + // его в приоритетную докачку, renderer ретраит по флагу pending. + return { + sheets: [], + activeSheetId: null, + pending: true, + } satisfies MathNotebookReadResult + } if (state) { return { @@ -55,6 +86,86 @@ export function registerSpacesHandlers() { ensureSpaceDirectory(vaultPath, 'math') const statePath = getSpaceStatePath(vaultPath, 'math') + + // Запись поверх недокачанного .state.yaml уничтожила бы облачную + // версию: до докачки писать некуда, renderer и так блокирует persist + // до первого успешного чтения. + if (getFileAvailability(statePath).isCloudPlaceholder) { + return + } + writeSpaceState(statePath, data) }) + + ipcMain.handle('spaces:drawings:list', () => { + const vaultPath = getVaultPath() + if (!vaultPath) { + return [] + } + + return listDrawings(vaultPath) + }) + + ipcMain.handle('spaces:drawings:read', (_, payload: { id: string }) => { + const vaultPath = getVaultPath() + if (!vaultPath) { + return null + } + + return readDrawing(vaultPath, payload.id) + }) + + ipcMain.handle( + 'spaces:drawings:write', + (_, payload: { id: string, content: string }) => { + const vaultPath = getVaultPath() + if (!vaultPath) { + return null + } + + return writeDrawing(vaultPath, payload.id, payload.content) + }, + ) + + ipcMain.handle( + 'spaces:drawings:create', + (_, payload: { name?: string | null } | null) => { + const vaultPath = getVaultPath() + if (!vaultPath) { + return null + } + + return createDrawing(vaultPath, payload?.name) + }, + ) + + ipcMain.handle( + 'spaces:drawings:rename', + (_, payload: { id: string, name: string }) => { + const vaultPath = getVaultPath() + if (!vaultPath) { + return null + } + + return renameDrawing(vaultPath, payload.id, payload.name) + }, + ) + + ipcMain.handle('spaces:drawings:duplicate', (_, payload: { id: string }) => { + const vaultPath = getVaultPath() + if (!vaultPath) { + return null + } + + return duplicateDrawing(vaultPath, payload.id) + }) + + ipcMain.handle('spaces:drawings:delete', (_, payload: { id: string }) => { + const vaultPath = getVaultPath() + if (!vaultPath) { + return { deleted: false } + } + + return deleteDrawing(vaultPath, payload.id) + }) } diff --git a/src/main/ipc/handlers/system.ts b/src/main/ipc/handlers/system.ts index 1c9b5796b..2caf36055 100644 --- a/src/main/ipc/handlers/system.ts +++ b/src/main/ipc/handlers/system.ts @@ -1,10 +1,18 @@ import path from 'node:path' import { app, ipcMain, shell } from 'electron' +import { + generateIntegrationToken, + revokeIntegrationToken, +} from '../../api/integrations/auth' import { getCurrencyRates, refreshCryptoRatesForced, refreshFiatRatesForced, } from '../../currencyRates' +import { refreshDockBadge, scheduleDockBadgeRefresh } from '../../dockBadge' +import i18n from '../../i18n' +import { activateLicense } from '../../license' +import { getCloudDownloadStatus } from '../../storage/providers/markdown/cloudDownloads' import { getHttpPaths, getHttpRuntimeCache, @@ -25,9 +33,117 @@ import { moveVault, } from '../../storage/providers/markdown/runtime/moveVault' import { getVaultPath } from '../../storage/providers/markdown/runtime/paths' +import { + startMarkdownWatcher, + stopMarkdownWatcher, +} from '../../storage/providers/markdown/watcher' import { store } from '../../store' +import { installDownloadedUpdate } from '../../updates' +import { log } from '../../utils' + +function setVaultPathAndRestartWatcher(vaultPath: string): void { + const previousVaultPath = store.preferences.get('storage.vaultPath') as + | string + | null + | undefined + + // Сначала останавливаем watcher: pending-записи flush'ятся в текущий vault, + // а cloud callbacks больше не используют захваченные пути старого vault. + stopMarkdownWatcher() + + try { + store.preferences.set('storage.vaultPath', vaultPath) + startMarkdownWatcher() + scheduleDockBadgeRefresh() + } + catch (error) { + // Не оставляем приложение на частично запущенном watcher нового vault: + // возвращаем прежнюю настройку и восстанавливаем старый watcher. + try { + stopMarkdownWatcher() + store.preferences.set('storage.vaultPath', previousVaultPath ?? null) + startMarkdownWatcher() + } + catch (recoveryError) { + log('storage:markdown:vault-switch-recovery', recoveryError) + } + + throw error + } +} + +function moveVaultAndRestartWatcher( + sourcePath: string, + targetPath: string, +): void { + // Flush обязан произойти до физического переноса, иначе отложенная запись + // способна заново создать state-файл в старом каталоге. + stopMarkdownWatcher() + + try { + moveVault(sourcePath, targetPath) + } + catch (error) { + // Путь в preferences ещё не менялся — возвращаем watcher старого vault. + try { + startMarkdownWatcher() + } + catch (recoveryError) { + log('storage:markdown:vault-move-recovery', recoveryError) + } + + throw error + } + + store.preferences.set('storage.vaultPath', targetPath) + + try { + startMarkdownWatcher() + scheduleDockBadgeRefresh() + } + catch (error) { + // Файлы уже перенесены, поэтому откат пути небезопасен. Чистый повтор + // восстанавливает watcher после частично выполненного startup. + try { + stopMarkdownWatcher() + startMarkdownWatcher() + scheduleDockBadgeRefresh() + return + } + catch (recoveryError) { + log('storage:markdown:vault-move-watcher-recovery', recoveryError) + } + + throw error + } +} export function registerSystemHandlers() { + ipcMain.handle('system:activate-license', (_, payload: { key: string }) => { + return activateLicense(payload.key) + }) + + // Текущий статус фоновой докачки облачных плейсхолдеров: renderer + // запрашивает его при старте, дальше обновления приходят событием + // system:cloud-download-progress. + ipcMain.handle('system:cloud-download-status', () => { + return getCloudDownloadStatus() + }) + + ipcMain.handle('system:install-update', () => { + installDownloadedUpdate() + return true + }) + + ipcMain.handle('system:api-token-generate', () => { + return generateIntegrationToken() + }) + + ipcMain.handle('system:api-token-revoke', () => { + revokeIntegrationToken() + return true + }) + ipcMain.handle('system:currency-rates', () => { return getCurrencyRates() }) @@ -40,6 +156,10 @@ export function registerSystemHandlers() { return refreshCryptoRatesForced() }) + ipcMain.handle('system:refresh-dock-badge', () => { + return refreshDockBadge() + }) + ipcMain.handle( 'system:get-directory-state', (_, payload: { path: string }) => { @@ -47,11 +167,23 @@ export function registerSystemHandlers() { }, ) + ipcMain.handle( + 'system:set-vault-path', + (_, payload: { vaultPath: string }) => { + if (typeof payload?.vaultPath !== 'string' || !payload.vaultPath.trim()) { + throw new Error(i18n.t('messages:error.vaultPathRequired')) + } + + setVaultPathAndRestartWatcher(payload.vaultPath) + + return { vaultPath: payload.vaultPath } + }, + ) + ipcMain.handle('system:move-vault', (_, payload: { targetPath: string }) => { const sourcePath = getVaultPath() - moveVault(sourcePath, payload.targetPath) - store.preferences.set('storage.vaultPath', payload.targetPath) + moveVaultAndRestartWatcher(sourcePath, payload.targetPath) return { vaultPath: payload.targetPath } }) diff --git a/src/main/license/__tests__/license.test.ts b/src/main/license/__tests__/license.test.ts new file mode 100644 index 000000000..0afe445a6 --- /dev/null +++ b/src/main/license/__tests__/license.test.ts @@ -0,0 +1,149 @@ +import { Buffer } from 'node:buffer' +import { generateKeyPairSync, sign } from 'node:crypto' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { activateLicense, validateStoredLicense, verifyLicenseKey } from '..' + +const appGet = vi.fn() +const appSet = vi.fn() + +vi.mock('../../store', () => ({ + store: { + app: { + get: (...args: unknown[]) => appGet(...args), + set: (...args: unknown[]) => appSet(...args), + }, + }, +})) + +// Подпись чужим ключом: валидна криптографически, но не нашим публичным ключом. +function makeForeignKey(payload: object) { + const { privateKey } = generateKeyPairSync('ed25519') + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString( + 'base64url', + ) + const signature = sign(null, Buffer.from(payloadBase64), privateKey).toString( + 'base64url', + ) + + return `${payloadBase64}.${signature}` +} + +// Выпущены скриптом scripts/license/issue.js парным приватным ключом. +const VALID_KEY + = 'eyJuYW1lIjoiVGVzdCBVc2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiaXNzdWVkQXQiOiIyMDI2LTA2LTEyIn0.C7HJmtCZI6KRGSFF1WmwAVDys2t_dP-jWBDXLGOiqjI6tQCMIqZIUFfZm5kOwR1lf1KHtzfv3NnB5sFV6H5TCQ' +const EMAIL_ONLY_KEY + = 'eyJlbWFpbCI6Im9ubHlAZXhhbXBsZS5jb20iLCJpc3N1ZWRBdCI6IjIwMjYtMDYtMTIifQ.xtJVJrjHSIgaTaD47cKV_kmUbxvdGcEZNklOe1phE670m52XkBjTSba8IglWdpW1TL87UwoUQg2cw2QBR0aiDQ' +// Подписан нашим ключом, но в payload нет обязательного email. +const NAME_ONLY_KEY + = 'eyJuYW1lIjoiTm8gRW1haWwiLCJpc3N1ZWRBdCI6IjIwMjYtMDYtMTIifQ.h3nngjQxRxc5RzhvrzBCbCoBDrTCKwbyiNf4fkoIjIBlf4hXhu3I3L5yb8MVwSMmAaInscj-lZk99sQGwFuKDA' + +describe('verifyLicenseKey', () => { + it('accepts a key signed with the project private key', () => { + expect(verifyLicenseKey(VALID_KEY)).toEqual({ + name: 'Test User', + email: 'test@example.com', + issuedAt: '2026-06-12', + }) + }) + + it('accepts a key without a name', () => { + expect(verifyLicenseKey(EMAIL_ONLY_KEY)).toEqual({ + email: 'only@example.com', + issuedAt: '2026-06-12', + }) + }) + + it('rejects a signed key without an email', () => { + expect(verifyLicenseKey(NAME_ONLY_KEY)).toBeNull() + }) + + it('accepts a key with surrounding whitespace', () => { + expect(verifyLicenseKey(` ${VALID_KEY}\n`)).not.toBeNull() + }) + + it('rejects a key signed with a foreign private key', () => { + expect( + verifyLicenseKey(makeForeignKey({ email: 'mallory@example.com' })), + ).toBeNull() + }) + + it('rejects a tampered payload', () => { + const [, signature] = VALID_KEY.split('.') + const tamperedPayload = Buffer.from( + JSON.stringify({ email: 'mallory@example.com' }), + ).toString('base64url') + + expect(verifyLicenseKey(`${tamperedPayload}.${signature}`)).toBeNull() + }) + + it('rejects malformed input', () => { + expect(verifyLicenseKey('')).toBeNull() + expect(verifyLicenseKey('not-a-key')).toBeNull() + expect(verifyLicenseKey('a.b.c')).toBeNull() + }) +}) + +describe('activateLicense', () => { + beforeEach(() => { + appSet.mockClear() + }) + + it('stores a valid key with the supporter identity', () => { + expect(activateLicense(VALID_KEY)).toEqual({ + active: true, + name: 'Test User', + email: 'test@example.com', + }) + expect(appSet).toHaveBeenCalledWith('license', { + key: VALID_KEY, + name: 'Test User', + email: 'test@example.com', + }) + }) + + it('stores a key without a name', () => { + expect(activateLicense(EMAIL_ONLY_KEY)).toEqual({ + active: true, + name: null, + email: 'only@example.com', + }) + }) + + it('does not store an invalid key', () => { + expect(activateLicense('garbage')).toEqual({ + active: false, + name: null, + email: null, + }) + expect(appSet).not.toHaveBeenCalled() + }) +}) + +describe('validateStoredLicense', () => { + beforeEach(() => { + appGet.mockReset() + appSet.mockClear() + }) + + it('keeps a valid stored key', () => { + appGet.mockReturnValue(VALID_KEY) + validateStoredLicense() + expect(appSet).not.toHaveBeenCalled() + }) + + it('resets an invalid stored key', () => { + appGet.mockReturnValue('tampered') + validateStoredLicense() + expect(appSet).toHaveBeenCalledWith('license', { + key: null, + name: null, + email: null, + }) + }) + + it('does nothing when no key is stored', () => { + appGet.mockReturnValue(null) + validateStoredLicense() + expect(appSet).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/license/index.ts b/src/main/license/index.ts new file mode 100644 index 000000000..caa9c1634 --- /dev/null +++ b/src/main/license/index.ts @@ -0,0 +1,87 @@ +import { Buffer } from 'node:buffer' +import { createPublicKey, verify } from 'node:crypto' +import { store } from '../store' + +// Публичный ключ Ed25519 (SPKI DER, base64). Парный приватный ключ хранится +// только у мейнтейнера и используется скриптом scripts/license/issue.js. +const LICENSE_PUBLIC_KEY + = 'MCowBQYDK2VwAyEA50TFZklSS8Q64UeasAngOrgKnQe2COfVkqiuS9YPIdo=' + +const publicKey = createPublicKey({ + key: Buffer.from(LICENSE_PUBLIC_KEY, 'base64'), + format: 'der', + type: 'spki', +}) + +export interface LicensePayload { + email: string + name?: string + issuedAt?: string +} + +export interface LicenseStatus { + active: boolean + name: string | null + email: string | null +} + +export function verifyLicenseKey(key: string): LicensePayload | null { + try { + const [payloadBase64, signatureBase64] = key.trim().split('.') + + if (!payloadBase64 || !signatureBase64) { + return null + } + + const isValid = verify( + null, + Buffer.from(payloadBase64), + publicKey, + Buffer.from(signatureBase64, 'base64url'), + ) + + if (!isValid) { + return null + } + + const payload = JSON.parse( + Buffer.from(payloadBase64, 'base64url').toString('utf8'), + ) + + if (typeof payload?.email !== 'string') { + return null + } + + return payload as LicensePayload + } + catch { + return null + } +} + +export function activateLicense(key: string): LicenseStatus { + const payload = verifyLicenseKey(key) + + if (!payload) { + return { active: false, name: null, email: null } + } + + const name = payload.name ?? null + + store.app.set('license', { + key: key.trim(), + name, + email: payload.email, + }) + + return { active: true, name, email: payload.email } +} + +// Страховка от ручной правки store-файла: невалидный ключ сбрасывается. +export function validateStoredLicense() { + const key = store.app.get('license.key') + + if (typeof key === 'string' && key && !verifyLicenseKey(key)) { + store.app.set('license', { key: null, name: null, email: null }) + } +} diff --git a/src/main/menu/__tests__/main.test.ts b/src/main/menu/__tests__/main.test.ts index 2a99777bc..b040d255b 100644 --- a/src/main/menu/__tests__/main.test.ts +++ b/src/main/menu/__tests__/main.test.ts @@ -2,6 +2,7 @@ import type { MainMenuContext } from '../../types/menu' import { beforeEach, describe, expect, it, vi } from 'vitest' const buildFromTemplate = vi.fn((template: unknown) => template) +const send = vi.fn() vi.mock('electron', () => ({ app: { @@ -30,7 +31,7 @@ vi.mock('../../i18n', () => ({ })) vi.mock('../../ipc', () => ({ - send: vi.fn(), + send, })) vi.mock('../../updates', () => ({ @@ -41,9 +42,53 @@ vi.mock('../../../../package.json', () => ({ repository: 'https://example.com/repo', })) +function createNotesContext( + options: { + canToggleMindmap?: boolean + isMindmapShown?: boolean + isPresentationShown?: boolean + noteMode?: MainMenuContext['editor']['noteMode'] + } = {}, +): MainMenuContext { + return { + file: { + primaryAction: 'new-note', + secondaryAction: 'new-folder', + canCreateFragment: false, + canCreateTask: true, + }, + view: { + layoutMode: 'all-panels', + layoutModes: ['all-panels', 'list-editor', 'editor-only'], + contentSortField: 'updatedAt', + contentSortOrder: 'DESC', + canToggleCompactMode: true, + canToggleHideCompletedTasks: false, + isHideCompletedTasksInFolders: false, + canToggleMindmap: options.canToggleMindmap ?? true, + isCompactMode: false, + isMindmapShown: options.isMindmapShown ?? false, + canTogglePresentation: true, + isPresentationShown: options.isPresentationShown ?? false, + }, + editor: { + kind: 'notes', + noteMode: options.noteMode ?? 'livePreview', + canSendRequest: false, + canFormat: false, + canPreviewCode: false, + isCodePreviewShown: false, + canPreviewJson: false, + isJsonPreviewShown: false, + canAdjustFontSize: true, + }, + } +} + describe('createMainMenu', () => { beforeEach(() => { buildFromTemplate.mockClear() + send.mockClear() }) it('renders file and view actions on the first submenu level', async () => { @@ -54,11 +99,16 @@ describe('createMainMenu', () => { primaryAction: 'new-snippet', secondaryAction: 'new-folder', canCreateFragment: true, + canCreateTask: false, }, view: { layoutMode: 'all-panels', layoutModes: ['all-panels', 'list-editor', 'editor-only'], + contentSortField: 'updatedAt', + contentSortOrder: 'DESC', canToggleCompactMode: true, + canToggleHideCompletedTasks: false, + isHideCompletedTasksInFolders: false, canToggleMindmap: false, isCompactMode: true, isMindmapShown: false, @@ -82,11 +132,18 @@ describe('createMainMenu', () => { const template = buildFromTemplate.mock.calls[0]?.[0] as Array<{ label?: string - submenu?: Array<{ label?: string, submenu?: unknown[] }> + submenu?: Array<{ + label?: string + submenu?: unknown[] + click?: () => void + }> }> const fileMenu = template.find(item => item.label === 'menu:file.label') const viewMenu = template.find(item => item.label === 'menu:view.label') + const editorMenu = template.find( + item => item.label === 'menu:editor.label', + ) expect(fileMenu?.submenu?.map(item => item.label)).toEqual([ 'action.new.snippet', @@ -102,11 +159,89 @@ describe('createMainMenu', () => { 'menu:view.layout.listEditor', 'menu:view.layout.editorOnly', undefined, + 'menu:view.sortBy.label', + 'menu:view.sortBy.dateModified', + 'menu:view.sortBy.dateCreated', + 'menu:view.sortBy.name', + undefined, + 'menu:view.sortOrder.label', + 'menu:view.sortOrder.ascending', + 'menu:view.sortOrder.descending', + undefined, 'menu:view.compactMode', ]) expect(viewMenu?.submenu?.some(item => Array.isArray(item.submenu))).toBe( false, ) + + const editorLabels = editorMenu?.submenu?.map(item => item.label) ?? [] + const formatIndex = editorLabels.indexOf('menu:editor.format') + + expect(editorLabels[formatIndex + 1]).toBe( + 'menu:editor.normalizeTerminalOutput', + ) + + editorMenu?.submenu + ?.find(item => item.label === 'menu:editor.normalizeTerminalOutput') + ?.click?.() + + expect(send).toHaveBeenCalledWith('main-menu:normalize-code-line-breaks') + expect( + editorMenu?.submenu?.find( + item => item.label === 'menu:editor.normalizeTerminalOutput', + ), + ).toMatchObject({ enabled: true }) + }) + + it('enables line break normalization for an editable selected note', async () => { + const { createMainMenu } = await import('../main') + + createMainMenu(createNotesContext()) + + const template = buildFromTemplate.mock.calls[0]?.[0] as Array<{ + label?: string + submenu?: Array<{ + label?: string + enabled?: boolean + click?: () => void + }> + }> + const editorMenu = template.find( + item => item.label === 'menu:editor.label', + ) + + const normalizeItem = editorMenu?.submenu?.find( + item => item.label === 'menu:editor.normalizeTerminalOutput', + ) + + expect(normalizeItem).toMatchObject({ enabled: true }) + normalizeItem?.click?.() + expect(send).toHaveBeenCalledWith('main-menu:normalize-note-line-breaks') + }) + + it.each([ + ['preview mode', { noteMode: 'preview' as const }], + ['no selected note', { canToggleMindmap: false }], + ['mindmap mode', { isMindmapShown: true }], + ['presentation mode', { isPresentationShown: true }], + ])('disables line break normalization in %s', async (_label, options) => { + const { createMainMenu } = await import('../main') + + createMainMenu(createNotesContext(options)) + + const template = buildFromTemplate.mock.calls[0]?.[0] as Array<{ + label?: string + submenu?: Array<{ label?: string, enabled?: boolean }> + }> + const editorMenu = template.find( + item => item.label === 'menu:editor.label', + ) + + expect( + editorMenu?.submenu?.find( + item => item.label === 'menu:editor.normalizeTerminalOutput', + ), + ).toMatchObject({ enabled: false }) }) it('omits compact mode when the current space has no list', async () => { @@ -117,11 +252,16 @@ describe('createMainMenu', () => { primaryAction: null, secondaryAction: null, canCreateFragment: false, + canCreateTask: false, }, view: { layoutMode: null, layoutModes: [], + contentSortField: null, + contentSortOrder: null, canToggleCompactMode: false, + canToggleHideCompletedTasks: false, + isHideCompletedTasksInFolders: false, canToggleMindmap: false, isCompactMode: false, isMindmapShown: false, @@ -155,6 +295,63 @@ describe('createMainMenu', () => { expect(compactModeItem).toBeUndefined() }) + it('adds a notes task creation shortcut in notes space', async () => { + const { createMainMenu } = await import('../main') + + const context: MainMenuContext = { + file: { + primaryAction: 'new-note', + secondaryAction: 'new-folder', + canCreateFragment: false, + canCreateTask: true, + }, + view: { + layoutMode: 'all-panels', + layoutModes: ['all-panels', 'list-editor', 'editor-only'], + contentSortField: 'createdAt', + contentSortOrder: 'DESC', + canToggleCompactMode: true, + canToggleHideCompletedTasks: false, + isHideCompletedTasksInFolders: false, + canToggleMindmap: false, + isCompactMode: false, + isMindmapShown: false, + canTogglePresentation: false, + isPresentationShown: false, + }, + editor: { + kind: 'notes', + noteMode: 'livePreview', + canSendRequest: false, + canFormat: false, + canPreviewCode: false, + isCodePreviewShown: false, + canPreviewJson: false, + isJsonPreviewShown: false, + canAdjustFontSize: true, + }, + } + + createMainMenu(context) + + const template = buildFromTemplate.mock.calls[0]?.[0] as Array<{ + label?: string + submenu?: Array<{ label?: string, accelerator?: string }> + }> + const fileMenu = template.find(item => item.label === 'menu:file.label') + + expect(fileMenu?.submenu?.map(item => item.label)).toEqual([ + 'action.new.note', + 'action.new.task', + 'action.new.folder', + ]) + expect( + fileMenu?.submenu?.find(item => item.label === 'action.new.task'), + ).toMatchObject({ + accelerator: 'CommandOrControl+T', + }) + }) + it('marks compact mode as checked when enabled', async () => { const { createMainMenu } = await import('../main') @@ -163,11 +360,16 @@ describe('createMainMenu', () => { primaryAction: 'new-sheet', secondaryAction: null, canCreateFragment: false, + canCreateTask: false, }, view: { layoutMode: null, layoutModes: [], + contentSortField: null, + contentSortOrder: null, canToggleCompactMode: true, + canToggleHideCompletedTasks: false, + isHideCompletedTasksInFolders: false, canToggleMindmap: false, isCompactMode: true, isMindmapShown: false, @@ -229,11 +431,16 @@ describe('createMainMenu', () => { primaryAction: null, secondaryAction: null, canCreateFragment: false, + canCreateTask: false, }, view: { layoutMode: 'all-panels', layoutModes: ['all-panels', 'list-editor', 'editor-only'], + contentSortField: 'createdAt', + contentSortOrder: 'ASC', canToggleCompactMode: false, + canToggleHideCompletedTasks: false, + isHideCompletedTasksInFolders: false, canToggleMindmap: false, isCompactMode: false, isMindmapShown: false, diff --git a/src/main/menu/main.ts b/src/main/menu/main.ts index ae4770233..bf45df917 100644 --- a/src/main/menu/main.ts +++ b/src/main/menu/main.ts @@ -1,5 +1,7 @@ /* eslint-disable node/prefer-global/process */ import type { + MainMenuContentSortField, + MainMenuContentSortOrder, MainMenuContext, MainMenuLayoutMode, MainMenuPrimaryAction, @@ -14,10 +16,9 @@ import { type MenuItemConstructorOptions, shell, } from 'electron' -import { repository } from '../../../package.json' import i18n from '../i18n' import { send } from '../ipc' -import { fetchUpdates } from '../updates' +import { checkForUpdatesFromMenu } from '../updates' import { createMenu, createPlatformMenuItems } from './utils' const year = new Date().getFullYear() @@ -30,11 +31,16 @@ const defaultMainMenuContext: MainMenuContext = { primaryAction: null, secondaryAction: null, canCreateFragment: false, + canCreateTask: false, }, view: { layoutMode: null, layoutModes: [], + contentSortField: null, + contentSortOrder: null, canToggleCompactMode: false, + canToggleHideCompletedTasks: false, + isHideCompletedTasksInFolders: false, canToggleMindmap: false, isCompactMode: false, isMindmapShown: false, @@ -83,33 +89,7 @@ const appMenuItems: MenuConfig[] = [ { id: 'update', label: i18n.t('menu:app.update'), - click: async () => { - const latestVersion = await fetchUpdates() - - if (latestVersion) { - const buttonId = dialog.showMessageBoxSync( - BrowserWindow.getFocusedWindow()!, - { - message: i18n.t('messages:update.available', { - newVersion: latestVersion, - oldVersion: version, - }), - buttons: [i18n.t('button.update.0'), i18n.t('button.update.1')], - defaultId: 0, - cancelId: 1, - }, - ) - - if (buttonId === 0) { - shell.openExternal(`${repository}/releases`) - } - } - else { - dialog.showMessageBoxSync(BrowserWindow.getFocusedWindow()!, { - message: i18n.t('messages:update.noAvailable'), - }) - } - }, + click: () => checkForUpdatesFromMenu(), }, { type: 'separator', @@ -340,6 +320,14 @@ function createFileMenuItems(context: MainMenuContext): MenuConfig[] { }) } + if (context.file.canCreateTask) { + items.push({ + label: i18n.t('action.new.task'), + accelerator: 'CommandOrControl+T', + click: () => send('main-menu:new-task'), + }) + } + if (context.file.secondaryAction) { items.push({ label: i18n.t('action.new.folder'), @@ -394,8 +382,64 @@ function createLayoutMenuItems(context: MainMenuContext): MenuConfig[] { })) } +function createSortMenuItems(context: MainMenuContext): MenuConfig[] { + if (!context.view.contentSortField || !context.view.contentSortOrder) { + return [] + } + + const sortLabels: Record = { + updatedAt: i18n.t('menu:view.sortBy.dateModified'), + createdAt: i18n.t('menu:view.sortBy.dateCreated'), + name: i18n.t('menu:view.sortBy.name'), + } + const orderLabels: Record = { + ASC: i18n.t('menu:view.sortOrder.ascending'), + DESC: i18n.t('menu:view.sortOrder.descending'), + } + + const sortItems = (Object.keys(sortLabels) as MainMenuContentSortField[]).map( + sortField => ({ + label: sortLabels[sortField], + type: 'radio' as const, + checked: context.view.contentSortField === sortField, + click: () => send('main-menu:set-content-sort-field', sortField), + }), + ) + const orderItems = ( + Object.keys(orderLabels) as MainMenuContentSortOrder[] + ).map(sortOrder => ({ + label: orderLabels[sortOrder], + type: 'radio' as const, + checked: context.view.contentSortOrder === sortOrder, + click: () => send('main-menu:set-content-sort-order', sortOrder), + })) + + return [ + { + label: i18n.t('menu:view.sortBy.label'), + enabled: false, + }, + ...sortItems, + { type: 'separator' as const }, + { + label: i18n.t('menu:view.sortOrder.label'), + enabled: false, + }, + ...orderItems, + ] +} + function createViewMenuItems(context: MainMenuContext): MenuConfig[] { const items = createLayoutMenuItems(context) + const sortItems = createSortMenuItems(context) + + if (sortItems.length) { + if (items.length) { + items.push({ type: 'separator' }) + } + + items.push(...sortItems) + } if (context.view.canToggleCompactMode) { if (items.length) { @@ -410,6 +454,19 @@ function createViewMenuItems(context: MainMenuContext): MenuConfig[] { }) } + if (context.view.canToggleHideCompletedTasks) { + if (items.length) { + items.push({ type: 'separator' }) + } + + items.push({ + label: i18n.t('menu:view.hideCompletedTasks'), + type: 'checkbox', + checked: context.view.isHideCompletedTasksInFolders, + click: () => send('main-menu:toggle-hide-completed-tasks'), + }) + } + if (context.view.canToggleMindmap || context.view.canTogglePresentation) { if (items.length) { items.push({ type: 'separator' }) @@ -497,6 +554,11 @@ function createEditorMenuItems(context: MainMenuContext): MenuConfig[] { accelerator: 'Shift+CommandOrControl+F', click: () => send('main-menu:format'), }) + items.push({ + label: i18n.t('menu:editor.normalizeTerminalOutput'), + enabled: true, + click: () => send('main-menu:normalize-code-line-breaks'), + }) items.push({ label: i18n.t('menu:editor.previewCode'), type: 'checkbox', @@ -521,6 +583,16 @@ function createEditorMenuItems(context: MainMenuContext): MenuConfig[] { click: () => send('main-menu:copy-note'), accelerator: 'CommandOrControl+Shift+C', }) + items.push({ + label: i18n.t('menu:editor.normalizeTerminalOutput'), + enabled: + context.editor.noteMode !== null + && context.editor.noteMode !== 'preview' + && context.view.canToggleMindmap + && !context.view.isMindmapShown + && !context.view.isPresentationShown, + click: () => send('main-menu:normalize-note-line-breaks'), + }) } if (context.editor.kind === 'http') { diff --git a/src/main/quitState.ts b/src/main/quitState.ts new file mode 100644 index 000000000..57a63419a --- /dev/null +++ b/src/main/quitState.ts @@ -0,0 +1,12 @@ +// Общий флаг «приложение реально завершается». На macOS обработчик 'close' +// прячет окно вместо выхода, поэтому путь установки апдейта (quitAndInstall) +// должен выставить флаг до закрытия окон, иначе выход блокируется. +let quitting = false + +export function setQuitting(value: boolean) { + quitting = value +} + +export function isQuitting() { + return quitting +} diff --git a/src/main/storage/contracts.ts b/src/main/storage/contracts.ts index 459f9a480..0c38112e0 100644 --- a/src/main/storage/contracts.ts +++ b/src/main/storage/contracts.ts @@ -92,11 +92,14 @@ export interface SnippetRecord { isDeleted: number createdAt: number updatedAt: number + /** Содержимое файла ещё не скачано облачным провайдером. */ + pendingCloudDownload?: boolean } export interface SnippetsQueryInput { search?: string searchNameOnly?: number + sort?: string order?: 'ASC' | 'DESC' folderId?: number tagId?: number @@ -170,7 +173,10 @@ export interface SnippetsStorage { input: SnippetContentCreateInput, ) => { id: number } deleteSnippet: (id: number) => { deleted: boolean } - deleteSnippetContent: (contentId: number) => { deleted: boolean } + deleteSnippetContent: ( + snippetId: number, + contentId: number, + ) => { deleted: boolean } deleteTagFromSnippet: ( snippetId: number, tagId: number, @@ -206,12 +212,15 @@ export interface NoteRecord { name: string description: string | null content: string + properties: Record tags: NoteTagRecord[] folder: NoteFolderInfo | null isFavorites: number isDeleted: number createdAt: number updatedAt: number + /** Содержимое файла ещё не скачано облачным провайдером. */ + pendingCloudDownload?: boolean } export interface NoteTagRecord { @@ -227,17 +236,28 @@ export interface NoteFolderInfo { export interface NotesQueryInput { search?: string searchNameOnly?: number + sort?: string order?: 'ASC' | 'DESC' folderId?: number tagId?: number isFavorites?: number isDeleted?: number isInbox?: number + propertyDue?: 'today' | 'upcoming' + propertyStatus?: string + propertyStatusNot?: string + propertyType?: string + hideCompletedTasks?: number + // Server-only флаг (не HTTP-параметр): дочитать тела заметок перед + // построением records — для потоков, которым нужен content (graph, + // dashboard). Список работает без тел. + withContent?: boolean } export interface NoteCreateInput { name: string folderId?: number | null + properties?: Record } export interface NoteUpdateInput { @@ -253,6 +273,11 @@ export interface NoteUpdateResult { notFound: boolean } +export interface NotePropertiesUpdateInput { + properties?: Record + unset?: string[] +} + export interface NoteTagRelationResult { notFound: false noteFound: boolean @@ -320,6 +345,10 @@ export interface NotesStorage { getNotesCounts: () => NotesCount updateNote: (id: number, input: NoteUpdateInput) => NoteUpdateResult updateNoteContent: (id: number, content: string) => NoteUpdateResult + updateNoteProperties: ( + id: number, + input: NotePropertiesUpdateInput, + ) => NoteUpdateResult } export interface NoteTagsStorage { @@ -353,6 +382,8 @@ export interface HttpFolderUpdateResult { export interface HttpRequestsQueryInput { search?: string searchNameOnly?: number + sort?: string + order?: 'ASC' | 'DESC' folderId?: number isFavorites?: number isDeleted?: number diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 2f0dffbd6..8b3f352c2 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -5,6 +5,7 @@ import type { } from './contracts' import { createMarkdownStorageProvider, + prepareMarkdownWatcher, startMarkdownWatcher, stopMarkdownWatcher, } from './providers/markdown' @@ -27,4 +28,4 @@ export function useHttpStorage(): HttpStorageProvider { return httpStorageProvider } -export { startMarkdownWatcher, stopMarkdownWatcher } +export { prepareMarkdownWatcher, startMarkdownWatcher, stopMarkdownWatcher } diff --git a/src/main/storage/providers/markdown/__tests__/doctor.test.ts b/src/main/storage/providers/markdown/__tests__/doctor.test.ts new file mode 100644 index 000000000..9107da4be --- /dev/null +++ b/src/main/storage/providers/markdown/__tests__/doctor.test.ts @@ -0,0 +1,575 @@ +import { Buffer } from 'node:buffer' +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import yaml from 'js-yaml' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { applyVaultDoctor, previewVaultDoctor } from '../doctor' +import { resetHttpRuntimeCache } from '../http/runtime' +import { resetNotesRuntimeCache } from '../notes/runtime' +import { waitForNotesAssetsMigrationForTests } from '../notes/runtime/assetsMigration' +import { resetRuntimeCache } from '../runtime' +import { setDatalessProbeForTests } from '../runtime/shared/cloudFiles' + +let tempVaultPath = '' + +vi.mock('electron-store', () => { + class MockStore { + private state: Record + + constructor(options?: { defaults?: Record }) { + this.state = { ...(options?.defaults || {}) } + } + + get(key?: string): unknown { + if (!key) { + return this.state + } + + return key.split('.').reduce((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined + } + + return (acc as Record)[segment] + }, this.state) + } + + set(key: string, value: unknown): void { + const segments = key.split('.') + let cursor: Record = this.state + + for (let index = 0; index < segments.length - 1; index += 1) { + const segment = segments[index] + const next = cursor[segment] + + if (!next || typeof next !== 'object') { + cursor[segment] = {} + } + + cursor = cursor[segment] as Record + } + + cursor[segments[segments.length - 1]] = value + } + } + + return { default: MockStore } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir(), + }, + BrowserWindow: { + getAllWindows: () => [], + }, +})) + +vi.mock('../../../../store', () => ({ + store: { + preferences: { + get: (key: string) => { + if (key === 'storage.vaultPath') { + return tempVaultPath + } + + return undefined + }, + }, + }, +})) + +function writeFile(relativePath: string, content: string): void { + const absolutePath = path.join(tempVaultPath, relativePath) + fs.ensureDirSync(path.dirname(absolutePath)) + fs.writeFileSync(absolutePath, content, 'utf8') +} + +function writeSparseFile(relativePath: string): string { + const absolutePath = path.join(tempVaultPath, relativePath) + fs.ensureDirSync(path.dirname(absolutePath)) + const descriptor = fs.openSync(absolutePath, 'w') + fs.ftruncateSync(descriptor, 4096) + fs.closeSync(descriptor) + return absolutePath +} + +function snippetSource(id: number, name: string): string { + return ['---', `id: ${id}`, `name: ${name}`, '---', '', 'body'].join('\n') +} + +function httpRequestSource(id: number, name: string): string { + return [ + '---', + `id: ${id}`, + `name: ${name}`, + 'method: GET', + 'url: https://example.com', + '---', + '', + ].join('\n') +} + +beforeEach(() => { + tempVaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'vault-doctor-')) +}) + +afterEach(() => { + setDatalessProbeForTests(null) + resetRuntimeCache() + resetNotesRuntimeCache() + resetHttpRuntimeCache() + fs.removeSync(tempVaultPath) + tempVaultPath = '' +}) + +describe('vault doctor', () => { + it('reports files missing from the state index as pending registrations', () => { + // Файл с валидным frontmatter-id, но не зарегистрированный в state: + // приложение его не отображает, doctor обязан это показать. + writeFile('code/Orphan.md', snippetSource(5, 'Orphan')) + + const result = previewVaultDoctor({ spaces: ['code'] }) + + const item = result.items.find( + candidate => + candidate.action === 'register-file' && candidate.path === 'Orphan.md', + ) + expect(item?.status).toBe('pending') + }) + + it('registers unindexed files on apply', () => { + writeFile('code/Orphan.md', snippetSource(5, 'Orphan')) + + applyVaultDoctor({ spaces: ['code'] }) + + const preview = previewVaultDoctor({ spaces: ['code'] }) + expect( + preview.items.filter(item => item.action === 'register-file'), + ).toHaveLength(0) + }) + + it('warns about notes with folderId pointing to a missing folder', () => { + writeFile('notes/Projects/.meta.yaml', 'id: 3\nname: Projects\n') + writeFile( + 'notes/Dangling.md', + ['---', 'id: 1', 'name: Dangling', 'folderId: 99', '---', 'body'].join( + '\n', + ), + ) + writeFile( + 'notes/Linked.md', + ['---', 'id: 2', 'name: Linked', 'folderId: 3', '---', 'body'].join('\n'), + ) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + + const danglingWarnings = result.warnings.filter( + warning => warning.code === 'DANGLING_FOLDER_ID', + ) + expect(danglingWarnings).toHaveLength(1) + expect(danglingWarnings[0].path).toBe('Dangling.md') + }) + + it('audits only referenced notes assets without changing their bytes', () => { + const legacyName = 'abcdefghijklmnop.png' + const equalName = 'ponmlkjihgfedcba.jpg' + const conflictName = 'abcdefghijklmnopqrstu.jpeg' + const missingName = 'ponmlkjihgfedcbazyxwv.png' + const healthyName = 'qrstuvwxyzabcdef.jpg' + const unknownName = 'zyxwvutsrqponmlk.png' + + writeFile(`notes/assets/${legacyName}`, 'legacy') + writeFile(`notes/assets/${equalName}`, 'equal') + writeFile(`notes/.masscode/assets/${equalName}`, 'equal') + writeFile(`notes/assets/${conflictName}`, 'source') + writeFile(`notes/.masscode/assets/${conflictName}`, 'destination') + writeFile(`notes/.masscode/assets/${healthyName}`, 'healthy') + writeFile(`notes/assets/${unknownName}`, 'unreferenced') + writeFile( + 'notes/Assets.md', + [legacyName, equalName, conflictName, missingName, healthyName] + .map(name => `![](masscode://notes-asset/${name})`) + .join('\n'), + ) + + const trackedPaths = [ + `notes/assets/${legacyName}`, + `notes/assets/${equalName}`, + `notes/.masscode/assets/${equalName}`, + `notes/assets/${conflictName}`, + `notes/.masscode/assets/${conflictName}`, + `notes/.masscode/assets/${healthyName}`, + `notes/assets/${unknownName}`, + ] + const before = trackedPaths.map(relativePath => + fs.readFileSync(path.join(tempVaultPath, relativePath)), + ) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + const assetWarnings = result.warnings.filter(warning => + warning.code.startsWith('NOTES_'), + ) + + expect( + assetWarnings.map(warning => [ + warning.code, + warning.details?.assetName, + warning.path, + ]), + ).toEqual([ + ['NOTES_LEGACY_ASSET', legacyName, 'Assets.md'], + ['NOTES_LEGACY_ASSET', equalName, 'Assets.md'], + ['NOTES_ASSET_DESTINATION_CONFLICT', conflictName, 'Assets.md'], + ['NOTES_ASSET_MISSING', missingName, 'Assets.md'], + ]) + expect( + assetWarnings.every( + warning => + Object.keys(warning.details ?? {}) + .sort() + .join(',') === 'assetName,destination,reason,source', + ), + ).toBe(true) + trackedPaths.forEach((relativePath, index) => { + expect(fs.readFileSync(path.join(tempVaultPath, relativePath))).toEqual( + before[index], + ) + }) + }) + + it('reports placeholders as pending and one missing warning per note', () => { + const pendingName = 'abcdefghijklmnop.png' + const missingName = 'ponmlkjihgfedcba.jpg' + const unreadableName = 'qrstuvwxyzabcdef.png' + const pendingPath = writeSparseFile(`notes/assets/${pendingName}`) + const unreadableNotePath = path.join(tempVaultPath, 'notes/Unreadable.md') + writeFile( + 'notes/One.md', + `![](masscode://notes-asset/${pendingName})\n![](masscode://notes-asset/${missingName})`, + ) + writeFile('notes/Two.md', `![](masscode://notes-asset/${missingName})`) + writeFile( + 'notes/Unreadable.md', + `![](masscode://notes-asset/${unreadableName})`, + ) + fs.chmodSync(unreadableNotePath, 0o000) + setDatalessProbeForTests(absolutePath => absolutePath === pendingPath) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'NOTES_ASSET_MIGRATION_PENDING', + details: expect.objectContaining({ + assetName: pendingName, + reason: 'source-placeholder', + }), + path: 'One.md', + }), + ]), + ) + expect( + result.warnings + .filter( + warning => + warning.code === 'NOTES_ASSET_MISSING' + && warning.details?.assetName === missingName, + ) + .map(warning => warning.path) + .sort(), + ).toEqual(['One.md', 'Two.md']) + expect( + result.warnings.some( + warning => warning.details?.assetName === unreadableName, + ), + ).toBe(false) + }) + + it('treats non-regular asset paths as pending without reading them', () => { + const sourceDirectoryName = 'abcdefghijklmnop.png' + const destinationDirectoryName = 'ponmlkjihgfedcba.jpg' + fs.ensureDirSync( + path.join(tempVaultPath, 'notes/assets', sourceDirectoryName), + ) + fs.ensureDirSync( + path.join( + tempVaultPath, + 'notes/.masscode/assets', + destinationDirectoryName, + ), + ) + writeFile( + 'notes/Directories.md', + [sourceDirectoryName, destinationDirectoryName] + .map(name => `![](masscode://notes-asset/${name})`) + .join('\n'), + ) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + + expect( + result.warnings + .filter(warning => warning.path === 'Directories.md') + .map(warning => warning.details?.reason), + ).toEqual(['source-not-regular', 'destination-not-regular']) + }) + + it.skipIf(process.platform === 'win32')( + 'treats source and destination symlinks as pending without touching targets', + () => { + const sourceSymlinkName = 'qrstuvwxyzabcdef.png' + const destinationSymlinkName = 'fedcbazyxwvutsrq.jpg' + const externalPath = path.join(tempVaultPath, 'external.bin') + const externalBytes = Buffer.from([0x10, 0x20, 0x30, 0x40]) + fs.writeFileSync(externalPath, externalBytes) + fs.ensureDirSync(path.join(tempVaultPath, 'notes/assets')) + fs.ensureDirSync(path.join(tempVaultPath, 'notes/.masscode/assets')) + fs.symlinkSync( + externalPath, + path.join(tempVaultPath, 'notes/assets', sourceSymlinkName), + ) + fs.symlinkSync( + externalPath, + path.join( + tempVaultPath, + 'notes/.masscode/assets', + destinationSymlinkName, + ), + ) + writeFile( + 'notes/Symlinks.md', + [sourceSymlinkName, destinationSymlinkName] + .map(name => `![](masscode://notes-asset/${name})`) + .join('\n'), + ) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + + expect( + result.warnings + .filter(warning => warning.path === 'Symlinks.md') + .map(warning => warning.details?.reason), + ).toEqual(['source-symlink', 'destination-symlink']) + expect(fs.readFileSync(externalPath)).toEqual(externalBytes) + }, + ) + + it('does not migrate referenced legacy assets while applying Notes repairs', async () => { + const assetName = 'abcdefghijklmnop.png' + const sourcePath = path.join(tempVaultPath, 'notes/assets', assetName) + const destinationPath = path.join( + tempVaultPath, + 'notes/.masscode/assets', + assetName, + ) + const assetBytes = Buffer.from([0x01, 0x02, 0xFE, 0xFF]) + fs.ensureDirSync(path.dirname(sourcePath)) + fs.writeFileSync(sourcePath, assetBytes) + writeFile('notes/Repair.md', `![](masscode://notes-asset/${assetName})`) + + applyVaultDoctor({ spaces: ['notes'] }) + await waitForNotesAssetsMigrationForTests() + await Promise.resolve() + + expect(fs.readFileSync(sourcePath)).toEqual(assetBytes) + expect(fs.pathExistsSync(destinationPath)).toBe(false) + }) + + it('reports duplicate code snippet ids as conflicts that need a decision', () => { + writeFile('code/One.md', snippetSource(10, 'One')) + writeFile('code/Two.md', snippetSource(10, 'Two')) + + const result = previewVaultDoctor({ spaces: ['code'] }) + + expect(result.conflictGroups).toHaveLength(1) + expect(result.conflictGroups[0].reason).toBe('duplicate-id') + expect(result.conflictGroups[0].items.map(item => item.status)).toEqual([ + 'needs-decision', + 'needs-decision', + ]) + }) + + it('reassigns duplicate code snippet ids after a keep-id decision', () => { + writeFile('code/One.md', snippetSource(10, 'One')) + writeFile('code/Two.md', snippetSource(10, 'Two')) + + const preview = previewVaultDoctor({ spaces: ['code'] }) + const result = applyVaultDoctor({ + decisions: [ + { + groupId: preview.conflictGroups[0].id, + keepPath: 'One.md', + }, + ], + spaces: ['code'], + }) + + const one = fs.readFileSync( + path.join(tempVaultPath, 'code/One.md'), + 'utf8', + ) + const two = fs.readFileSync( + path.join(tempVaultPath, 'code/Two.md'), + 'utf8', + ) + const after = previewVaultDoctor({ spaces: ['code'] }) + + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: 'reassign-id', + path: 'Two.md', + status: 'applied', + }), + ]), + ) + expect(one).toContain('id: 10') + expect(two).toContain('id: 11') + expect(two).toContain('body') + expect(after.conflictGroups).toHaveLength(0) + }) + + it('includes code inbox files in duplicate id detection', () => { + writeFile('code/Live.md', snippetSource(10, 'Live')) + writeFile('code/.masscode/inbox/Inbox.md', snippetSource(10, 'Inbox')) + + const result = previewVaultDoctor({ spaces: ['code'] }) + + expect(result.conflictGroups).toHaveLength(1) + expect( + result.conflictGroups[0].items.map(item => item.path).sort(), + ).toEqual(['.masscode/inbox/Inbox.md', 'Live.md']) + }) + + it('ignores notes trash files in duplicate id detection', () => { + writeFile('notes/Live.md', snippetSource(10, 'Live')) + writeFile('notes/.masscode/trash/Deleted.md', snippetSource(10, 'Deleted')) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + + expect(result.conflictGroups).toHaveLength(0) + }) + + it('blocks files with merge markers instead of parsing them as entities', () => { + writeFile( + 'notes/Conflict.md', + [ + '<<<<<<< HEAD', + 'name: local', + '=======', + 'name: remote', + '>>>>>>> branch', + ].join('\n'), + ) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + + expect(result.conflictGroups).toHaveLength(1) + expect(result.conflictGroups[0].reason).toBe('merge-markers') + expect(result.conflictGroups[0].items[0].status).toBe('blocked') + }) + + it('blocks standalone git separator markers', () => { + writeFile( + 'notes/Conflict.md', + ['---', 'id: 1', '---', '======='].join('\n'), + ) + + const result = previewVaultDoctor({ spaces: ['notes'] }) + + expect(result.conflictGroups).toHaveLength(1) + expect(result.conflictGroups[0].reason).toBe('merge-markers') + }) + + it('repairs http environment state while request conflicts remain unresolved', () => { + writeFile('http/One.md', httpRequestSource(10, 'One')) + writeFile('http/Two.md', httpRequestSource(10, 'Two')) + writeFile( + 'http/.state.yaml', + yaml.dump({ + activeEnvironmentId: 99, + counters: { + environmentId: 99, + folderId: 0, + historyId: 0, + requestId: 10, + }, + environments: [ + { + id: 1, + name: 'Broken', + }, + ], + folders: [], + history: [], + requests: [], + version: 1, + }), + ) + + const result = applyVaultDoctor({ spaces: ['http'] }) + const repaired = yaml.load( + fs.readFileSync(path.join(tempVaultPath, 'http/.state.yaml'), 'utf8'), + ) as { + activeEnvironmentId: number | null + environments: Array<{ variables: Record }> + } + + expect(result.conflictGroups).toHaveLength(1) + expect(result.conflictGroups[0].reason).toBe('duplicate-id') + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: 'repair-environment-state', + status: 'applied', + }), + ]), + ) + expect(repaired.activeEnvironmentId).toBeNull() + expect(repaired.environments[0].variables).toEqual({}) + }) + + it('repairs duplicate math sheet ids as a safe state-level fix', () => { + writeFile( + 'math/.state.yaml', + yaml.dump({ + activeSheetId: 'sheet-1', + sheets: [ + { + content: '1 + 1', + createdAt: 1, + id: 'sheet-1', + name: 'One', + updatedAt: 1, + }, + { + content: '2 + 2', + createdAt: 2, + id: 'sheet-1', + name: 'Two', + updatedAt: 2, + }, + ], + }), + ) + + const preview = previewVaultDoctor({ spaces: ['math'] }) + expect(preview.items).toMatchObject([ + { + action: 'repair-math-state', + status: 'pending', + }, + ]) + + applyVaultDoctor({ spaces: ['math'] }) + + const repaired = yaml.load( + fs.readFileSync(path.join(tempVaultPath, 'math/.state.yaml'), 'utf8'), + ) as { sheets: Array<{ id: string }> } + + expect(new Set(repaired.sheets.map(sheet => sheet.id)).size).toBe(2) + }) +}) diff --git a/src/main/storage/providers/markdown/__tests__/drawings.test.ts b/src/main/storage/providers/markdown/__tests__/drawings.test.ts new file mode 100644 index 000000000..13d5ce0bb --- /dev/null +++ b/src/main/storage/providers/markdown/__tests__/drawings.test.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createDrawing, + deleteDrawing, + duplicateDrawing, + listDrawings, + readDrawing, + renameDrawing, + sanitizeDrawingName, + wasRecentAppDrawingChange, + writeDrawing, +} from '../drawings' +import { isDrawingsWatchPath, shouldIgnoreWatchPath } from '../watcherPaths' + +describe('drawings storage', () => { + let vaultPath: string + + beforeEach(() => { + vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'masscode-drawings-')) + }) + + afterEach(() => { + fs.rmSync(vaultPath, { force: true, recursive: true }) + }) + + it('creates drawings with unique indexed names', async () => { + const first = await createDrawing(vaultPath) + const second = await createDrawing(vaultPath) + + expect(first.name).toBe('Untitled') + expect(second.name).toBe('Untitled 2') + expect( + fs.existsSync(path.join(vaultPath, 'drawings', 'Untitled.excalidraw')), + ).toBe(true) + + const content = await readDrawing(vaultPath, first.id) + expect(content).toContain('"type": "excalidraw"') + }) + + it('lists drawings sorted by name', async () => { + await createDrawing(vaultPath, 'Beta') + await createDrawing(vaultPath, 'Alpha') + + const items = await listDrawings(vaultPath) + + expect(items.map(item => item.name)).toEqual(['Alpha', 'Beta']) + }) + + it('writes and reads drawing content', async () => { + const record = await createDrawing(vaultPath, 'Scene') + const content = '{"type":"excalidraw","version":2,"elements":[]}' + + const result = await writeDrawing(vaultPath, record.id, content) + + expect(result.updatedAt).toBeGreaterThan(0) + expect(await readDrawing(vaultPath, record.id)).toBe(content) + }) + + it('renames a drawing and resolves name conflicts', async () => { + await createDrawing(vaultPath, 'Taken') + const record = await createDrawing(vaultPath, 'Source') + + const renamed = await renameDrawing(vaultPath, record.id, 'Taken') + + expect(renamed?.name).toBe('Taken 2') + expect(await readDrawing(vaultPath, 'Source')).toBeNull() + expect(await readDrawing(vaultPath, 'Taken 2')).not.toBeNull() + }) + + it('keeps the name on a case-only rename instead of suffixing it', async () => { + const record = await createDrawing(vaultPath, 'scene') + + const renamed = await renameDrawing(vaultPath, record.id, 'Scene') + + expect(renamed?.name).toBe('Scene') + expect(await listDrawings(vaultPath)).toHaveLength(1) + }) + + it('duplicates and deletes drawings', async () => { + const record = await createDrawing(vaultPath, 'Original') + const copy = await duplicateDrawing(vaultPath, record.id) + + expect(copy?.name).toBe('Original 2') + expect(await listDrawings(vaultPath)).toHaveLength(2) + + expect(await deleteDrawing(vaultPath, copy!.id)).toEqual({ deleted: true }) + expect(await deleteDrawing(vaultPath, copy!.id)).toEqual({ + deleted: false, + }) + expect(await listDrawings(vaultPath)).toHaveLength(1) + }) + + it('remembers own writes for watcher echo suppression', async () => { + const record = await createDrawing(vaultPath, 'Echo') + await writeDrawing(vaultPath, record.id, '{}') + + expect( + wasRecentAppDrawingChange(vaultPath, 'drawings/Echo.excalidraw'), + ).toBe(true) + expect( + wasRecentAppDrawingChange(vaultPath, 'drawings/Other.excalidraw'), + ).toBe(false) + }) + + it('sanitizes invalid drawing names', () => { + expect(sanitizeDrawingName(' My / Draft: v2? ')).toBe('My Draft v2') + expect(sanitizeDrawingName('...')).toBe('Untitled') + expect(sanitizeDrawingName(null)).toBe('Untitled') + expect(sanitizeDrawingName('con')).toBe('con 1') + }) + + it('rejects drawing ids escaping the drawings directory', async () => { + await expect(readDrawing(vaultPath, '../outside')).rejects.toThrow( + /INVALID_NAME/, + ) + }) + + it('keeps drawings files observable by the watcher', () => { + expect( + shouldIgnoreWatchPath('/vault', '/vault/drawings/Scene.excalidraw'), + ).toBe(false) + expect(isDrawingsWatchPath('drawings/Scene.excalidraw')).toBe(true) + }) +}) diff --git a/src/main/storage/providers/markdown/__tests__/notesAssetEvents.test.ts b/src/main/storage/providers/markdown/__tests__/notesAssetEvents.test.ts new file mode 100644 index 000000000..95740a57c --- /dev/null +++ b/src/main/storage/providers/markdown/__tests__/notesAssetEvents.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' + +const send = vi.fn() + +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [{ webContents: { send } }], + }, +})) + +describe('notes asset ready events', () => { + it('broadcasts a targeted completion immediately', async () => { + const { broadcastNotesAssetReady } = await import('../notesAssetEvents') + broadcastNotesAssetReady('abcdefghijklmnop.png') + + expect(send).toHaveBeenCalledTimes(1) + expect(send).toHaveBeenCalledWith( + 'system:notes-asset-ready', + 'abcdefghijklmnop.png', + ) + }) +}) diff --git a/src/main/storage/providers/markdown/__tests__/watcher.test.ts b/src/main/storage/providers/markdown/__tests__/watcher.test.ts index 289585b17..32cac1ce1 100644 --- a/src/main/storage/providers/markdown/__tests__/watcher.test.ts +++ b/src/main/storage/providers/markdown/__tests__/watcher.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { + getManagedNotesAssetName, getWatchPathSpaceId, isCodeWatchPath, isHttpWatchPath, + isManagedNotesAssetsPath, isMathWatchPath, isNotesWatchPath, normalizeRelativeWatchPath, @@ -56,6 +58,25 @@ describe('watcher routing', () => { ) }) + it('routes managed Notes assets without hiding legacy assets', () => { + expect( + getManagedNotesAssetName('notes/.masscode/assets/abcdefghijklmnop.png'), + ).toBe('abcdefghijklmnop.png') + expect( + getManagedNotesAssetName('notes/assets/abcdefghijklmnop.png'), + ).toBeNull() + expect(isManagedNotesAssetsPath('notes/.masscode/assets')).toBe(true) + expect( + isManagedNotesAssetsPath('notes/.masscode/assets/nested/file.png'), + ).toBe(true) + expect( + shouldIgnoreWatchPath( + vaultRoot, + '/vault/notes/assets/abcdefghijklmnop.png', + ), + ).toBe(false) + }) + it('ignores hidden non-space paths', () => { expect(shouldIgnoreWatchPath(vaultRoot, '/vault/.git/config')).toBe(true) expect(shouldIgnoreWatchPath(vaultRoot, '/vault/random/.cache/file')).toBe( diff --git a/src/main/storage/providers/markdown/__tests__/watcherLifecycle.test.ts b/src/main/storage/providers/markdown/__tests__/watcherLifecycle.test.ts new file mode 100644 index 000000000..8e3c86a5a --- /dev/null +++ b/src/main/storage/providers/markdown/__tests__/watcherLifecycle.test.ts @@ -0,0 +1,356 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +interface CloudDownloadConfiguration { + onDownloaded: (absolutePath: string) => void + onQueueActivity: () => void +} + +async function flushImmediate(): Promise { + await new Promise(resolve => setImmediate(resolve)) + await Promise.resolve() +} + +async function setup() { + vi.resetModules() + + const vaultRootPath = '/vault' + const paths = { + inboxDirPath: '/vault/code/.masscode/inbox', + metaDirPath: '/vault/code/.masscode', + statePath: '/vault/code/.masscode/state.json', + trashDirPath: '/vault/code/.masscode/trash', + vaultPath: '/vault/code', + } + const notesPaths = { + assetsPath: '/vault/notes/.masscode/assets', + inboxDirPath: '/vault/notes/.masscode/inbox', + legacyAssetsPath: '/vault/notes/assets', + metaDirPath: '/vault/notes/.masscode', + notesRoot: '/vault/notes', + statePath: '/vault/notes/.masscode/state.json', + trashDirPath: '/vault/notes/.masscode/trash', + } + const httpPaths = { + httpRoot: '/vault/http', + statePath: '/vault/http/.state.yaml', + } + const cloudConfigurations: CloudDownloadConfiguration[] = [] + const send = vi.fn() + const log = vi.fn() + const broadcastNotesAssetReady = vi.fn() + const getPaths = vi.fn(() => paths) + const getVaultPath = vi.fn(() => vaultRootPath) + const ensureStateFile = vi.fn() + const syncRuntimeWithDisk = vi.fn() + const syncNotesRuntimeWithDisk = vi.fn() + const scheduleNotesAssetsMigration = vi.fn() + const peekNotesRuntimeCache = vi.fn( + (): { paths: typeof notesPaths } | null => null, + ) + const syncHttpRuntimeWithDisk = vi.fn() + const resetCloudDownloads = vi.fn() + const getPendingCloudPaths = vi.fn(() => ['/vault/.masscode/state.json']) + const configureCloudDownloads = vi.fn( + (configuration: CloudDownloadConfiguration) => { + cloudConfigurations.push(configuration) + }, + ) + const watcherHandlers = new Map void>() + const watcher = { + close: vi.fn(), + on: vi.fn((event: string, handler: (changedPath: string) => void) => { + watcherHandlers.set(event, handler) + return watcher + }), + } + const watch = vi.fn(() => watcher) + const importEsm = vi.fn(async () => ({ watch })) + + vi.doMock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [{ webContents: { send } }], + }, + })) + + vi.doMock('../../../../utils', () => ({ + importEsm, + log, + })) + + vi.doMock('../../../../dockBadge', () => ({ + scheduleDockBadgeRefresh: vi.fn(), + })) + + vi.doMock('../cloudDownloads', () => ({ + configureCloudDownloads, + getPendingCloudPaths, + resetCloudDownloads, + })) + + vi.doMock('../drawings', () => ({ + wasRecentAppDrawingChange: vi.fn(() => false), + })) + + vi.doMock('../http', () => ({ + getHttpPaths: vi.fn(() => httpPaths), + peekHttpRuntimeCache: vi.fn(() => null), + resetHttpRuntimeCache: vi.fn(), + syncHttpRuntimeWithDisk, + })) + + vi.doMock('../notes/runtime', () => ({ + getNotesAssetNameFromAbsolutePath: vi.fn( + (_paths: typeof notesPaths, absolutePath: string) => { + const parentPath = absolutePath.slice(0, absolutePath.lastIndexOf('/')) + return parentPath === notesPaths.assetsPath + || parentPath === notesPaths.legacyAssetsPath + ? absolutePath.slice(absolutePath.lastIndexOf('/') + 1) + : null + }, + ), + getNotesPaths: vi.fn(() => notesPaths), + parseNotesAssetName: vi.fn((fileName: string) => ({ fileName })), + peekNotesRuntimeCache, + refreshPendingNoteFiles: vi.fn(() => ({ changed: false, remaining: 0 })), + resetNotesPathsCache: vi.fn(), + resetNotesRuntimeCache: vi.fn(), + scheduleNotesAssetsMigration, + syncNoteFileWithDisk: vi.fn(), + syncNotesRuntimeWithDisk, + })) + + vi.doMock('../notesAssetEvents', () => ({ + broadcastNotesAssetReady, + })) + + vi.doMock('../runtime', () => ({ + ensureStateFile, + getPaths, + getVaultPath, + peekRuntimeCache: vi.fn(() => null), + refreshPendingSnippetFiles: vi.fn(() => ({ + changed: false, + remaining: 0, + })), + resetPathsCache: vi.fn(), + resetRuntimeCache: vi.fn(), + syncRuntimeWithDisk, + syncSnippetFileWithDisk: vi.fn(), + })) + + vi.doMock('../runtime/shared/appChanges', () => ({ + wasRecentAppFileChange: vi.fn(() => false), + })) + + vi.doMock('../runtime/shared/cloudFiles', () => ({ + getFileAvailability: vi.fn(() => ({ + exists: true, + isCloudPlaceholder: false, + stats: null, + })), + })) + + vi.doMock('../runtime/shared/guardedRead', () => ({ + isCloudFileNotDownloadedError: (error: unknown) => + error instanceof Error + && error.message.startsWith('CLOUD_FILE_NOT_DOWNLOADED'), + })) + + const { prepareMarkdownWatcher, startMarkdownWatcher, stopMarkdownWatcher } + = await import('../watcher') + + return { + cloudConfigurations, + broadcastNotesAssetReady, + configureCloudDownloads, + ensureStateFile, + getPaths, + getPendingCloudPaths, + importEsm, + log, + notesPaths, + paths, + peekNotesRuntimeCache, + prepareMarkdownWatcher, + resetCloudDownloads, + scheduleNotesAssetsMigration, + send, + startMarkdownWatcher, + stopMarkdownWatcher, + syncHttpRuntimeWithDisk, + syncNotesRuntimeWithDisk, + syncRuntimeWithDisk, + watch, + watcherHandlers, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('markdown watcher cloud bootstrap', () => { + it('broadcasts an asset hydrated before the watcher starts', async () => { + const context = await setup() + + context.prepareMarkdownWatcher() + context.cloudConfigurations[0].onDownloaded( + '/vault/notes/.masscode/assets/abcdefghijklmnop.png', + ) + + expect(context.broadcastNotesAssetReady).toHaveBeenCalledWith( + 'abcdefghijklmnop.png', + ) + }) + + it('preserves the prepared cloud queue on the initial start', async () => { + const context = await setup() + + context.prepareMarkdownWatcher() + context.startMarkdownWatcher() + await flushImmediate() + + expect(context.resetCloudDownloads).not.toHaveBeenCalled() + expect(context.configureCloudDownloads).toHaveBeenCalledTimes(2) + }) + + it('resets cloud downloads on a later stop and restart', async () => { + const context = await setup() + + context.prepareMarkdownWatcher() + context.startMarkdownWatcher() + await flushImmediate() + context.stopMarkdownWatcher() + context.startMarkdownWatcher() + + expect(context.resetCloudDownloads).toHaveBeenCalledTimes(2) + }) + + it('cancels a pending initial start when start is called again', async () => { + const context = await setup() + + context.prepareMarkdownWatcher() + context.startMarkdownWatcher() + context.startMarkdownWatcher() + await flushImmediate() + + expect(context.resetCloudDownloads).toHaveBeenCalledTimes(1) + expect(context.watch).toHaveBeenCalledTimes(1) + }) + + it('routes managed watch and asset hydration events without Notes sync', async () => { + const context = await setup() + context.startMarkdownWatcher() + await flushImmediate() + expect(context.syncNotesRuntimeWithDisk).toHaveBeenCalledTimes(1) + context.peekNotesRuntimeCache.mockReturnValue({ + paths: context.notesPaths, + }) + + context.watcherHandlers.get('change')?.( + '/vault/notes/.masscode/assets/abcdefghijklmnop.png', + ) + context.cloudConfigurations[0].onDownloaded( + '/vault/notes/assets/ponmlkjihgfedcba.png', + ) + + expect(context.broadcastNotesAssetReady).toHaveBeenNthCalledWith( + 1, + 'abcdefghijklmnop.png', + ) + expect(context.broadcastNotesAssetReady).toHaveBeenNthCalledWith( + 2, + 'ponmlkjihgfedcba.png', + ) + expect(context.syncNotesRuntimeWithDisk).toHaveBeenCalledTimes(1) + expect(context.scheduleNotesAssetsMigration).toHaveBeenCalledTimes(1) + }) + + it('retries startup after a legacy cloud state file is hydrated', async () => { + const context = await setup() + context.getPaths + .mockImplementationOnce(() => { + throw new Error('CLOUD_FILE_NOT_DOWNLOADED: legacy state') + }) + .mockReturnValue(context.paths) + + expect(() => context.startMarkdownWatcher()).not.toThrow() + + const recovery = context.cloudConfigurations[0] + expect(recovery).toBeDefined() + recovery.onDownloaded('/vault/.masscode/state.json') + await flushImmediate() + + expect(context.getPaths).toHaveBeenCalledTimes(2) + expect(context.ensureStateFile).toHaveBeenCalledWith(context.paths) + expect(context.syncRuntimeWithDisk).toHaveBeenCalledWith(context.paths) + expect(context.syncNotesRuntimeWithDisk).toHaveBeenCalledTimes(1) + expect(context.syncHttpRuntimeWithDisk).toHaveBeenCalledTimes(1) + expect(context.importEsm).toHaveBeenCalledWith('chokidar') + expect(context.watch).toHaveBeenCalledWith( + '/vault', + expect.objectContaining({ ignoreInitial: true, persistent: true }), + ) + expect(context.send).toHaveBeenCalledWith('system:storage-synced') + }) + + it('keeps waiting when another cloud state file blocks the retry', async () => { + const context = await setup() + context.getPaths.mockImplementation(() => { + throw new Error('CLOUD_FILE_NOT_DOWNLOADED: legacy state') + }) + + context.startMarkdownWatcher() + context.cloudConfigurations[0].onDownloaded('/vault/.masscode/state.json') + await flushImmediate() + + expect(context.getPaths).toHaveBeenCalledTimes(2) + expect(context.configureCloudDownloads).toHaveBeenCalledTimes(2) + expect(context.ensureStateFile).not.toHaveBeenCalled() + expect(context.send).not.toHaveBeenCalledWith('system:storage-synced') + }) + + it('retries once when hydration finished before recovery was configured', async () => { + const context = await setup() + context.getPendingCloudPaths.mockReturnValue([]) + context.getPaths + .mockImplementationOnce(() => { + throw new Error('CLOUD_FILE_NOT_DOWNLOADED: legacy state') + }) + .mockReturnValue(context.paths) + + context.startMarkdownWatcher() + await flushImmediate() + + expect(context.getPaths).toHaveBeenCalledTimes(2) + expect(context.ensureStateFile).toHaveBeenCalledWith(context.paths) + expect(context.send).toHaveBeenCalledWith('system:storage-synced') + }) + + it('ignores a hydration callback after watcher shutdown', async () => { + const context = await setup() + context.getPaths.mockImplementationOnce(() => { + throw new Error('CLOUD_FILE_NOT_DOWNLOADED: legacy state') + }) + + context.startMarkdownWatcher() + const recovery = context.cloudConfigurations[0] + context.stopMarkdownWatcher() + recovery.onDownloaded('/vault/.masscode/state.json') + await flushImmediate() + + expect(context.getPaths).toHaveBeenCalledTimes(1) + expect(context.send).not.toHaveBeenCalledWith('system:storage-synced') + }) + + it('still propagates non-cloud startup errors', async () => { + const context = await setup() + context.getPaths.mockImplementationOnce(() => { + throw new Error('EACCES') + }) + + expect(() => context.startMarkdownWatcher()).toThrow('EACCES') + expect(context.configureCloudDownloads).not.toHaveBeenCalled() + expect(context.log).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/storage/providers/markdown/cloudDownloads.ts b/src/main/storage/providers/markdown/cloudDownloads.ts new file mode 100644 index 000000000..ce07f9618 --- /dev/null +++ b/src/main/storage/providers/markdown/cloudDownloads.ts @@ -0,0 +1,446 @@ +import type { ChildProcess } from 'node:child_process' +import type { Stats } from 'node:fs' +import { spawn } from 'node:child_process' +import process from 'node:process' +import { BrowserWindow } from 'electron' +import fs from 'fs-extra' +import { log } from '../../../utils' +import { + getFileAvailability, + markFileReadableDespiteZeroBlocks, + resetCloudFileExemptions, +} from './runtime/shared/cloudFiles' + +// Фоновая докачка облачных плейсхолдеров (iCloud Drive, Dropbox, OneDrive, +// Google Drive). Главный принцип: main process никогда не читает +// недокачанный файл синхронно. Вместо этого файл попадает в очередь, +// материализация выполняется вне event loop, а по завершении вызывается +// инкрементальный sync через зарегистрированный обработчик. +// +// Механика материализации: для iCloud скачивание запускается через +// `brctl download` (без блокировки процессов), для остальных провайдеров +// содержимое вычитывает отдельный child process (чтение данных заставляет +// провайдера докачать файл; зависший child убивается по таймауту, что +// невозможно для потоков внутри main process). Факт готовности определяется +// либо успешным выходом child-ридера (файл дочитан до конца), либо +// поллингом stat: у докачанного файла появляются блоки на диске, причём +// сигнатура должна быть стабильной в двух опросах подряд, чтобы не принять +// частично скачанный файл за готовый. + +const MAX_CONCURRENT_DOWNLOADS = 3 +const MAX_DOWNLOAD_ATTEMPTS = 3 +const DOWNLOAD_TIMEOUT_MS = 5 * 60_000 +const HYDRATION_POLL_INTERVAL_MS = 500 +const RETRY_DELAY_MS = 5_000 +// Файл из failedPaths снова допускается в очередь после паузы: сеть могла +// восстановиться, а вечная блокировка потребовала бы перезапуска приложения. +const FAILED_RETRY_COOLDOWN_MS = 10 * 60_000 +// Файл, который очередь раз за разом признаёт «уже локальным», но который +// после этого снова попадает в очередь, читается с ошибкой не из-за облака +// (EACCES, EIO): без предела это бесконечный цикл sync -> enqueue. +const MAX_IMMEDIATE_COMPLETIONS = 3 + +// Читает файл потоково и выходит: сам факт чтения заставляет облачного +// провайдера материализовать содержимое. Запускается как ELECTRON_RUN_AS_NODE. +const HYDRATION_READER_SCRIPT = [ + 'const fs = require(\'node:fs\')', + 'const stream = fs.createReadStream(process.env.MASSCODE_HYDRATE_PATH)', + 'stream.on(\'data\', () => {})', + 'stream.on(\'end\', () => process.exit(0))', + 'stream.on(\'error\', () => process.exit(1))', +].join('\n') + +export interface CloudDownloadStatus { + downloaded: number + downloading: number + failed: number + queued: number +} + +interface StableProbe { + blocks: number + mtimeMs: number + size: number +} + +interface ActiveDownload { + pollTimer: NodeJS.Timeout + previousProbe: StableProbe | null + readerChild: ChildProcess | null + timeoutTimer: NodeJS.Timeout +} + +type CloudFileDownloadedHandler = (absolutePath: string) => void + +let onFileDownloaded: CloudFileDownloadedHandler | null = null +let onQueueActivity: (() => void) | null = null +let queue: { absolutePath: string, attempts: number }[] = [] +const queuedPaths = new Set() +const activeDownloads = new Map() +const retryTimers = new Set() +const failedAtByPath = new Map() +const immediateCompletionsByPath = new Map() +let downloadedCount = 0 + +export function getCloudDownloadStatus(): CloudDownloadStatus { + return { + downloaded: downloadedCount, + downloading: activeDownloads.size, + failed: failedAtByPath.size, + queued: queue.length, + } +} + +function broadcastStatus(): void { + const status = getCloudDownloadStatus() + + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send('system:cloud-download-progress', status) + }) +} + +export function configureCloudDownloads(options: { + onDownloaded: CloudFileDownloadedHandler + onQueueActivity: () => void +}): void { + onFileDownloaded = options.onDownloaded + onQueueActivity = options.onQueueActivity +} + +// Все пути, докачка которых ещё не завершена (в очереди, активные или +// отложенные после неудачи). Self-heal перепроверяет их напрямую, не +// полагаясь на fs-события, которых материализация iCloud не порождает. +export function getPendingCloudPaths(): string[] { + return [...queuedPaths, ...activeDownloads.keys(), ...failedAtByPath.keys()] +} + +export function resetCloudDownloads(): void { + for (const [, download] of activeDownloads) { + stopActiveDownload(download) + } + + for (const timer of retryTimers) { + clearTimeout(timer) + } + + activeDownloads.clear() + retryTimers.clear() + queue = [] + queuedPaths.clear() + failedAtByPath.clear() + immediateCompletionsByPath.clear() + downloadedCount = 0 + onFileDownloaded = null + onQueueActivity = null + resetCloudFileExemptions() + broadcastStatus() +} + +export function enqueueCloudDownload(absolutePath: string): void { + if (queuedPaths.has(absolutePath) || activeDownloads.has(absolutePath)) { + return + } + + const failedAt = failedAtByPath.get(absolutePath) + if (failedAt !== undefined) { + if (Date.now() - failedAt < FAILED_RETRY_COOLDOWN_MS) { + return + } + + failedAtByPath.delete(absolutePath) + immediateCompletionsByPath.delete(absolutePath) + } + + queuedPaths.add(absolutePath) + queue.push({ absolutePath, attempts: 0 }) + onQueueActivity?.() + broadcastStatus() + processQueue() +} + +export function prioritizeCloudDownload(absolutePath: string): void { + if (activeDownloads.has(absolutePath)) { + return + } + + // Явное действие пользователя всегда даёт файлу новый шанс. + failedAtByPath.delete(absolutePath) + immediateCompletionsByPath.delete(absolutePath) + + const queueIndex = queue.findIndex( + entry => entry.absolutePath === absolutePath, + ) + + if (queueIndex === -1) { + queuedPaths.add(absolutePath) + queue.unshift({ absolutePath, attempts: 0 }) + } + else if (queueIndex > 0) { + const [entry] = queue.splice(queueIndex, 1) + queue.unshift(entry) + } + + broadcastStatus() + processQueue() +} + +function isICloudPath(absolutePath: string): boolean { + return ( + process.platform === 'darwin' + && absolutePath.includes('/Library/Mobile Documents/') + ) +} + +function spawnHydrationReader(absolutePath: string): ChildProcess | null { + try { + return spawn(process.execPath, ['-e', HYDRATION_READER_SCRIPT], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + MASSCODE_HYDRATE_PATH: absolutePath, + }, + stdio: 'ignore', + }) + } + catch (error) { + log('storage:cloud-downloads:spawn', error) + return null + } +} + +function spawnBrctlDownload(absolutePath: string): void { + try { + // brctl просит демон CloudDocs докачать файл и сразу выходит: его код + // выхода означает лишь принятие запроса, готовность определяет поллинг. + const child = spawn('/usr/bin/brctl', ['download', absolutePath], { + stdio: 'ignore', + }) + child.on('error', (error) => { + log('storage:cloud-downloads:brctl', error) + }) + } + catch (error) { + log('storage:cloud-downloads:brctl', error) + } +} + +function stopActiveDownload(download: ActiveDownload): void { + clearInterval(download.pollTimer) + clearTimeout(download.timeoutTimer) + + if (download.readerChild && download.readerChild.exitCode === null) { + download.readerChild.kill() + } +} + +function notifyFileDownloaded(absolutePath: string): void { + try { + onFileDownloaded?.(absolutePath) + } + catch (error) { + log('storage:cloud-downloads:synced', error) + } +} + +function finishDownload(absolutePath: string, succeeded: boolean): void { + const download = activeDownloads.get(absolutePath) + if (!download) { + return + } + + stopActiveDownload(download) + activeDownloads.delete(absolutePath) + + if (succeeded) { + downloadedCount += 1 + failedAtByPath.delete(absolutePath) + immediateCompletionsByPath.delete(absolutePath) + notifyFileDownloaded(absolutePath) + } + + broadcastStatus() + processQueue() +} + +function retryOrFail(absolutePath: string, attempts: number): void { + const download = activeDownloads.get(absolutePath) + if (!download) { + return + } + + stopActiveDownload(download) + activeDownloads.delete(absolutePath) + + if (attempts >= MAX_DOWNLOAD_ATTEMPTS) { + failedAtByPath.set(absolutePath, Date.now()) + log( + 'storage:cloud-downloads:failed', + `Giving up on cloud download after ${attempts} attempts: ${absolutePath}`, + ) + broadcastStatus() + processQueue() + return + } + + const retryTimer = setTimeout(() => { + retryTimers.delete(retryTimer) + + if (!queuedPaths.has(absolutePath) && !activeDownloads.has(absolutePath)) { + queuedPaths.add(absolutePath) + queue.push({ absolutePath, attempts }) + broadcastStatus() + processQueue() + } + }, RETRY_DELAY_MS * attempts) + + retryTimers.add(retryTimer) + broadcastStatus() + processQueue() +} + +function toStableProbe(stats: Stats): StableProbe { + return { + blocks: stats.blocks, + mtimeMs: stats.mtimeMs, + size: stats.size, + } +} + +function isSameProbe(a: StableProbe | null, b: StableProbe): boolean { + return ( + a !== null + && a.blocks === b.blocks + && a.mtimeMs === b.mtimeMs + && a.size === b.size + ) +} + +function startDownload(absolutePath: string, attempts: number): void { + let readerChild: ChildProcess | null = null + + // В режиме симуляции (MASSCODE_SIMULATE_CLOUD_PLACEHOLDERS) «докачку» + // выполняет внешний скрипт удалением сайдкара: приложение только поллит + // готовность, не запуская brctl/reader. + if (process.env.MASSCODE_SIMULATE_CLOUD_PLACEHOLDERS === '1') { + // Поллинг ниже определит готовность. + } + else if (isICloudPath(absolutePath)) { + spawnBrctlDownload(absolutePath) + } + else { + readerChild = spawnHydrationReader(absolutePath) + } + + const pollTimer = setInterval(() => { + const download = activeDownloads.get(absolutePath) + if (!download) { + return + } + + const availability = getFileAvailability(absolutePath) + + if (!availability.exists || !availability.stats) { + // Файл исчез (удалён или перемещён): докачивать больше нечего. + finishDownload(absolutePath, false) + return + } + + const probe = toStableProbe(availability.stats) + + // Готовность объявляется только по стабильной сигнатуре в двух опросах + // подряд: провайдеры, пишущие файл на диск постепенно, дают blocks > 0 + // задолго до конца скачивания. + if ( + !availability.isCloudPlaceholder + && isSameProbe(download.previousProbe, probe) + ) { + finishDownload(absolutePath, true) + return + } + + download.previousProbe = probe + }, HYDRATION_POLL_INTERVAL_MS) + + const timeoutTimer = setTimeout(() => { + retryOrFail(absolutePath, attempts + 1) + }, DOWNLOAD_TIMEOUT_MS) + + activeDownloads.set(absolutePath, { + pollTimer, + previousProbe: null, + readerChild, + timeoutTimer, + }) + + readerChild?.on('error', (error) => { + log('storage:cloud-downloads:reader', error) + }) + + readerChild?.on('exit', (code) => { + if (code !== 0 || !activeDownloads.has(absolutePath)) { + return + } + + // Ридер дочитал файл до конца: содержимое гарантированно локально. + // Если stat всё ещё показывает сигнатуру плейсхолдера, это ложное + // срабатывание эвристики (inline/resident-файл): такой файл помечается + // читаемым, иначе он навсегда остался бы «недокачанным». + try { + const stats = fs.statSync(absolutePath) + + if (stats.isFile() && stats.size > 0 && stats.blocks === 0) { + markFileReadableDespiteZeroBlocks(absolutePath, stats) + } + + finishDownload(absolutePath, true) + } + catch { + finishDownload(absolutePath, false) + } + }) +} + +function processQueue(): void { + while (queue.length > 0 && activeDownloads.size < MAX_CONCURRENT_DOWNLOADS) { + const entry = queue.shift() + if (!entry) { + return + } + + queuedPaths.delete(entry.absolutePath) + + const availability = getFileAvailability(entry.absolutePath) + + if (!availability.exists) { + continue + } + + // Файл мог быть докачан провайдером самостоятельно, пока стоял в + // очереди: тогда достаточно инкрементального синка без загрузки. + if (!availability.isCloudPlaceholder) { + const completions + = (immediateCompletionsByPath.get(entry.absolutePath) ?? 0) + 1 + + // Файл снова и снова признаётся «уже локальным», но продолжает + // попадать в очередь: он читается с ошибкой не из-за облака. + // Без предела это бесконечный цикл sync -> enqueue. + if (completions > MAX_IMMEDIATE_COMPLETIONS) { + failedAtByPath.set(entry.absolutePath, Date.now()) + log( + 'storage:cloud-downloads:failed', + `File is local but repeatedly unreadable: ${entry.absolutePath}`, + ) + continue + } + + immediateCompletionsByPath.set(entry.absolutePath, completions) + downloadedCount += 1 + notifyFileDownloaded(entry.absolutePath) + continue + } + + startDownload(entry.absolutePath, entry.attempts) + } + + broadcastStatus() +} diff --git a/src/main/storage/providers/markdown/doctor.ts b/src/main/storage/providers/markdown/doctor.ts new file mode 100644 index 000000000..95013bec4 --- /dev/null +++ b/src/main/storage/providers/markdown/doctor.ts @@ -0,0 +1,1313 @@ +import type { + VaultDoctorInput, + VaultDoctorItem, + VaultDoctorResponse, + VaultDoctorWarning, +} from '../../../api/dto/vault-doctor' +import type { MathNotebookStore, MathSheet } from '../../../store/types' +import { createHash, randomUUID } from 'node:crypto' +import path from 'node:path' +import fs from 'fs-extra' +import yaml from 'js-yaml' +import { enqueueCloudDownload } from './cloudDownloads' +import { + getHttpPaths, + isHttpVaultDiskReady, + loadHttpState, + syncHttpRuntimeWithDisk, +} from './http/runtime' +import { + extractNotesAssetNames, + getNotesPaths, + isNotesVaultDiskReady, + loadNotesState, + syncNotesRuntimeWithDisk, +} from './notes/runtime' +import { + getPaths, + getSpaceStatePath, + getVaultPath, + INBOX_DIR_NAME, + isCodeVaultDiskReady, + loadState, + META_DIR_NAME, + META_FILE_NAME, + readSpaceState, + syncRuntimeWithDisk, + TRASH_DIR_NAME, + writeSpaceStateImmediate, +} from './runtime' +import { + getFileAvailability, + primeDatalessChecks, +} from './runtime/shared/cloudFiles' +import { isCloudFileNotDownloadedError } from './runtime/shared/guardedRead' +import { readDirEntriesFailClosed } from './runtime/shared/path' + +type VaultDoctorSpace = NonNullable[number] + +interface Fingerprint { + mtimeMs: number + path: string + size: number +} + +interface ScanContext { + conflictGroups: VaultDoctorResponse['conflictGroups'] + items: VaultDoctorItem[] + warnings: VaultDoctorWarning[] +} + +interface EntityScanRecord { + filePath: string + fingerprint: Fingerprint + // folderId из frontmatter: у заметок он приоритетнее пути и может + // указывать на несуществующую папку (dangling). + folderId: number | null + id: number + kind: 'note' | 'snippet' + space: Extract +} + +interface InspectedNotesAssetPath { + exists: boolean + invalidReason: string | null + isCloudPlaceholder: boolean +} + +const DEFAULT_SPACES: VaultDoctorSpace[] = ['code', 'notes', 'http', 'math'] +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ +const MERGE_MARKER_RE = /^(?:<<<<<<<(?: .*)?|=======|>>>>>>>(?: .*)?)$/m +const CONFLICTED_NAME_RE + = /\b(?:conflict|conflicted|conflicted copy|copy conflict)\b|\([^)]+(?:macbook|machine|conflict|conflicted)[^)]*\)/i +const FRONTMATTER_ID_RE = /^id:.*$/m +const INBOX_RELATIVE_PATH = `${META_DIR_NAME}/${INBOX_DIR_NAME}` +const TRASH_RELATIVE_PATH = `${META_DIR_NAME}/${TRASH_DIR_NAME}` + +function normalizeSpaces(input?: VaultDoctorInput): VaultDoctorSpace[] { + const requestedSpaces = input?.spaces?.length ? input.spaces : DEFAULT_SPACES + return [...new Set(requestedSpaces)] +} + +function toPosixPath(value: string): string { + return value.replaceAll(path.sep, '/') +} + +function getFingerprint(absolutePath: string): Fingerprint { + try { + const stat = fs.statSync(absolutePath) + return { + mtimeMs: stat.mtimeMs, + path: absolutePath, + size: stat.size, + } + } + catch { + return { + mtimeMs: 0, + path: absolutePath, + size: 0, + } + } +} + +function isInsideRelativePath(value: string, parentPath: string): boolean { + return value === parentPath || value.startsWith(`${parentPath}/`) +} + +function shouldSkipMarkdownDirectory(relativePath: string): boolean { + if (isInsideRelativePath(relativePath, TRASH_RELATIVE_PATH)) { + return true + } + + if ( + relativePath.startsWith(`${META_DIR_NAME}/`) + && !isInsideRelativePath(relativePath, INBOX_RELATIVE_PATH) + ) { + return true + } + + const name = path.posix.basename(relativePath) + return name.startsWith('.') && name !== META_DIR_NAME +} + +function addItem(context: ScanContext, item: VaultDoctorItem): void { + context.items.push(item) +} + +function addWarning(context: ScanContext, warning: VaultDoctorWarning): void { + context.warnings.push(warning) +} + +function hashLocalFile(absolutePath: string): string { + return createHash('sha256') + .update(fs.readFileSync(absolutePath)) + .digest('hex') +} + +function inspectNotesAssetPath( + absolutePath: string, + location: 'destination' | 'source', +): InspectedNotesAssetPath { + try { + const stats = fs.lstatSync(absolutePath) + if (stats.isSymbolicLink()) { + return { + exists: true, + invalidReason: `${location}-symlink`, + isCloudPlaceholder: false, + } + } + if (!stats.isFile()) { + return { + exists: true, + invalidReason: `${location}-not-regular`, + isCloudPlaceholder: false, + } + } + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + exists: false, + invalidReason: null, + isCloudPlaceholder: false, + } + } + return { + exists: true, + invalidReason: `${location}-inspection-unavailable`, + isCloudPlaceholder: false, + } + } + + const availability = getFileAvailability(absolutePath) + if (!availability.exists) { + return { + exists: true, + invalidReason: `${location}-inspection-unavailable`, + isCloudPlaceholder: false, + } + } + + return { + exists: true, + invalidReason: null, + isCloudPlaceholder: availability.isCloudPlaceholder, + } +} + +function inspectNotesAssets(input: { + context: ScanContext + notePath: string + notesRoot: string + source: string +}): void { + const paths = getNotesPaths(getVaultPath()) + + for (const assetName of extractNotesAssetNames(input.source)) { + const sourcePath = path.join(paths.legacyAssetsPath, assetName) + const destinationPath = path.join(paths.assetsPath, assetName) + const sourceAvailability = inspectNotesAssetPath(sourcePath, 'source') + const destinationAvailability = inspectNotesAssetPath( + destinationPath, + 'destination', + ) + const details = { + assetName, + destination: toPosixPath(path.relative(input.notesRoot, destinationPath)), + source: toPosixPath(path.relative(input.notesRoot, sourcePath)), + } + + const invalidReason + = sourceAvailability.invalidReason ?? destinationAvailability.invalidReason + if (invalidReason) { + addWarning(input.context, { + code: 'NOTES_ASSET_MIGRATION_PENDING', + details: { ...details, reason: invalidReason }, + path: input.notePath, + space: 'notes', + }) + continue + } + + if ( + sourceAvailability.isCloudPlaceholder + || destinationAvailability.isCloudPlaceholder + ) { + addWarning(input.context, { + code: 'NOTES_ASSET_MIGRATION_PENDING', + details: { + ...details, + reason: + sourceAvailability.isCloudPlaceholder + && destinationAvailability.isCloudPlaceholder + ? 'source-and-destination-placeholder' + : sourceAvailability.isCloudPlaceholder + ? 'source-placeholder' + : 'destination-placeholder', + }, + path: input.notePath, + space: 'notes', + }) + continue + } + + if (sourceAvailability.exists && destinationAvailability.exists) { + try { + const isEqual + = hashLocalFile(sourcePath) === hashLocalFile(destinationPath) + addWarning(input.context, { + code: isEqual + ? 'NOTES_LEGACY_ASSET' + : 'NOTES_ASSET_DESTINATION_CONFLICT', + details: { + ...details, + reason: isEqual ? 'legacy-copy-remains' : 'content-mismatch', + }, + path: input.notePath, + space: 'notes', + }) + } + catch { + addWarning(input.context, { + code: 'NOTES_ASSET_MIGRATION_PENDING', + details: { ...details, reason: 'inspection-unavailable' }, + path: input.notePath, + space: 'notes', + }) + } + continue + } + + if (sourceAvailability.exists) { + addWarning(input.context, { + code: 'NOTES_LEGACY_ASSET', + details: { ...details, reason: 'destination-missing' }, + path: input.notePath, + space: 'notes', + }) + continue + } + + if (!destinationAvailability.exists) { + addWarning(input.context, { + code: 'NOTES_ASSET_MISSING', + details: { ...details, reason: 'source-and-destination-missing' }, + path: input.notePath, + space: 'notes', + }) + } + } +} + +function createFileItem(input: { + action: VaultDoctorItem['action'] + absolutePath: string + kind: VaultDoctorItem['kind'] + relativePath: string + space: VaultDoctorSpace + status: VaultDoctorItem['status'] +}): VaultDoctorItem { + return { + action: input.action, + fingerprint: getFingerprint(input.absolutePath), + kind: input.kind, + path: input.relativePath, + space: input.space, + status: input.status, + } +} + +function listMarkdownFiles(rootPath: string): string[] { + const files: string[] = [] + + function walk(currentPath: string): void { + // Fail closed: stat-ошибка (EIO) не должна превращаться в пустой + // listing — аудит показал бы ложно-чистый vault. + for (const entry of readDirEntriesFailClosed(currentPath)) { + if (entry.name.startsWith('.') && entry.name !== META_DIR_NAME) { + continue + } + + const absolutePath = path.join(currentPath, entry.name) + const relativePath = toPosixPath(path.relative(rootPath, absolutePath)) + + if (entry.isDirectory()) { + if (shouldSkipMarkdownDirectory(relativePath)) { + continue + } + + walk(absolutePath) + continue + } + + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + files.push(relativePath) + } + } + } + + walk(rootPath) + return files +} + +function listFolders( + rootPath: string, + skipRootNames = new Set(), +): string[] { + const folders: string[] = [] + + function walk(currentPath: string): void { + const isRoot = currentPath === rootPath + // Fail closed: stat-ошибка (EIO) не должна превращаться в пустой + // listing — аудит показал бы ложно-чистый vault. + for (const entry of readDirEntriesFailClosed(currentPath)) { + if (!entry.isDirectory()) { + continue + } + + if ( + entry.name.startsWith('.') + || (isRoot && skipRootNames.has(entry.name)) + ) { + continue + } + + const absolutePath = path.join(currentPath, entry.name) + const relativePath = toPosixPath(path.relative(rootPath, absolutePath)) + folders.push(relativePath) + walk(absolutePath) + } + } + + walk(rootPath) + return folders +} + +function readFrontmatter(source: string): { + frontmatter: Record + hasFrontmatter: boolean + invalid: boolean +} { + const match = source.match(FRONTMATTER_RE) + if (!match) { + return { + frontmatter: {}, + hasFrontmatter: false, + invalid: false, + } + } + + try { + const parsed = yaml.load(match[1]) + return { + frontmatter: + parsed && typeof parsed === 'object' + ? (parsed as Record) + : {}, + hasFrontmatter: true, + invalid: false, + } + } + catch { + return { + frontmatter: {}, + hasFrontmatter: true, + invalid: true, + } + } +} + +function normalizeId(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value > 0 + ? value + : null +} + +function inspectMarkdownEntity(input: { + context: ScanContext + filePath: string + kind: EntityScanRecord['kind'] + rootPath: string + space: EntityScanRecord['space'] +}): EntityScanRecord | null { + const absolutePath = path.join(input.rootPath, input.filePath) + + // Недокачанный облачный файл нельзя аудировать без блокирующего чтения: + // он уходит в фоновую докачку и пропускается в этом прогоне Doctor. + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + return null + } + + let source: string + try { + source = fs.readFileSync(absolutePath, 'utf8') + } + catch { + enqueueCloudDownload(absolutePath) + return null + } + + if (input.space === 'notes') { + inspectNotesAssets({ + context: input.context, + notePath: input.filePath, + notesRoot: input.rootPath, + source, + }) + } + + const fileName = path.basename(input.filePath) + const fingerprint = getFingerprint(absolutePath) + + if (MERGE_MARKER_RE.test(source)) { + addItem(input.context, { + action: 'detect-conflict', + fingerprint, + kind: 'conflict', + path: input.filePath, + space: input.space, + status: 'blocked', + }) + input.context.conflictGroups.push({ + id: `${input.space}:merge-markers:${input.filePath}`, + items: [input.context.items[input.context.items.length - 1]], + reason: 'merge-markers', + }) + return null + } + + if (CONFLICTED_NAME_RE.test(fileName)) { + addWarning(input.context, { + code: 'CONFLICTED_COPY_NAME', + path: input.filePath, + space: input.space, + }) + } + + const parsed = readFrontmatter(source) + if (parsed.invalid) { + addItem(input.context, { + action: 'detect-conflict', + fingerprint, + kind: 'conflict', + path: input.filePath, + space: input.space, + status: 'blocked', + }) + input.context.conflictGroups.push({ + id: `${input.space}:invalid-frontmatter:${input.filePath}`, + items: [input.context.items[input.context.items.length - 1]], + reason: 'invalid-frontmatter', + }) + return null + } + + const id = normalizeId(parsed.frontmatter.id) + if (!parsed.hasFrontmatter || !id) { + addItem(input.context, { + action: parsed.hasFrontmatter ? 'register-file' : 'write-frontmatter', + fingerprint, + kind: input.kind, + path: input.filePath, + space: input.space, + status: 'pending', + }) + } + + return id + ? { + filePath: input.filePath, + fingerprint, + folderId: normalizeId(parsed.frontmatter.folderId), + id, + kind: input.kind, + space: input.space, + } + : null +} + +function collectDuplicateIds( + context: ScanContext, + records: EntityScanRecord[], +): void { + const recordsByKey = new Map() + + records.forEach((record) => { + const key = `${record.space}:${record.kind}:${record.id}` + recordsByKey.set(key, [...(recordsByKey.get(key) ?? []), record]) + }) + + recordsByKey.forEach((group, key) => { + if (group.length < 2) { + return + } + + const items = group.map( + record => + ({ + action: 'reassign-id', + fingerprint: record.fingerprint, + kind: record.kind, + path: record.filePath, + space: record.space, + status: 'needs-decision', + }) satisfies VaultDoctorItem, + ) + + context.items.push(...items) + context.conflictGroups.push({ + id: key, + items, + reason: 'duplicate-id', + }) + }) +} + +function getSpaceRoot( + space: Extract, +): string { + const vaultPath = getVaultPath() + + if (space === 'code') { + return getPaths(vaultPath).vaultPath + } + + if (space === 'notes') { + return getNotesPaths(vaultPath).notesRoot + } + + return getHttpPaths(vaultPath).httpRoot +} + +function getStateIds( + space: Extract, +): number[] { + const vaultPath = getVaultPath() + + if (space === 'code') { + return loadState(getPaths(vaultPath)).snippets.map(item => item.id) + } + + if (space === 'notes') { + return loadNotesState(getNotesPaths(vaultPath)).notes.map( + item => item.id, + ) + } + + return loadHttpState(getHttpPaths(vaultPath)).requests.map(item => item.id) +} + +function getStateIndexedFilePaths( + space: Extract, +): Set { + const vaultPath = getVaultPath() + + const filePaths + = space === 'code' + ? loadState(getPaths(vaultPath)).snippets.map(item => item.filePath) + : space === 'notes' + ? loadNotesState(getNotesPaths(vaultPath)).notes.map( + item => item.filePath, + ) + : loadHttpState(getHttpPaths(vaultPath)).requests.map( + item => item.filePath, + ) + + return new Set(filePaths.map(filePath => filePath.toLowerCase())) +} + +// Файл с валидным frontmatter-id, которого нет в state-индексе, приложение +// не отображает до полного пересканирования: Doctor предлагает регистрацию +// (apply выполняет пересинк пространства, который заберёт файл в индекс). +function collectUnindexedFiles( + context: ScanContext, + space: Extract, + records: EntityScanRecord[], +): void { + const indexedFilePaths = getStateIndexedFilePaths(space) + + for (const record of records) { + if (!indexedFilePaths.has(record.filePath.toLowerCase())) { + addItem(context, { + action: 'register-file', + fingerprint: record.fingerprint, + kind: record.kind, + path: record.filePath, + space, + status: 'pending', + }) + } + } +} + +function getNextEntityId( + space: Extract, +): () => number { + const rootPath = getSpaceRoot(space) + const ids = new Set(getStateIds(space)) + + listMarkdownFiles(rootPath).forEach((filePath) => { + try { + const absolutePath = path.join(rootPath, filePath) + + // Недокачанный файл пропускается: его чтение заблокировало бы main + // process, а id из него в этот прогон всё равно не получить. + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + return + } + + const source = fs.readFileSync(absolutePath, 'utf8') + const parsed = readFrontmatter(source) + const id = normalizeId(parsed.frontmatter.id) + if (id) { + ids.add(id) + } + } + catch { + // Ignore unreadable files; preview will report them separately. + } + }) + + let nextId = Math.max(0, ...ids) + 1 + return () => { + const id = nextId + nextId += 1 + return id + } +} + +function isFingerprintCurrent(item: VaultDoctorItem): boolean { + const current = getFingerprint(item.fingerprint.path) + return ( + current.mtimeMs === item.fingerprint.mtimeMs + && current.size === item.fingerprint.size + ) +} + +function replaceFrontmatterId(source: string, id: number): string { + const match = source.match(FRONTMATTER_RE) + if (!match) { + return source + } + + const frontmatter = FRONTMATTER_ID_RE.test(match[1]) + ? match[1].replace(FRONTMATTER_ID_RE, `id: ${id}`) + : `id: ${id}\n${match[1]}` + + return `---\n${frontmatter}\n---\n${match[2] || ''}` +} + +function applyDuplicateIdDecisions( + preview: VaultDoctorResponse, + decisions: VaultDoctorInput['decisions'] = [], +): VaultDoctorItem[] { + const appliedItems: VaultDoctorItem[] = [] + const decisionsByGroupId = new Map( + decisions.map(decision => [decision.groupId, decision]), + ) + + preview.conflictGroups.forEach((group) => { + if (group.reason !== 'duplicate-id') { + return + } + + const decision = decisionsByGroupId.get(group.id) + if (!decision) { + return + } + + const keepItem = group.items.find( + item => item.path === decision.keepPath, + ) + if (!keepItem) { + return + } + + if (group.items.some(item => !isFingerprintCurrent(item))) { + return + } + + const space = keepItem.space as Extract< + VaultDoctorSpace, + 'code' | 'http' | 'notes' + > + const nextId = getNextEntityId(space) + + group.items.forEach((item) => { + if (item.path === decision.keepPath || item.status !== 'needs-decision') { + return + } + + const absolutePath = item.fingerprint.path + + // Недокачанный файл не переписывается: чтение заблокировало бы main + // process, а запись затёрла бы облачное содержимое. Элемент остаётся + // неприменённым, пользователь повторит после докачки. + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + return + } + + const source = fs.readFileSync(absolutePath, 'utf8') + fs.writeFileSync( + absolutePath, + replaceFrontmatterId(source, nextId()), + 'utf8', + ) + appliedItems.push({ + ...item, + status: 'applied', + }) + }) + }) + + return appliedItems +} + +function scanCode(context: ScanContext): void { + const paths = getPaths(getVaultPath()) + const records: EntityScanRecord[] = [] + const skipRootNames = new Set([META_DIR_NAME, 'inbox', TRASH_DIR_NAME]) + + listFolders(paths.vaultPath, skipRootNames).forEach((folderPath) => { + const metaPath = path.join(paths.vaultPath, folderPath, META_FILE_NAME) + if (!metaFileExists(metaPath)) { + addItem( + context, + createFileItem({ + action: 'create-folder-metadata', + absolutePath: path.join(paths.vaultPath, folderPath), + kind: 'folder', + relativePath: folderPath, + space: 'code', + status: 'pending', + }), + ) + } + }) + + const snippetFiles = listMarkdownFiles(paths.vaultPath) + primeDatalessChecks( + snippetFiles.map(filePath => path.join(paths.vaultPath, filePath)), + ) + + snippetFiles.forEach((filePath) => { + const record = inspectMarkdownEntity({ + context, + filePath, + kind: 'snippet', + rootPath: paths.vaultPath, + space: 'code', + }) + if (record) { + records.push(record) + } + }) + + collectDuplicateIds(context, records) + collectUnindexedFiles(context, 'code', records) +} + +// Fail closed: stat-ошибка (EIO) не считается отсутствием файла — иначе +// аудит предложил бы пересоздать существующие метаданные папки. +function metaFileExists(metaPath: string): boolean { + try { + fs.statSync(metaPath) + return true + } + catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ENOENT' + } +} + +function readNotesFolderIdFromMetadata(metaPath: string): number | null { + // Недокачанный .meta.yaml не читается синхронно: id папки в этот прогон + // не получить, заметки этой папки не считаются dangling. + if (getFileAvailability(metaPath).isCloudPlaceholder) { + enqueueCloudDownload(metaPath) + return null + } + + try { + const parsed = yaml.load(fs.readFileSync(metaPath, 'utf8')) as { + id?: unknown + } | null + return normalizeId(parsed?.id) + } + catch { + return null + } +} + +function scanNotes(context: ScanContext): void { + const paths = getNotesPaths(getVaultPath()) + const records: EntityScanRecord[] = [] + const diskFolderIds = new Set() + let hasUnreadableFolderMetadata = false + + listFolders(paths.notesRoot).forEach((folderPath) => { + const metaPath = path.join(paths.notesRoot, folderPath, META_FILE_NAME) + if (!metaFileExists(metaPath)) { + // Папка без метаданных получит id при следующем синке: судить о + // dangling-ссылках в этот прогон нельзя. + hasUnreadableFolderMetadata = true + addItem( + context, + createFileItem({ + action: 'create-folder-metadata', + absolutePath: path.join(paths.notesRoot, folderPath), + kind: 'folder', + relativePath: folderPath, + space: 'notes', + status: 'pending', + }), + ) + return + } + + const folderId = readNotesFolderIdFromMetadata(metaPath) + if (folderId) { + diskFolderIds.add(folderId) + } + else { + hasUnreadableFolderMetadata = true + } + }) + + const noteFiles = listMarkdownFiles(paths.notesRoot) + primeDatalessChecks( + noteFiles.map(filePath => path.join(paths.notesRoot, filePath)), + ) + + noteFiles.forEach((filePath) => { + const record = inspectMarkdownEntity({ + context, + filePath, + kind: 'note', + rootPath: paths.notesRoot, + space: 'notes', + }) + if (record) { + records.push(record) + } + }) + + collectDuplicateIds(context, records) + collectUnindexedFiles(context, 'notes', records) + + // folderId во frontmatter приоритетнее пути (см. readNoteFromFile): + // ссылка на несуществующую папку делает заметку невидимой в дереве папок. + if (!hasUnreadableFolderMetadata) { + for (const record of records) { + if (record.folderId && !diskFolderIds.has(record.folderId)) { + addWarning(context, { + code: 'DANGLING_FOLDER_ID', + path: record.filePath, + space: 'notes', + }) + } + } + } +} + +function scanHttp(context: ScanContext): void { + const paths = getHttpPaths(getVaultPath()) + const records: EntityScanRecord[] = [] + const state = loadHttpState(paths) + + const httpFiles = listMarkdownFiles(paths.httpRoot) + primeDatalessChecks( + httpFiles.map(filePath => path.join(paths.httpRoot, filePath)), + ) + + httpFiles.forEach((filePath) => { + const record = inspectMarkdownEntity({ + context, + filePath, + kind: 'snippet', + rootPath: paths.httpRoot, + space: 'http', + }) + if (record) { + records.push(record) + } + }) + + const seenEnvironmentIds = new Set() + let hasEnvironmentRepair = false + for (const environment of state.environments) { + if ( + !Number.isInteger(environment.id) + || environment.id <= 0 + || seenEnvironmentIds.has(environment.id) + || !environment.variables + || typeof environment.variables !== 'object' + ) { + hasEnvironmentRepair = true + } + if (Number.isInteger(environment.id) && environment.id > 0) { + seenEnvironmentIds.add(environment.id) + } + } + if ( + state.activeEnvironmentId !== null + && !state.environments.some(env => env.id === state.activeEnvironmentId) + ) { + hasEnvironmentRepair = true + } + + if (hasEnvironmentRepair) { + addItem(context, { + action: 'repair-environment-state', + fingerprint: getFingerprint(paths.statePath), + kind: 'environment', + path: toPosixPath(path.relative(paths.httpRoot, paths.statePath)), + space: 'http', + status: 'pending', + }) + } + + collectDuplicateIds(context, records) + collectUnindexedFiles(context, 'http', records) +} + +function normalizeMathSheet( + sheet: Partial, + usedIds: Set, +): { changed: boolean, sheet: MathSheet } { + let changed = false + let id = typeof sheet.id === 'string' && sheet.id ? sheet.id : randomUUID() + if (usedIds.has(id)) { + id = randomUUID() + changed = true + } + usedIds.add(id) + + if (id !== sheet.id) { + changed = true + } + + const now = Date.now() + const normalized: MathSheet = { + content: typeof sheet.content === 'string' ? sheet.content : '', + createdAt: typeof sheet.createdAt === 'number' ? sheet.createdAt : now, + id, + name: + typeof sheet.name === 'string' && sheet.name.trim() + ? sheet.name + : 'Sheet', + updatedAt: typeof sheet.updatedAt === 'number' ? sheet.updatedAt : now, + } + + return { + changed: + changed + || normalized.content !== sheet.content + || normalized.createdAt !== sheet.createdAt + || normalized.name !== sheet.name + || normalized.updatedAt !== sheet.updatedAt, + sheet: normalized, + } +} + +function getMathStatePath(): string { + return getSpaceStatePath(getVaultPath(), 'math') +} + +function readNormalizedMathState(): { + changed: boolean + state: MathNotebookStore +} { + const statePath = getMathStatePath() + const raw = readSpaceState>(statePath) + const usedIds = new Set() + let changed = false + const sheets: MathSheet[] = [] + + if (!raw || !Array.isArray(raw.sheets)) { + changed = !!raw + } + else { + raw.sheets.forEach((sheet) => { + if (!sheet || typeof sheet !== 'object') { + changed = true + return + } + + const result = normalizeMathSheet(sheet, usedIds) + if (result.changed) { + changed = true + } + sheets.push(result.sheet) + }) + } + + let activeSheetId + = typeof raw?.activeSheetId === 'string' ? raw.activeSheetId : null + if (activeSheetId && !sheets.some(sheet => sheet.id === activeSheetId)) { + activeSheetId = sheets[0]?.id ?? null + changed = true + } + + return { + changed, + state: { + activeSheetId, + sheets, + }, + } +} + +function scanMath(context: ScanContext): void { + const statePath = getMathStatePath() + + // math/.state.yaml может быть ещё не докачан из облака: аудит без + // содержимого невозможен, пространство проверится после докачки. + let changed: boolean + try { + changed = readNormalizedMathState().changed + } + catch (error) { + if (isCloudFileNotDownloadedError(error)) { + return + } + + throw error + } + + if (!changed) { + return + } + + addItem(context, { + action: 'repair-math-state', + fingerprint: getFingerprint(statePath), + kind: 'math-sheet', + path: 'math/.state.yaml', + space: 'math', + status: 'pending', + }) +} + +function buildSummary(context: ScanContext): VaultDoctorResponse['summary'] { + const items = context.items + + return { + affectedFiles: items.filter(item => item.status === 'pending').length, + blocked: items.filter(item => item.status === 'blocked').length, + conflicts: context.conflictGroups.length, + folders: items.filter(item => item.kind === 'folder').length, + httpEnvironments: items.filter(item => item.kind === 'environment') + .length, + httpRequests: items.filter(item => item.space === 'http').length, + mathSheets: items.filter(item => item.kind === 'math-sheet').length, + notes: items.filter(item => item.kind === 'note').length, + skipped: items.filter(item => item.status === 'skipped').length, + snippets: items.filter(item => item.kind === 'snippet').length, + warnings: context.warnings.length, + } +} + +function createContext(): ScanContext { + return { + conflictGroups: [], + items: [], + warnings: [], + } +} + +export function previewVaultDoctor( + input?: VaultDoctorInput, +): VaultDoctorResponse { + const context = createContext() + const spaces = normalizeSpaces(input) + + // Пока запрошенное пространство не сверено с диском после открытия + // (каталоги могут быть недокачаны из облака), полный аудит невозможен без + // блокирующего обхода: возвращается явный notReady, чтобы пустой результат + // не выглядел как чистый vault. Готовность проверяется только для + // запрошенных пространств (аудит math не должен ждать http). + const vaultRootPath = getVaultPath() + const isRequestedSpaceNotReady + = (spaces.includes('code') + && !isCodeVaultDiskReady(getPaths(vaultRootPath))) + || (spaces.includes('notes') + && !isNotesVaultDiskReady(getNotesPaths(vaultRootPath))) + || (spaces.includes('http') + && !isHttpVaultDiskReady(getHttpPaths(vaultRootPath))) + + if (isRequestedSpaceNotReady) { + return { + conflictGroups: [], + items: [], + notReady: true, + summary: buildSummary(context), + warnings: [], + } + } + + if (spaces.includes('code')) { + scanCode(context) + } + if (spaces.includes('notes')) { + scanNotes(context) + } + if (spaces.includes('http')) { + scanHttp(context) + } + if (spaces.includes('math')) { + scanMath(context) + } + + return { + conflictGroups: context.conflictGroups, + items: context.items, + summary: buildSummary(context), + warnings: context.warnings, + } +} + +function spacesWithConflicts( + result: VaultDoctorResponse, +): Set { + return new Set( + result.items + .filter( + item => item.status === 'blocked' || item.status === 'needs-decision', + ) + .map(item => item.space), + ) +} + +function repairHttpEnvironmentState(): void { + const paths = getHttpPaths(getVaultPath()) + const state = loadHttpState(paths) + const usedIds = new Set() + let nextId = Math.max( + state.counters.environmentId, + ...state.environments.map(env => + Number.isInteger(env.id) && env.id > 0 ? env.id : 0, + ), + ) + + state.environments.forEach((environment) => { + if ( + !Number.isInteger(environment.id) + || environment.id <= 0 + || usedIds.has(environment.id) + ) { + nextId += 1 + environment.id = nextId + } + usedIds.add(environment.id) + + if (!environment.variables || typeof environment.variables !== 'object') { + environment.variables = {} + } + }) + + if ( + state.activeEnvironmentId !== null + && !state.environments.some(env => env.id === state.activeEnvironmentId) + ) { + state.activeEnvironmentId = null + } + state.counters.environmentId = Math.max(nextId, state.counters.environmentId) + writeSpaceStateImmediate(paths.statePath, state) +} + +function repairMathState(): void { + // math/.state.yaml может быть ещё не докачан из облака: запись поверх + // плейсхолдера уничтожила бы облачную версию. + let state: MathNotebookStore + try { + state = readNormalizedMathState().state + } + catch (error) { + if (isCloudFileNotDownloadedError(error)) { + return + } + + throw error + } + + writeSpaceStateImmediate(getMathStatePath(), state) +} + +function isAppliedBySpaceSync( + item: VaultDoctorItem, + conflictedSpaces: Set, +): boolean { + if ( + item.action === 'repair-environment-state' + || item.action === 'repair-math-state' + ) { + return true + } + + return !conflictedSpaces.has(item.space) +} + +export function applyVaultDoctor( + input?: VaultDoctorInput, +): VaultDoctorResponse { + const before = previewVaultDoctor(input) + + // Аудит не выполнялся (пространства ещё сверяются с диском): применять + // «ремонт» по пустому результату нельзя. + if (before.notReady) { + return before + } + + const appliedDecisionItems = applyDuplicateIdDecisions( + before, + input?.decisions, + ) + const afterDecisions = previewVaultDoctor(input) + const conflictedSpaces = spacesWithConflicts(afterDecisions) + const spaces = normalizeSpaces(input) + + if (spaces.includes('code') && !conflictedSpaces.has('code')) { + syncRuntimeWithDisk(getPaths(getVaultPath())) + } + if (spaces.includes('notes') && !conflictedSpaces.has('notes')) { + syncNotesRuntimeWithDisk(getNotesPaths(getVaultPath()), { + scheduleAssetsMigration: false, + }) + } + if (spaces.includes('http')) { + if (!conflictedSpaces.has('http')) { + syncHttpRuntimeWithDisk(getHttpPaths(getVaultPath())) + } + repairHttpEnvironmentState() + } + if (spaces.includes('math')) { + repairMathState() + } + + const after = previewVaultDoctor(input) + const appliedItems = before.items + .filter( + item => + item.status === 'pending' + && isAppliedBySpaceSync(item, conflictedSpaces), + ) + .map(item => ({ + ...item, + status: 'applied' as const, + })) + + return { + conflictGroups: after.conflictGroups, + items: [...appliedDecisionItems, ...appliedItems, ...after.items], + summary: buildSummary({ + conflictGroups: after.conflictGroups, + items: [...appliedDecisionItems, ...appliedItems, ...after.items], + warnings: after.warnings, + }), + warnings: after.warnings, + } +} diff --git a/src/main/storage/providers/markdown/drawings/index.ts b/src/main/storage/providers/markdown/drawings/index.ts new file mode 100644 index 000000000..1c34a532c --- /dev/null +++ b/src/main/storage/providers/markdown/drawings/index.ts @@ -0,0 +1,300 @@ +import path from 'node:path' +import fs from 'fs-extra' +import { + enqueueCloudDownload, + prioritizeCloudDownload, +} from '../cloudDownloads' +import { + DRAWINGS_SPACE_ID, + INVALID_NAME_CHARS_RE, + WINDOWS_RESERVED_NAME_RE, +} from '../runtime/constants' +import { getFileAvailability } from '../runtime/shared/cloudFiles' +import { ensureSpaceDirectory, getSpaceDirPath } from '../runtime/spaces' + +export const DRAWING_FILE_EXTENSION = '.excalidraw' + +const DEFAULT_DRAWING_NAME = 'Untitled' + +// Own mutations are remembered briefly so the vault watcher can skip +// broadcasting echoes of the app's own writes back to the renderer. +const RECENT_APP_CHANGE_TTL_MS = 2500 +const recentAppChanges = new Map() + +export interface DrawingRecord { + id: string + name: string + createdAt: number + updatedAt: number +} + +function rememberAppChange(filePath: string): void { + const now = Date.now() + + for (const [knownPath, timestamp] of recentAppChanges) { + if (now - timestamp > RECENT_APP_CHANGE_TTL_MS) { + recentAppChanges.delete(knownPath) + } + } + + recentAppChanges.set(path.resolve(filePath), now) +} + +export function wasRecentAppDrawingChange( + vaultPath: string, + relativePath: string, +): boolean { + const absolutePath = path.resolve(path.join(vaultPath, relativePath)) + const timestamp = recentAppChanges.get(absolutePath) + + if (timestamp === undefined) { + return false + } + + return Date.now() - timestamp <= RECENT_APP_CHANGE_TTL_MS +} + +export function createEmptyDrawingContent(): string { + return `${JSON.stringify( + { + type: 'excalidraw', + version: 2, + source: 'massCode', + elements: [], + appState: { + gridSize: null, + viewBackgroundColor: '#ffffff', + }, + files: {}, + }, + null, + 2, + )}\n` +} + +function removeControlChars(value: string): string { + return [...value].filter(char => char.charCodeAt(0) > 0x1F).join('') +} + +export function sanitizeDrawingName(name: string | null | undefined): string { + let candidate = typeof name === 'string' ? name.trim() : '' + + candidate = removeControlChars(candidate) + candidate = candidate.replace(INVALID_NAME_CHARS_RE, ' ') + candidate = candidate.replace(/\s+/g, ' ').trim() + candidate = candidate.replace(/[. ]+$/g, '').trim() + + if (!candidate || candidate === '.' || candidate === '..') { + candidate = DEFAULT_DRAWING_NAME + } + + if (WINDOWS_RESERVED_NAME_RE.test(candidate)) { + candidate = `${candidate} 1` + } + + return candidate +} + +function getDrawingsDirPath(vaultPath: string): string { + return getSpaceDirPath(vaultPath, DRAWINGS_SPACE_ID) +} + +function getDrawingFilePath(vaultPath: string, id: string): string { + const dirPath = getDrawingsDirPath(vaultPath) + const filePath = path.join(dirPath, `${id}${DRAWING_FILE_EXTENSION}`) + + if (path.dirname(filePath) !== dirPath) { + throw new Error(`INVALID_NAME: invalid drawing id "${id}"`) + } + + return filePath +} + +async function toDrawingRecord( + filePath: string, +): Promise { + try { + const stat = await fs.stat(filePath) + const name = path.basename(filePath, DRAWING_FILE_EXTENSION) + + return { + id: name, + name, + createdAt: stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs, + updatedAt: stat.mtimeMs, + } + } + catch { + return null + } +} + +async function getUniqueDrawingName( + vaultPath: string, + baseName: string, + excludeId?: string, +): Promise { + const normalizedExcludeId = excludeId?.toLowerCase() + let candidate = baseName + let index = 2 + + // The comparison with excludeId is case-insensitive: on macOS and + // Windows the filesystem is, and a case-only rename must not collide + // with the file itself. + while ( + candidate.toLowerCase() !== normalizedExcludeId + && (await fs.pathExists(getDrawingFilePath(vaultPath, candidate))) + ) { + candidate = `${baseName} ${index}` + index += 1 + } + + return candidate +} + +export async function listDrawings( + vaultPath: string, +): Promise { + const dirPath = ensureSpaceDirectory(vaultPath, DRAWINGS_SPACE_ID) + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + + const records = await Promise.all( + entries + .filter(entry => entry.isFile()) + .filter(entry => entry.name.endsWith(DRAWING_FILE_EXTENSION)) + .filter(entry => !entry.name.startsWith('.')) + .map(entry => toDrawingRecord(path.join(dirPath, entry.name))), + ) + + return records + .filter((record): record is DrawingRecord => record !== null) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function readDrawing( + vaultPath: string, + id: string, +): Promise { + const filePath = getDrawingFilePath(vaultPath, id) + + // Недокачанный рисунок не читается: чтение зависло бы в UV threadpool до + // докачки. Файл поднимается в приоритет очереди, после докачки renderer + // получит событие sync и перечитает рисунок. + if (getFileAvailability(filePath).isCloudPlaceholder) { + prioritizeCloudDownload(filePath) + return null + } + + try { + return await fs.readFile(filePath, 'utf8') + } + catch { + return null + } +} + +export async function writeDrawing( + vaultPath: string, + id: string, + content: string, +): Promise<{ updatedAt: number }> { + ensureSpaceDirectory(vaultPath, DRAWINGS_SPACE_ID) + const filePath = getDrawingFilePath(vaultPath, id) + + // Запись в недокачанный рисунок затёрла бы облачное содержимое. + if (getFileAvailability(filePath).isCloudPlaceholder) { + enqueueCloudDownload(filePath) + throw new Error( + 'CLOUD_FILE_NOT_DOWNLOADED:This drawing is still downloading from cloud storage. Try again once the download completes.', + ) + } + + rememberAppChange(filePath) + await fs.writeFile(filePath, content, 'utf8') + + return { updatedAt: Date.now() } +} + +export async function createDrawing( + vaultPath: string, + name?: string | null, +): Promise { + ensureSpaceDirectory(vaultPath, DRAWINGS_SPACE_ID) + + const baseName = sanitizeDrawingName(name ?? DEFAULT_DRAWING_NAME) + const uniqueName = await getUniqueDrawingName(vaultPath, baseName) + const filePath = getDrawingFilePath(vaultPath, uniqueName) + + rememberAppChange(filePath) + await fs.writeFile(filePath, createEmptyDrawingContent(), 'utf8') + + return (await toDrawingRecord(filePath))! +} + +export async function renameDrawing( + vaultPath: string, + id: string, + name: string, +): Promise { + const sourcePath = getDrawingFilePath(vaultPath, id) + + if (!(await fs.pathExists(sourcePath))) { + return null + } + + const baseName = sanitizeDrawingName(name) + const uniqueName = await getUniqueDrawingName(vaultPath, baseName, id) + + if (uniqueName === id) { + return toDrawingRecord(sourcePath) + } + + const targetPath = getDrawingFilePath(vaultPath, uniqueName) + rememberAppChange(sourcePath) + rememberAppChange(targetPath) + + if (uniqueName.toLowerCase() === id.toLowerCase()) { + // Case-only rename: on case-insensitive filesystems fs.move treats + // the target as an existing file, while a plain rename succeeds. + await fs.rename(sourcePath, targetPath) + } + else { + await fs.move(sourcePath, targetPath) + } + + return toDrawingRecord(targetPath) +} + +export async function duplicateDrawing( + vaultPath: string, + id: string, +): Promise { + const sourcePath = getDrawingFilePath(vaultPath, id) + + if (!(await fs.pathExists(sourcePath))) { + return null + } + + const uniqueName = await getUniqueDrawingName(vaultPath, id) + const targetPath = getDrawingFilePath(vaultPath, uniqueName) + rememberAppChange(targetPath) + await fs.copy(sourcePath, targetPath) + + return toDrawingRecord(targetPath) +} + +export async function deleteDrawing( + vaultPath: string, + id: string, +): Promise<{ deleted: boolean }> { + const filePath = getDrawingFilePath(vaultPath, id) + + if (!(await fs.pathExists(filePath))) { + return { deleted: false } + } + + rememberAppChange(filePath) + await fs.remove(filePath) + + return { deleted: true } +} diff --git a/src/main/storage/providers/markdown/http/index.ts b/src/main/storage/providers/markdown/http/index.ts index 9033f3b4b..d7bf0fc50 100644 --- a/src/main/storage/providers/markdown/http/index.ts +++ b/src/main/storage/providers/markdown/http/index.ts @@ -2,7 +2,9 @@ export { peekHttpRuntimeCache } from './runtime/constants' export { getHttpPaths } from './runtime/paths' export { getHttpRuntimeCache, + isHttpVaultDiskReady, resetHttpRuntimeCache, syncHttpRuntimeWithDisk, } from './runtime/sync' +export type { HttpPaths } from './runtime/types' export { createHttpStorageProvider } from './storages' diff --git a/src/main/storage/providers/markdown/http/runtime/parser.ts b/src/main/storage/providers/markdown/http/runtime/parser.ts index cf51b1b23..9a7fb76bd 100644 --- a/src/main/storage/providers/markdown/http/runtime/parser.ts +++ b/src/main/storage/providers/markdown/http/runtime/parser.ts @@ -11,7 +11,11 @@ import type { import path from 'node:path' import fs from 'fs-extra' import yaml from 'js-yaml' +import { log } from '../../../../../utils' +import { enqueueCloudDownload } from '../../cloudDownloads' import { normalizeFlag } from '../../runtime/normalizers' +import { getFileAvailability } from '../../runtime/shared/cloudFiles' +import { throwCloudContentUnavailable } from '../../runtime/shared/cloudGuards' const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ const HTTP_METHODS: HttpMethod[] = [ @@ -53,7 +57,7 @@ function splitFrontmatter(source: string): { } } -function normalizeMethod(value: unknown): HttpMethod { +export function normalizeMethod(value: unknown): HttpMethod { if (typeof value !== 'string') { return 'GET' } @@ -62,7 +66,7 @@ function normalizeMethod(value: unknown): HttpMethod { return HTTP_METHODS.includes(upper) ? upper : 'GET' } -function normalizeBodyType(value: unknown): HttpBodyType { +export function normalizeBodyType(value: unknown): HttpBodyType { if (typeof value !== 'string') { return 'none' } @@ -218,7 +222,16 @@ export function readRequestFile( filePath: string, ): ParsedRequestFile | null { const absolutePath = path.join(httpRoot, filePath) - if (!fs.pathExistsSync(absolutePath)) { + const availability = getFileAvailability(absolutePath) + + if (!availability.exists) { + return null + } + + // Недокачанный файл не читается синхронно: он уходит в фоновую докачку, + // после которой запрос появится через повторный sync. + if (availability.isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) return null } @@ -226,14 +239,85 @@ export function readRequestFile( return parseRequestFile(source) } +// Дочитывает body и description записи, построенной из индекса. Остальные +// поля runtime-записи авторитетны и могут содержать ещё не сохранённые +// правки. Возвращает false, если содержимое сейчас недоступно +// (плейсхолдер, сбой чтения). +export function ensureRequestDetailsLoaded( + httpRoot: string, + record: HttpRequestRecord, +): boolean { + if (record.pendingCloudDownload) { + return false + } + + if (!record.detailsPending) { + return true + } + + const absolutePath = path.join(httpRoot, record.filePath) + const availability = getFileAvailability(absolutePath) + + if (!availability.exists) { + return false + } + + if (availability.isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + return false + } + + let source: string + try { + source = fs.readFileSync(absolutePath, 'utf8') + } + catch (error) { + log('storage:http:load-request-details', error) + enqueueCloudDownload(absolutePath) + return false + } + + const parsed = parseRequestFile(source) + record.body = parsed.normalized.body + record.description = parsed.description + delete record.detailsPending + + return true +} + export function writeRequestFile( httpRoot: string, record: HttpRequestRecord, + options?: { skipIfUnavailable?: boolean }, ): void { const absolutePath = path.join(httpRoot, record.filePath) + const availability = getFileAvailability(absolutePath) + + // Запись в недокачанный файл затёрла бы облачное содержимое: файл сначала + // докачивается в фоне. По умолчанию сбой поднимается наверх: тихий пропуск + // означал бы «принятую» правку, которую докачка затем молча перезапишет + // облачным содержимым. Пропуск допустим только в необязательном write-back + // scan-пути. + if (record.pendingCloudDownload || availability.isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + if (options?.skipIfUnavailable) { + return + } + throwCloudContentUnavailable() + } + + // Ленивая запись (body/description ещё не дочитаны из индекса): они + // дочитываются перед сериализацией, иначе запись затёрла бы их пустыми. + // Тихий пропуск записи потерял бы правку метаданных при следующем скане, + // поэтому сбой поднимается наверх. В scan-путях (write-back после чтения) + // запись уже прочитана и ветка недостижима. + if (!ensureRequestDetailsLoaded(httpRoot, record)) { + throwCloudContentUnavailable() + } + const next = serializeRequestFile(record) - if (fs.pathExistsSync(absolutePath)) { + if (availability.exists) { const current = fs.readFileSync(absolutePath, 'utf8') if (current === next) { return diff --git a/src/main/storage/providers/markdown/http/runtime/state.ts b/src/main/storage/providers/markdown/http/runtime/state.ts index 89ec199fd..b264e7e3e 100644 --- a/src/main/storage/providers/markdown/http/runtime/state.ts +++ b/src/main/storage/providers/markdown/http/runtime/state.ts @@ -6,10 +6,14 @@ import type { HttpStateFile, } from './types' import fs from 'fs-extra' +import { pendingStateWriteByPath } from '../../runtime/cache' import { readSpaceState, writeSpaceState } from '../../runtime/spaceState' import { HTTP_HISTORY_CAP } from './constants' -const STATE_VERSION = 1 +// Версия 2: записи requests несут денормализованные метаданные списка и +// stat-сигнатуру (`meta`). Записи без meta (v1) дозаполняются организно: +// файл читается один раз при первом скане и метаданные попадают в индекс. +const STATE_VERSION = 2 export function createDefaultHttpState(): HttpState { return { @@ -64,6 +68,13 @@ function normalizeFolders(raw: HttpStateFile['folders']): HttpFolderRecord[] { export function ensureHttpStateFile(paths: HttpPaths): void { fs.ensureDirSync(paths.httpRoot) + // Ожидающая debounce-запись уже содержит актуальный state: перезапись + // дефолтом потеряла бы индекс и environments, записанные за последние + // мгновения до того, как файл впервые доехал до диска. + if (pendingStateWriteByPath.has(paths.statePath)) { + return + } + if (!fs.pathExistsSync(paths.statePath)) { writeSpaceState(paths.statePath, createDefaultHttpState()) } @@ -96,11 +107,32 @@ export function loadHttpState(paths: HttpPaths): HttpState { } export function saveHttpState(paths: HttpPaths, state: HttpState): void { + // Provisional state существует только пока .state.yaml не докачан из + // облака: записать его — значит затереть настоящий индекс и environments + // почти пустым состоянием. + if (state.provisional) { + return + } + state.version = Math.max(state.version, STATE_VERSION) if (state.history.length > HTTP_HISTORY_CAP) { state.history = state.history.slice(-HTTP_HISTORY_CAP) } - writeSpaceState(paths.statePath, state) + // Персистится явная схема: .state.yaml синхронизируется между + // устройствами и не должен накапливать посторонние и runtime-поля. + writeSpaceState(paths.statePath, { + version: state.version, + counters: state.counters, + folders: state.folders, + requests: state.requests.map(({ filePath, id, meta }) => ({ + filePath, + id, + ...(meta ? { meta } : {}), + })), + environments: state.environments, + activeEnvironmentId: state.activeEnvironmentId, + history: state.history, + }) } diff --git a/src/main/storage/providers/markdown/http/runtime/sync.ts b/src/main/storage/providers/markdown/http/runtime/sync.ts index b50c9de22..f2f44b024 100644 --- a/src/main/storage/providers/markdown/http/runtime/sync.ts +++ b/src/main/storage/providers/markdown/http/runtime/sync.ts @@ -1,20 +1,40 @@ import type { HttpFolderRecord, HttpPaths, + HttpRequestIndexMetadata, HttpRequestRecord, HttpRuntimeCache, HttpState, } from './types' import path from 'node:path' import fs from 'fs-extra' -import { toPosixPath } from '../../runtime/shared/path' +import { log } from '../../../../../utils' +import { enqueueCloudDownload } from '../../cloudDownloads' +import { normalizeFlag } from '../../runtime/normalizers' +import { + getFileAvailability, + primeDatalessChecks, +} from '../../runtime/shared/cloudFiles' +import { isCloudFileNotDownloadedError } from '../../runtime/shared/guardedRead' +import { + readDirEntriesFailClosed, + toPosixPath, +} from '../../runtime/shared/path' +import { createVaultReconciler } from '../../runtime/shared/vaultReconcile' import { HTTP_STATE_FILE_NAME, httpRuntimeRef } from './constants' import { + normalizeBodyType, + normalizeMethod, parseRequestFile, serializeRequestFile, writeRequestFile, } from './parser' -import { ensureHttpStateFile, loadHttpState, saveHttpState } from './state' +import { + createDefaultHttpState, + ensureHttpStateFile, + loadHttpState, + saveHttpState, +} from './state' const SKIP_FILES = new Set([HTTP_STATE_FILE_NAME]) @@ -29,11 +49,7 @@ function walkHttpDir(rootPath: string, currentPath = rootPath): DiskWalkResult { requestRelativePaths: [], } - if (!fs.pathExistsSync(currentPath)) { - return result - } - - const entries = fs.readdirSync(currentPath, { withFileTypes: true }) + const entries = readDirEntriesFailClosed(currentPath) for (const entry of entries) { if (entry.name.startsWith('.')) { @@ -142,6 +158,142 @@ function reconcileFolders( return folderIdByPath } +// Метаданные индекса собираются только по реально прочитанному файлу, а +// stat-сигнатура — по stat до чтения. Записи приложения не обновляют meta: +// изменённый mtime просто заставит перечитать файл на следующем старте. +function buildHttpRequestIndexMetadata( + record: HttpRequestRecord, + stats: { mtimeMs: number, size: number }, +): HttpRequestIndexMetadata { + return { + auth: record.auth, + bodyType: record.bodyType, + createdAt: record.createdAt, + description: record.description, + formData: record.formData, + headers: record.headers, + isDeleted: record.isDeleted, + isFavorites: record.isFavorites, + method: record.method, + mtimeMs: stats.mtimeMs, + name: record.name, + query: record.query, + size: stats.size, + updatedAt: record.updatedAt, + url: record.url, + } +} + +// .state.yaml синхронизируется между устройствами и правится извне, поэтому +// метаданные индекса перед использованием проверяются по форме: битая +// запись не роняет скан, файл просто перечитывается. +// Мягкая форма для placeholder-ветки: description может отсутствовать +// (ранний dev-формат меты) — остальные поля всё равно ценнее минимального +// placeholder'а, который «воскресил» бы трэшнутые запросы (isDeleted: 0) +// и потерял бы избранное до докачки. +function isUsableHttpRequestIndexMetadata( + meta: HttpRequestIndexMetadata | undefined, +): meta is HttpRequestIndexMetadata { + return ( + !!meta + && typeof meta === 'object' + && typeof meta.name === 'string' + && typeof meta.method === 'string' + && typeof meta.bodyType === 'string' + && typeof meta.url === 'string' + && Array.isArray(meta.headers) + && Array.isArray(meta.query) + && Array.isArray(meta.formData) + && !!meta.auth + && typeof meta.auth === 'object' + && typeof meta.mtimeMs === 'number' + && Number.isFinite(meta.mtimeMs) + && typeof meta.size === 'number' + && Number.isFinite(meta.size) + && typeof meta.createdAt === 'number' + && typeof meta.updatedAt === 'number' + ) +} + +function isValidHttpRequestIndexMetadata( + meta: HttpRequestIndexMetadata | undefined, +): meta is HttpRequestIndexMetadata { + return ( + isUsableHttpRequestIndexMetadata(meta) + // meta без description (ранний dev-формат) бракуется в fresh-ветке: + // файл будет перечитан один раз, и индекс дозаполнится. + && typeof meta.description === 'string' + ) +} + +// Запись из метаданных индекса, без чтения файла: body дочитывается лениво +// (ensureRequestDetailsLoaded), description уже есть в метаданных. +function buildRequestFromIndexMetadata( + entryId: number, + relativePath: string, + meta: HttpRequestIndexMetadata, + folderId: number | null, + options?: { pendingCloudDownload?: boolean }, +): HttpRequestRecord { + // Enum-поля нормализуются: .state.yaml мог быть правлен извне, а мусорное + // значение уронило бы валидацию DTO целого списка. + return { + id: entryId, + name: meta.name, + folderId, + method: normalizeMethod(meta.method), + url: meta.url, + headers: meta.headers, + query: meta.query, + bodyType: normalizeBodyType(meta.bodyType), + body: null, + formData: meta.formData, + auth: meta.auth, + description: meta.description ?? '', + filePath: relativePath, + isFavorites: normalizeFlag(meta.isFavorites), + isDeleted: normalizeFlag(meta.isDeleted), + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + detailsPending: true, + ...(options?.pendingCloudDownload ? { pendingCloudDownload: true } : {}), + } +} + +// Минимальная placeholder-запись для недоступного файла без метаданных +// индекса: entries из v5.8-state имеют только {id, filePath}, и без такой +// записи весь старый offloaded vault выглядел бы пустым до докачки +// (offline — бессрочно). Имя берётся из имени файла, детали дочитаются +// после докачки, мутации и отправка заблокированы pendingCloudDownload. +function buildPlaceholderRequest( + entryId: number, + relativePath: string, + folderId: number | null, + now: number, +): HttpRequestRecord { + return { + auth: { type: 'none' }, + body: null, + bodyType: 'none', + createdAt: now, + description: '', + detailsPending: true, + filePath: relativePath, + folderId, + formData: [], + headers: [], + id: entryId, + isDeleted: 0, + isFavorites: 0, + method: 'GET', + name: path.posix.basename(relativePath, '.md'), + pendingCloudDownload: true, + query: [], + updatedAt: now, + url: '', + } +} + function reconcileRequests( paths: HttpPaths, state: HttpState, @@ -149,20 +301,131 @@ function reconcileRequests( folderIdByPath: Map, ): HttpRequestRecord[] { const existingByPath = new Map( - state.requests.map(item => [item.filePath, item.id]), + state.requests.map(item => [item.filePath, item]), + ) + + // Один batch-вызов точной проверки dataless на весь список вместо + // отдельного системного вызова на каждый подозрительный файл. + primeDatalessChecks( + requestRelativePaths.map(relativePath => + path.join(paths.httpRoot, relativePath), + ), ) + const usedIds = new Set() const records: HttpRequestRecord[] = [] const indexEntries: HttpState['requests'] = [] const now = Date.now() + const resolveFolderId = (relativePath: string): number | null => { + const dirPath = path.posix.dirname(relativePath) + return dirPath && dirPath !== '.' + ? (folderIdByPath.get(dirPath) ?? null) + : null + } + + // Файл, содержимое которого сейчас недоступно (облачный плейсхолдер или + // сбой чтения), не читается синхронно: он уходит в фоновую докачку, + // которая триггерит повторный sync http-пространства. Уже известный + // запрос при этом сохраняет свою запись в индексе (вместе с метаданными), + // иначе он «исчез» бы из state и после докачки получил бы новый id. + // Запрос сразу виден в списке placeholder-записью — как сниппеты и + // заметки: полной при известных метаданных, минимальной (имя из файла) + // для v5.8-entries без meta. + const keepUnavailableRequest = ( + absolutePath: string, + relativePath: string, + ) => { + enqueueCloudDownload(absolutePath) + + const knownEntry = existingByPath.get(relativePath) + if (knownEntry && !usedIds.has(knownEntry.id)) { + usedIds.add(knownEntry.id) + indexEntries.push({ + filePath: relativePath, + id: knownEntry.id, + ...(knownEntry.meta ? { meta: knownEntry.meta } : {}), + }) + + if (isUsableHttpRequestIndexMetadata(knownEntry.meta)) { + records.push( + buildRequestFromIndexMetadata( + knownEntry.id, + relativePath, + knownEntry.meta, + resolveFolderId(relativePath), + { pendingCloudDownload: true }, + ), + ) + } + else { + records.push( + buildPlaceholderRequest( + knownEntry.id, + relativePath, + resolveFolderId(relativePath), + now, + ), + ) + } + } + } + for (const relativePath of requestRelativePaths) { const absolutePath = path.join(paths.httpRoot, relativePath) - const source = fs.readFileSync(absolutePath, 'utf8') + const availability = getFileAvailability(absolutePath) + + if (availability.isCloudPlaceholder) { + keepUnavailableRequest(absolutePath, relativePath) + continue + } + + // Свежая stat-сигнатура: запись строится из индекса без чтения файла, + // body и description дочитываются лениво по первому обращению. + const existingEntry = existingByPath.get(relativePath) + if ( + existingEntry + && !usedIds.has(existingEntry.id) + && availability.stats + && isValidHttpRequestIndexMetadata(existingEntry.meta) + && existingEntry.meta.mtimeMs === availability.stats.mtimeMs + && existingEntry.meta.size === availability.stats.size + ) { + usedIds.add(existingEntry.id) + if (existingEntry.id > state.counters.requestId) { + state.counters.requestId = existingEntry.id + } + + records.push( + buildRequestFromIndexMetadata( + existingEntry.id, + relativePath, + existingEntry.meta, + resolveFolderId(relativePath), + ), + ) + indexEntries.push({ + filePath: relativePath, + id: existingEntry.id, + meta: existingEntry.meta, + }) + continue + } + + let source: string + try { + source = fs.readFileSync(absolutePath, 'utf8') + } + catch (error) { + log('storage:http:read-request', error) + keepUnavailableRequest(absolutePath, relativePath) + continue + } + const parsed = parseRequestFile(source) const fmId = parsed.frontmatter.id - let id = existingByPath.get(relativePath) + let id = existingEntry?.id let needsRewrite = !parsed.hasFrontmatter if (!id) { @@ -181,9 +444,7 @@ function reconcileRequests( } usedIds.add(id) - const dirPath = path.posix.dirname(relativePath) - const folderId - = dirPath && dirPath !== '.' ? (folderIdByPath.get(dirPath) ?? null) : null + const folderId = resolveFolderId(relativePath) const fileName = path.posix.basename(relativePath, '.md') const fmCreatedAt = parsed.frontmatter.createdAt @@ -210,11 +471,20 @@ function reconcileRequests( } if (needsRewrite || serializeRequestFile(record) !== source) { - writeRequestFile(paths.httpRoot, record) + writeRequestFile(paths.httpRoot, record, { skipIfUnavailable: true }) } records.push(record) - indexEntries.push({ id, filePath: relativePath }) + // Индекс обновляется по реально прочитанному файлу: следующий холодный + // старт соберёт запись без чтения. Write-back выше сдвигает mtime после + // снятой сигнатуры — такой файл будет перечитан один раз и заживёт. + indexEntries.push({ + id, + filePath: relativePath, + meta: availability.stats + ? buildHttpRequestIndexMetadata(record, availability.stats) + : undefined, + }) } state.requests = indexEntries @@ -234,7 +504,50 @@ function buildRuntimeCache( } } -export function syncHttpRuntimeWithDisk(paths: HttpPaths): HttpRuntimeCache { +const httpVaultReconciler = createVaultReconciler('http') + +// Полные обходы диска (например, Vault Doctor) допустимы только после +// фоновой сверки: до неё листинги каталогов могут блокироваться сетью. +export function isHttpVaultDiskReady(paths: HttpPaths): boolean { + return httpVaultReconciler.isReconciled(paths.httpRoot) +} + +// Мгновенный кэш из state без обхода диска: запросы появятся после фоновой +// сверки, их записи в state сохраняются (id стабильны). +function buildProvisionalHttpCache(paths: HttpPaths): HttpRuntimeCache { + if ( + httpRuntimeRef.cache + && httpRuntimeRef.cache.paths.httpRoot === paths.httpRoot + ) { + return httpRuntimeRef.cache + } + + ensureHttpStateFile(paths) + + // .state.yaml сам может быть облачным плейсхолдером: тогда loadHttpState + // бросает, а кэш строится на неперсистируемом дефолтном state (флаг + // provisional блокирует запись и мутации до докачки). Пространство + // наполнится после докачки state и повторной сверки. + let state: HttpState + try { + state = loadHttpState(paths) + } + catch (error) { + if (!isCloudFileNotDownloadedError(error)) { + throw error + } + state = createDefaultHttpState() + state.provisional = true + } + + const cache = buildRuntimeCache(paths, state, []) + httpRuntimeRef.cache = cache + return cache +} + +// Настоящая сверка с диском: может бросить, если .state.yaml сам недокачан +// из облака (reconciler ретраит по этой ошибке). +function performFullHttpSync(paths: HttpPaths): HttpRuntimeCache { ensureHttpStateFile(paths) const state = loadHttpState(paths) @@ -254,6 +567,30 @@ export function syncHttpRuntimeWithDisk(paths: HttpPaths): HttpRuntimeCache { return cache } +export function syncHttpRuntimeWithDisk(paths: HttpPaths): HttpRuntimeCache { + // Первый доступ к vault: обход диска опасен синхронно (листинги + // dataless-каталогов материализуются сетью), поэтому мгновенно отдаётся + // provisional-кэш, а настоящая сверка выполняется в фоне. + if (!httpVaultReconciler.isReconciled(paths.httpRoot)) { + const provisionalCache = buildProvisionalHttpCache(paths) + + httpVaultReconciler.begin(paths.httpRoot, () => { + if ( + httpRuntimeRef.cache + && httpRuntimeRef.cache.paths.httpRoot !== paths.httpRoot + ) { + return + } + + performFullHttpSync(paths) + }) + + return provisionalCache + } + + return performFullHttpSync(paths) +} + export function getHttpRuntimeCache(paths: HttpPaths): HttpRuntimeCache { if ( httpRuntimeRef.cache @@ -266,5 +603,11 @@ export function getHttpRuntimeCache(paths: HttpPaths): HttpRuntimeCache { } export function resetHttpRuntimeCache(): void { + // Смена vault: ретраи сверки брошенного корня останавливаются, иначе они + // продолжили бы попытки по неактивному пути и слали storage-synced. + const previousHttpRoot = httpRuntimeRef.cache?.paths.httpRoot + if (previousHttpRoot) { + httpVaultReconciler.abandon(previousHttpRoot) + } httpRuntimeRef.cache = null } diff --git a/src/main/storage/providers/markdown/http/runtime/types.ts b/src/main/storage/providers/markdown/http/runtime/types.ts index 8198e7bd6..aff5a7b8b 100644 --- a/src/main/storage/providers/markdown/http/runtime/types.ts +++ b/src/main/storage/providers/markdown/http/runtime/types.ts @@ -50,9 +50,33 @@ export interface HttpFolderTreeRecord extends HttpFolderRecord { children: HttpFolderTreeRecord[] } +// Денормализованные метаданные списка в .state.yaml (слой 4 плана +// icloud-lazy-vault-load): всё, кроме body, чтобы строить записи без чтения +// файлов (description входит: он отдаётся списком GET /http-requests). +// mtimeMs/size — freshness-сигнатура последнего чтения: пока stat +// совпадает, файл не перечитывается. +export interface HttpRequestIndexMetadata { + auth: HttpAuth + bodyType: HttpBodyType + createdAt: number + description: string + formData: HttpFormDataEntry[] + headers: HttpHeaderEntry[] + isDeleted: number + isFavorites: number + method: HttpMethod + mtimeMs: number + name: string + query: HttpQueryEntry[] + size: number + updatedAt: number + url: string +} + export interface HttpRequestIndexItem { id: number filePath: string + meta?: HttpRequestIndexMetadata } export interface HttpRequestRecord { @@ -73,6 +97,17 @@ export interface HttpRequestRecord { isDeleted: number createdAt: number updatedAt: number + /** + * Runtime-only: body и description ещё не дочитаны из файла (запись + * построена из индекса метаданных). Снимается ensureRequestDetailsLoaded; + * наружу через API не отдаётся — все выдающие потоки материализуют. + */ + detailsPending?: boolean + /** + * Файл запроса — облачный плейсхолдер: содержимое ещё не скачано + * провайдером, запись показывается в списке и докачивается в фоне. + */ + pendingCloudDownload?: boolean } export interface HttpEnvironmentRecord { @@ -120,6 +155,9 @@ export interface HttpState { environments: HttpEnvironmentRecord[] activeEnvironmentId: number | null history: HttpHistoryRecord[] + // Дефолтный state на период, пока .state.yaml не докачан из облака: + // такой state нельзя ни персистить, ни использовать для выдачи id. + provisional?: boolean } export interface HttpPaths { diff --git a/src/main/storage/providers/markdown/http/storages/__tests__/requests.test.ts b/src/main/storage/providers/markdown/http/storages/__tests__/requests.test.ts new file mode 100644 index 000000000..94402e7e8 --- /dev/null +++ b/src/main/storage/providers/markdown/http/storages/__tests__/requests.test.ts @@ -0,0 +1,345 @@ +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import yaml from 'js-yaml' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { stateContentCacheByPath } from '../../../runtime/cache' +import { setDatalessProbeForTests } from '../../../runtime/shared/cloudFiles' +import { flushPendingStateWrites } from '../../../runtime/shared/stateWriter' +import { getHttpPaths } from '../../runtime/paths' +import { ensureHttpStateFile } from '../../runtime/state' +import { getHttpRuntimeCache, resetHttpRuntimeCache } from '../../runtime/sync' +import { createHttpRequestsStorage } from '../requests' + +let tempVaultPath = '' + +vi.mock('electron-store', () => { + class MockStore { + private state: Record + + constructor(options?: { defaults?: Record }) { + this.state = { ...(options?.defaults || {}) } + } + + get(key?: string): unknown { + if (!key) { + return this.state + } + + return key.split('.').reduce((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined + } + + return (acc as Record)[segment] + }, this.state) + } + + set(key: string, value: unknown): void { + const segments = key.split('.') + let cursor: Record = this.state + + for (let index = 0; index < segments.length - 1; index += 1) { + const segment = segments[index] + const next = cursor[segment] + + if (!next || typeof next !== 'object') { + cursor[segment] = {} + } + + cursor = cursor[segment] as Record + } + + cursor[segments[segments.length - 1]] = value + } + } + + return { default: MockStore } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir(), + }, +})) + +vi.mock('../../../cloudDownloads', () => ({ + enqueueCloudDownload: vi.fn(), + prioritizeCloudDownload: vi.fn(), +})) + +vi.mock('../../../../../../store', () => ({ + store: { + preferences: { + get: (key: string) => { + if (key === 'storage.vaultPath') { + return tempVaultPath + } + + return undefined + }, + }, + }, +})) + +describe('http requests storage', () => { + beforeEach(() => { + tempVaultPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'http-requests-storage-'), + ) + resetHttpRuntimeCache() + ensureHttpStateFile(getHttpPaths(tempVaultPath)) + }) + + afterEach(() => { + setDatalessProbeForTests(null) + resetHttpRuntimeCache() + fs.removeSync(tempVaultPath) + tempVaultPath = '' + vi.useRealTimers() + }) + + // Два ресинка: первый скан дозаполняет индекс метаданных, второй строит + // ленивые записи из индекса без чтения body/description. + function resyncTwiceForLazyRequests() { + resetHttpRuntimeCache() + getHttpRuntimeCache(getHttpPaths(tempVaultPath)) + resetHttpRuntimeCache() + return getHttpRuntimeCache(getHttpPaths(tempVaultPath)) + } + + it('materializes lazy body and description on getRequestById', () => { + const storage = createHttpRequestsStorage() + const { id } = storage.createRequest({ name: 'Lazy Read' }) + storage.updateRequest(id, { + body: '{"payload":true}', + bodyType: 'json', + description: 'request docs', + }) + + const cache = resyncTwiceForLazyRequests() + const lazyRecord = cache.requestById.get(id) + expect(lazyRecord?.detailsPending).toBe(true) + expect(lazyRecord?.body).toBeNull() + expect(lazyRecord?.bodyType).toBe('json') + + const record = storage.getRequestById(id) + expect(record?.body).toBe('{"payload":true}') + expect(record?.description).toBe('request docs') + expect(record?.detailsPending).toBeUndefined() + }) + + it('keeps request details lazy in list results', () => { + const storage = createHttpRequestsStorage() + const { id } = storage.createRequest({ name: 'List Read' }) + storage.updateRequest(id, { body: 'list-body', bodyType: 'json' }) + + resyncTwiceForLazyRequests() + + // Список не требует body/description: записи остаются ленивыми, роут + // срезает эти поля из ответа. + const listed = storage.getRequests() + const record = listed.find(request => request.id === id) + expect(record?.detailsPending).toBe(true) + expect(record?.body).toBeNull() + }) + + it('builds untouched requests from the index without reading files', () => { + const storage = createHttpRequestsStorage() + const { id } = storage.createRequest({ name: 'Frozen' }) + storage.updateRequest(id, { url: 'https://example.com' }) + + resyncTwiceForLazyRequests() + + // Имя в файле меняется, но сигнатура индекса совпадает со stat: если + // повторный скан отдаёт старое имя, файл действительно не читался. + // Правка state.yaml имитирует внешнее изменение при выключенном + // приложении: pending-запись сбрасывается, кэш содержимого чистится. + const paths = getHttpPaths(tempVaultPath) + const cacheBefore = getHttpRuntimeCache(paths) + const filePath = cacheBefore.requestById.get(id)!.filePath + const absolutePath = path.join(paths.httpRoot, filePath) + fs.writeFileSync( + absolutePath, + fs.readFileSync(absolutePath, 'utf8').replace('Frozen', 'Hacked'), + 'utf8', + ) + + flushPendingStateWrites() + const stats = fs.statSync(absolutePath) + const persisted = yaml.load(fs.readFileSync(paths.statePath, 'utf8')) as { + requests: { meta?: { mtimeMs: number, size: number } }[] + } + persisted.requests[0].meta!.mtimeMs = stats.mtimeMs + persisted.requests[0].meta!.size = stats.size + fs.writeFileSync(paths.statePath, yaml.dump(persisted), 'utf8') + stateContentCacheByPath.delete(paths.statePath) + + resetHttpRuntimeCache() + const cache = getHttpRuntimeCache(paths) + const record = cache.requestById.get(id) + + expect(record?.name).toBe('Frozen') + expect(record?.detailsPending).toBe(true) + }) + + it('lists cloud placeholder requests from index metadata', () => { + const storage = createHttpRequestsStorage() + const { id } = storage.createRequest({ name: 'Offloaded' }) + storage.updateRequest(id, { url: 'https://example.com' }) + + resyncTwiceForLazyRequests() + + // Провайдер «выгрузил» файл: содержимое заменяется sparse-плейсхолдером + // (size > 0, blocks 0), точная проверка dataless подменяется. + const paths = getHttpPaths(tempVaultPath) + const filePath = getHttpRuntimeCache(paths).requestById.get(id)!.filePath + const absolutePath = path.join(paths.httpRoot, filePath) + fs.removeSync(absolutePath) + const fd = fs.openSync(absolutePath, 'w') + fs.ftruncateSync(fd, 4096) + fs.closeSync(fd) + + const stats = fs.statSync(absolutePath) + if (stats.size === 0 || stats.blocks !== 0) { + // На ФС без поддержки sparse-файлов сценарий невоспроизводим. + return + } + setDatalessProbeForTests(() => true) + + resetHttpRuntimeCache() + const listed = createHttpRequestsStorage().getRequests() + const record = listed.find(request => request.id === id) + + // Недокачанный запрос виден в списке по метаданным индекса. + expect(record?.pendingCloudDownload).toBe(true) + expect(record?.name).toBe('Offloaded') + expect(record?.url).toBe('https://example.com') + }) + + it('re-reads the file when index metadata misses bodyType', () => { + const storage = createHttpRequestsStorage() + const { id } = storage.createRequest({ name: 'No BodyType' }) + storage.updateRequest(id, { body: '{"x":1}', bodyType: 'json' }) + + resyncTwiceForLazyRequests() + + // Внешне правленный .state.yaml потерял bodyType: битая meta не должна + // ронять список — валидатор бракует запись, файл перечитывается. + flushPendingStateWrites() + const paths = getHttpPaths(tempVaultPath) + const persisted = yaml.load(fs.readFileSync(paths.statePath, 'utf8')) as { + requests: { meta?: { bodyType?: string } }[] + } + delete persisted.requests[0].meta!.bodyType + fs.writeFileSync(paths.statePath, yaml.dump(persisted), 'utf8') + stateContentCacheByPath.delete(paths.statePath) + + resetHttpRuntimeCache() + const listed = createHttpRequestsStorage().getRequests() + const record = listed.find(request => request.id === id) + + expect(record?.bodyType).toBe('json') + }) + + it('rejects updates when lazy details are unavailable', () => { + const storage = createHttpRequestsStorage() + const { id } = storage.createRequest({ name: 'Vanishing' }) + storage.updateRequest(id, { body: 'data', bodyType: 'json' }) + + resyncTwiceForLazyRequests() + + // Файл недоступен: «принятая» правка молча потерялась бы при следующем + // ресинке, поэтому мутация отклоняется до изменения кэша. + const paths = getHttpPaths(tempVaultPath) + const record = getHttpRuntimeCache(paths).requestById.get(id)! + fs.removeSync(path.join(paths.httpRoot, record.filePath)) + + expect(() => storage.updateRequest(id, { name: 'Renamed' })).toThrow( + 'CLOUD_FILE_NOT_DOWNLOADED', + ) + expect(record.name).toBe('Vanishing') + }) + + it('keeps body intact when renaming a lazy request', () => { + const storage = createHttpRequestsStorage() + const { id } = storage.createRequest({ name: 'Lazy Rename' }) + storage.updateRequest(id, { body: 'keep me', bodyType: 'text' }) + + resyncTwiceForLazyRequests() + + // Переименование сериализует запрос целиком: незагруженные body и + // description должны дочитаться, а не затереться пустыми. + storage.updateRequest(id, { name: 'Lazy Renamed' }) + + const record = storage.getRequestById(id) + expect(record?.body).toBe('keep me') + + const paths = getHttpPaths(tempVaultPath) + const rawSource = fs.readFileSync( + path.join(paths.httpRoot, record!.filePath), + 'utf8', + ) + expect(rawSource).toContain('keep me') + }) + + it('sorts requests by name and updated date', () => { + vi.useFakeTimers() + + const storage = createHttpRequestsStorage() + + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const bravo = storage.createRequest({ name: 'Bravo' }) + + vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')) + const alpha = storage.createRequest({ name: 'Alpha' }) + + vi.setSystemTime(new Date('2026-01-01T00:00:02.000Z')) + storage.updateRequest(bravo.id, { description: 'Updated' }) + + expect( + storage + .getRequests({ sort: 'name', order: 'ASC' }) + .map(request => request.id), + ).toEqual([alpha.id, bravo.id]) + expect( + storage + .getRequests({ sort: 'updatedAt', order: 'DESC' }) + .map(request => request.id), + ).toEqual([bravo.id, alpha.id]) + }) + + it('keeps URL search and searchNameOnly behavior while sorting', () => { + const storage = createHttpRequestsStorage() + + const urlMatch = storage.createRequest({ + name: 'Bravo', + url: 'https://example.com/target', + }) + storage.createRequest({ + name: 'Alpha', + url: 'https://example.com/other', + }) + const nameMatch = storage.createRequest({ + name: 'Target Request', + url: 'https://example.com/other-target', + }) + + expect( + storage + .getRequests({ search: 'target', sort: 'name', order: 'ASC' }) + .map(request => request.id), + ).toEqual([urlMatch.id, nameMatch.id]) + expect( + storage + .getRequests({ + search: 'target', + searchNameOnly: 1, + sort: 'name', + order: 'ASC', + }) + .map(request => request.id), + ).toEqual([nameMatch.id]) + }) +}) diff --git a/src/main/storage/providers/markdown/http/storages/environments.ts b/src/main/storage/providers/markdown/http/storages/environments.ts index 2fe7de1e3..87152cebe 100644 --- a/src/main/storage/providers/markdown/http/storages/environments.ts +++ b/src/main/storage/providers/markdown/http/storages/environments.ts @@ -6,7 +6,11 @@ import type { } from '../../../../contracts' import type { HttpEnvironmentRecord } from '../runtime/types' import { getVaultPath } from '../../runtime/paths' -import { throwStorageError, validateEntryName } from '../../runtime/validation' +import { + assertVaultNotHydrating, + throwStorageError, + validateEntryName, +} from '../../runtime/validation' import { getHttpPaths } from '../runtime/paths' import { saveHttpState } from '../runtime/state' import { getHttpRuntimeCache } from '../runtime/sync' @@ -68,6 +72,7 @@ export function createHttpEnvironmentsStorage(): HttpEnvironmentsStorage { const paths = resolvePaths() const { state } = getHttpRuntimeCache(paths) + assertVaultNotHydrating(state) const name = validateEntryName(input.name, 'folder') const conflict = state.environments.some( env => env.name.toLowerCase() === name.toLowerCase(), diff --git a/src/main/storage/providers/markdown/http/storages/folders.ts b/src/main/storage/providers/markdown/http/storages/folders.ts index bd0abe2bb..fb96d5dec 100644 --- a/src/main/storage/providers/markdown/http/storages/folders.ts +++ b/src/main/storage/providers/markdown/http/storages/folders.ts @@ -14,6 +14,10 @@ import path from 'node:path' import fs from 'fs-extra' import { normalizeFlag, normalizeNumber } from '../../runtime/normalizers' import { getVaultPath } from '../../runtime/paths' +import { + assertEntityFileWritable, + throwCloudContentUnavailable, +} from '../../runtime/shared/cloudGuards' import { buildFolderPathMap, collectDescendantIds, @@ -22,6 +26,7 @@ import { import { applyFolderParentAndOrder, assertFolderMoveTargetValid, + assertNoUnknownDomainFiles, createFolderInStateAndDisk, getFolderPathsByDepth, getFoldersSortedByCreatedAt, @@ -39,10 +44,13 @@ import { throwStorageError, validateEntryName, } from '../../runtime/validation' -import { writeRequestFile } from '../runtime/parser' +import { + ensureRequestDetailsLoaded, + writeRequestFile, +} from '../runtime/parser' import { getHttpPaths } from '../runtime/paths' import { saveHttpState } from '../runtime/state' -import { getHttpRuntimeCache } from '../runtime/sync' +import { getHttpRuntimeCache, isHttpVaultDiskReady } from '../runtime/sync' function findHttpFolderById( folders: HttpFolderRecord[], @@ -195,6 +203,20 @@ export function createHttpFoldersStorage(): HttpFoldersStorage { input: HttpFolderUpdateInput, ): HttpFolderUpdateResult { const paths = resolvePaths() + + // Rename/move каталога до завершения фоновой сверки работал бы по + // пустому provisional-списку запросов: файлы переместились бы на + // диске, а index paths остались бы старыми. + if ( + (input.name !== undefined || input.parentId !== undefined) + && !isHttpVaultDiskReady(paths) + ) { + throwStorageError( + 'VAULT_HYDRATING', + 'Vault is still syncing, folder rename or move is not available yet', + ) + } + const cache = getHttpRuntimeCache(paths) const { state } = cache const folder = findHttpFolderById(state.folders, id) @@ -318,6 +340,17 @@ export function createHttpFoldersStorage(): HttpFoldersStorage { deleteFolder(id: number) { const paths = resolvePaths() + + // До завершения фоновой сверки runtime-кэш provisional: список + // запросов пуст, перенос содержимого папки в trash ничего бы не нашёл, + // а removeFolderPathsFromDisk физически уничтожил бы файлы на диске. + if (!isHttpVaultDiskReady(paths)) { + throwStorageError( + 'VAULT_HYDRATING', + 'Vault is still syncing, folder deletion is not available yet', + ) + } + const cache = getHttpRuntimeCache(paths) const { state } = cache const folder = findHttpFolderById(state.folders, id) @@ -329,12 +362,47 @@ export function createHttpFoldersStorage(): HttpFoldersStorage { const descendantIds = collectDescendantIds(state.folders, id) descendantIds.add(id) + // Trash-маркер запроса — frontmatter isDeleted, а не путь: перенос + // недокачанного файла без перезаписи frontmatter «воскресил» бы запрос + // после докачки. Preflight выполняется до первой мутации в две фазы: + // сначала eager-дочитка body/description всех записей, затем свежий + // stat всех файлов (флаг pendingCloudDownload мог устареть после + // eviction) — так окно между проверкой и мутацией не растягивается на + // гидрацию соседей. + const affectedRecords = [...cache.requestById.values()].filter( + record => + record.folderId !== null && descendantIds.has(record.folderId), + ) + for (const record of affectedRecords) { + if (!ensureRequestDetailsLoaded(paths.httpRoot, record)) { + throwCloudContentUnavailable() + } + } + const folderPathMap = buildFolderPathMap(state.folders) const folderPathsToDelete = getFolderPathsByDepth( folderPathMap, descendantIds, ) + // Доменный .md без записи в runtime (плейсхолдер с другого устройства, + // сбой чтения при скане) физически уничтожился бы вместе с каталогом: + // удаление отклоняется целиком. Обход стартует с корня удаляемой папки + // и идёт до финальной stat-фазы, чтобы не расширять окно до мутации. + const topFolderPath = folderPathMap.get(id) + assertNoUnknownDomainFiles( + paths.httpRoot, + topFolderPath ? [topFolderPath] : [], + new Set(affectedRecords.map(record => record.filePath)), + ) + + for (const record of affectedRecords) { + assertEntityFileWritable( + path.join(paths.httpRoot, record.filePath), + record, + ) + } + for (const record of cache.requestById.values()) { if (record.folderId !== null && descendantIds.has(record.folderId)) { moveRequestToTrash(paths.httpRoot, state, record) diff --git a/src/main/storage/providers/markdown/http/storages/requests.ts b/src/main/storage/providers/markdown/http/storages/requests.ts index 83ccdc24a..e4b612a0e 100644 --- a/src/main/storage/providers/markdown/http/storages/requests.ts +++ b/src/main/storage/providers/markdown/http/storages/requests.ts @@ -12,15 +12,27 @@ import type { } from '../runtime/types' import path from 'node:path' import fs from 'fs-extra' +import { prioritizeCloudDownload } from '../../cloudDownloads' import { normalizeFlag } from '../../runtime/normalizers' import { getVaultPath } from '../../runtime/paths' +import { + assertEntityFileWritable, + markEntityPendingIfEvicted, + markEntityPendingIfFileExists, + throwCloudContentUnavailable, +} from '../../runtime/shared/cloudGuards' +import { filterAndSortByQuery } from '../../runtime/shared/entityQuery' import { buildFolderPathMap } from '../../runtime/shared/folderIndex' import { assertUniqueSiblingEntryName, + assertVaultNotHydrating, throwStorageError, validateEntryName, } from '../../runtime/validation' -import { writeRequestFile } from '../runtime/parser' +import { + ensureRequestDetailsLoaded, + writeRequestFile, +} from '../runtime/parser' import { getHttpPaths } from '../runtime/paths' import { saveHttpState } from '../runtime/state' import { getHttpRuntimeCache } from '../runtime/sync' @@ -131,53 +143,89 @@ export function createHttpRequestsStorage(): HttpRequestsStorage { return { getRequests(query?: HttpRequestsQueryInput) { const { requestById } = getCache() - let result = [...requestById.values()].sort( - (a, b) => b.createdAt - a.createdAt, - ) + const resolvedQuery = query ?? {} + + const search = resolvedQuery.search?.trim().toLowerCase() + + const filtered = filterAndSortByQuery({ + entities: [...requestById.values()], + filters: [ + request => + resolvedQuery.isDeleted !== undefined + ? request.isDeleted === normalizeFlag(resolvedQuery.isDeleted) + : request.isDeleted === 0, + request => + resolvedQuery.folderId === undefined + || request.folderId === resolvedQuery.folderId, + request => + !resolvedQuery.isInbox + || (request.folderId === null && request.isDeleted === 0), + request => !resolvedQuery.isFavorites || request.isFavorites === 1, + (request) => { + if (!search) { + return true + } + + if (request.name.toLowerCase().includes(search)) { + return true + } + + return ( + !resolvedQuery.searchNameOnly + && request.url.toLowerCase().includes(search) + ) + }, + ], + getSortValue: (request, sort) => { + if (sort === 'name') { + return request.name.toLowerCase() + } - result = result.filter(request => - query?.isDeleted !== undefined - ? request.isDeleted === normalizeFlag(query.isDeleted) - : request.isDeleted === 0, - ) + if (sort === 'updatedAt') { + return request.updatedAt + } - if (query?.folderId !== undefined) { - result = result.filter( - request => request.folderId === query.folderId, - ) - } + return request.createdAt + }, + query: resolvedQuery, + }) - if (query?.isInbox) { - result = result.filter( - request => request.folderId === null && request.isDeleted === 0, - ) - } + return filtered + }, - if (query?.isFavorites) { - result = result.filter(request => request.isFavorites === 1) - } + getRequestById(id: number) { + const paths = resolvePaths() + const { requestById } = getCache() + const request = requestById.get(id) ?? null - const search = query?.search?.trim().toLowerCase() - if (!search) { - return result + // Пользователь открыл ещё не докачанный запрос: его файл поднимается + // в начало очереди фоновой докачки, ответ при этом не блокируется. + if (request?.pendingCloudDownload) { + prioritizeCloudDownload(path.join(paths.httpRoot, request.filePath)) } - if (query?.searchNameOnly) { - return result.filter(request => - request.name.toLowerCase().includes(search), - ) + // Запись из индекса без тела: body и description дочитываются по + // первому запросу. Сбой дочитки (файл выгружен после скана, флаг ещё + // не обновился) помечает запись pending: успешный ответ с body: null + // без флага обошёл бы guards отправки/дублирования, и запрос ушёл бы + // на сервер с пустым телом. Для уже гидрированной записи eviction + // ловится свежим stat. Флаг снимет ресинк после докачки. + if (request) { + if (!ensureRequestDetailsLoaded(paths.httpRoot, request)) { + markEntityPendingIfFileExists( + path.join(paths.httpRoot, request.filePath), + request, + ) + } + else { + markEntityPendingIfEvicted( + path.join(paths.httpRoot, request.filePath), + request, + ) + } } - return result.filter( - request => - request.name.toLowerCase().includes(search) - || request.url.toLowerCase().includes(search), - ) - }, - - getRequestById(id: number) { - const { requestById } = getCache() - return requestById.get(id) ?? null + return request }, createRequest(input: HttpRequestCreateInput) { @@ -185,6 +233,7 @@ export function createHttpRequestsStorage(): HttpRequestsStorage { const cache = getHttpRuntimeCache(paths) const { state } = cache + assertVaultNotHydrating(state) const name = validateEntryName(input.name, 'request') const folderId = input.folderId ?? null @@ -252,6 +301,19 @@ export function createHttpRequestsStorage(): HttpRequestsStorage { return { invalidInput: false, notFound: true } } + // Патч мутирует запись и сериализует её целиком: недостающие body и + // description дочитываются до применения полей. Если содержимое + // сейчас недоступно, «принятая» правка молча потерялась бы при + // следующем ресинке — мутация отклоняется до изменения кэша (со + // свежим stat: флаг pendingCloudDownload мог устареть после eviction). + assertEntityFileWritable( + path.join(paths.httpRoot, record.filePath), + record, + ) + if (!ensureRequestDetailsLoaded(paths.httpRoot, record)) { + throwCloudContentUnavailable() + } + const updatableFields = [ input.name, input.folderId, diff --git a/src/main/storage/providers/markdown/index.ts b/src/main/storage/providers/markdown/index.ts index 5540c606f..cca051d8e 100644 --- a/src/main/storage/providers/markdown/index.ts +++ b/src/main/storage/providers/markdown/index.ts @@ -5,4 +5,8 @@ export { resetRuntimeCache, } from './runtime' export { createMarkdownStorageProvider } from './storages' -export { startMarkdownWatcher, stopMarkdownWatcher } from './watcher' +export { + prepareMarkdownWatcher, + startMarkdownWatcher, + stopMarkdownWatcher, +} from './watcher' diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/assets.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/assets.test.ts new file mode 100644 index 000000000..387f130ed --- /dev/null +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/assets.test.ts @@ -0,0 +1,224 @@ +import type { NotesPaths } from '../types' +import { Buffer } from 'node:buffer' +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, describe, expect, it } from 'vitest' +import { setDatalessProbeForTests } from '../../../runtime/shared/cloudFiles' +import { + parseNotesAssetName, + parseNotesAssetWritePayload, + resolveNotesAsset, + writeNotesAsset, +} from '../assets' + +const tempDirs: string[] = [] + +function createNotesPaths(): NotesPaths { + const notesRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'notes-assets-')) + tempDirs.push(notesRoot) + const metaDirPath = path.join(notesRoot, '.masscode') + + return { + assetsPath: path.join(metaDirPath, 'assets'), + inboxDirPath: path.join(metaDirPath, 'inbox'), + legacyAssetsPath: path.join(notesRoot, 'assets'), + metaDirPath, + notesRoot, + statePath: path.join(metaDirPath, 'state.json'), + trashDirPath: path.join(metaDirPath, 'trash'), + } +} + +function pngBytes(): ArrayBuffer { + return Uint8Array.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]) + .buffer +} + +function jpegBytes(): ArrayBuffer { + return Uint8Array.from([0xFF, 0xD8, 0xFF, 0xD9]).buffer +} + +afterEach(() => { + setDatalessProbeForTests(null) + for (const dirPath of tempDirs.splice(0)) { + fs.removeSync(dirPath) + } +}) + +describe('notes asset validation', () => { + it('accepts current and historical generated names case-insensitively', () => { + expect(parseNotesAssetName('abcdefghijklmnop.PNG')).toMatchObject({ + extension: '.png', + mimeType: 'image/png', + }) + expect(parseNotesAssetName('abcdefghijklmnopqrstu.JpEg')).toMatchObject({ + extension: '.jpeg', + mimeType: 'image/jpeg', + }) + }) + + it.each([ + '../abcdefghijklmnop.png', + '..%2Fabcdefghijklmnop.png', + '%2e%2e%5cabcdefghijklmnop.png', + 'short.png', + 'abcdefghijklmnop.exe', + ])('rejects unsafe or unsupported name %s', (fileName) => { + expect(parseNotesAssetName(fileName)).toBeNull() + }) + + it('accepts only an object with string ext and ArrayBuffer bytes', () => { + expect(parseNotesAssetWritePayload(null)).toBeNull() + expect(parseNotesAssetWritePayload({ buffer: [0], ext: 1 })).toBeNull() + expect( + parseNotesAssetWritePayload({ buffer: [0, 1], ext: '.png' }), + ).toBeNull() + expect( + parseNotesAssetWritePayload({ buffer: pngBytes(), ext: '.png' }), + ).toMatchObject({ ext: '.png' }) + }) +}) + +describe('writeNotesAsset', () => { + it('writes atomically to the managed root and returns the stable URL', async () => { + const paths = createNotesPaths() + const url = await writeNotesAsset( + paths, + pngBytes(), + '.PNG', + () => 'abcdefghijklmnop', + ) + + expect(url).toBe('masscode://notes-asset/abcdefghijklmnop.png') + expect( + fs.readFileSync(path.join(paths.assetsPath, 'abcdefghijklmnop.png')), + ).toEqual(Buffer.from(pngBytes())) + expect(fs.pathExistsSync(paths.legacyAssetsPath)).toBe(false) + expect(fs.readdirSync(paths.assetsPath)).toEqual(['abcdefghijklmnop.png']) + }) + + it('accepts a JPEG signature for JPG uploads', async () => { + const paths = createNotesPaths() + + const url = await writeNotesAsset( + paths, + jpegBytes(), + '.JPG', + () => 'abcdefghijklmnop', + ) + + expect(url).toBe('masscode://notes-asset/abcdefghijklmnop.jpg') + }) + + it('rejects SVG, mismatched bytes, and an existing destination', async () => { + const paths = createNotesPaths() + await expect( + writeNotesAsset(paths, new TextEncoder().encode('').buffer, '.svg'), + ).rejects.toThrow('Unsupported') + await expect( + writeNotesAsset( + paths, + new TextEncoder().encode('not png').buffer, + '.png', + ), + ).rejects.toThrow('does not match') + + fs.ensureDirSync(paths.assetsPath) + const destination = path.join(paths.assetsPath, 'abcdefghijklmnop.png') + fs.writeFileSync(destination, 'existing') + await expect( + writeNotesAsset(paths, pngBytes(), '.png', () => 'abcdefghijklmnop'), + ).rejects.toThrow('already exists') + expect(fs.readFileSync(destination, 'utf8')).toBe('existing') + expect(fs.readdirSync(paths.assetsPath)).toEqual(['abcdefghijklmnop.png']) + }) +}) + +describe('resolveNotesAsset', () => { + it('prefers the managed root and falls back to legacy', async () => { + const paths = createNotesPaths() + const fileName = 'abcdefghijklmnop.png' + fs.ensureDirSync(paths.assetsPath) + fs.ensureDirSync(paths.legacyAssetsPath) + fs.writeFileSync(path.join(paths.assetsPath, fileName), 'new') + fs.writeFileSync(path.join(paths.legacyAssetsPath, fileName), 'legacy') + + const preferred = await resolveNotesAsset(fileName, paths) + expect(await preferred.text()).toBe('new') + fs.removeSync(path.join(paths.assetsPath, fileName)) + + const fallback = await resolveNotesAsset(fileName, paths) + expect(await fallback.text()).toBe('legacy') + expect(fallback.headers.get('Content-Type')).toBe('image/png') + }) + + it('does not mask a managed placeholder with a legacy file', async () => { + const paths = createNotesPaths() + const fileName = 'abcdefghijklmnop.png' + const managedPath = path.join(paths.assetsPath, fileName) + fs.ensureDirSync(paths.assetsPath) + fs.ensureDirSync(paths.legacyAssetsPath) + const placeholderFd = fs.openSync(managedPath, 'w') + fs.ftruncateSync(placeholderFd, 4096) + fs.closeSync(placeholderFd) + fs.writeFileSync(path.join(paths.legacyAssetsPath, fileName), 'legacy') + setDatalessProbeForTests(absolutePath => absolutePath === managedPath) + const prioritized: string[] = [] + + const response = await resolveNotesAsset(fileName, paths, absolutePath => + prioritized.push(absolutePath)) + + expect(response.status).toBe(503) + expect(prioritized).toEqual([managedPath]) + expect(await response.text()).not.toBe('legacy') + }) + + it('returns a safe 404 for traversal', async () => { + const response = await resolveNotesAsset( + '..%2Fabcdefghijklmnop.png', + createNotesPaths(), + ) + + expect(response.status).toBe(404) + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + }) + + it('serves legacy SVG with active content blocked', async () => { + const paths = createNotesPaths() + const fileName = 'abcdefghijklmnop.svg' + fs.ensureDirSync(paths.legacyAssetsPath) + fs.writeFileSync(path.join(paths.legacyAssetsPath, fileName), '') + + const response = await resolveNotesAsset(fileName, paths) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('image/svg+xml') + expect(response.headers.get('Content-Security-Policy')).toContain( + 'sandbox', + ) + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + }) + + it.runIf(process.platform !== 'win32')( + 'does not follow a direct asset file symlink', + async () => { + const fileName = 'abcdefghijklmnop.png' + const outsideRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'notes-outside-'), + ) + tempDirs.push(outsideRoot) + fs.writeFileSync(path.join(outsideRoot, fileName), 'outside') + + const fileSymlinkPaths = createNotesPaths() + fs.ensureDirSync(fileSymlinkPaths.assetsPath) + fs.symlinkSync( + path.join(outsideRoot, fileName), + path.join(fileSymlinkPaths.assetsPath, fileName), + ) + expect((await resolveNotesAsset(fileName, fileSymlinkPaths)).status).toBe( + 404, + ) + }, + ) +}) diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/assetsMigration.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/assetsMigration.test.ts new file mode 100644 index 000000000..fe7fdaec9 --- /dev/null +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/assetsMigration.test.ts @@ -0,0 +1,409 @@ +import type { MarkdownNote, NotesPaths, NotesRuntimeCache } from '../types' +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setDatalessProbeForTests } from '../../../runtime/shared/cloudFiles' +import { extractNotesAssetNames } from '../assetsInspection' +import { + cancelNotesAssetsMigration, + discoverNotesAssetReferences, + runNotesAssetsMigration, + runNotesAssetsMigrationForTests, + scheduleNotesAssetsMigration, + waitForNotesAssetsMigrationForTests, +} from '../assetsMigration' + +const { enqueueCloudDownload, prioritizeCloudDownload } = vi.hoisted(() => ({ + enqueueCloudDownload: vi.fn(), + prioritizeCloudDownload: vi.fn(), +})) + +vi.mock('../../../cloudDownloads', () => ({ + enqueueCloudDownload, + prioritizeCloudDownload, +})) + +vi.mock('../../../../../../utils', () => ({ + log: vi.fn(), +})) + +const tempDirs: string[] = [] + +function createPaths(): NotesPaths { + const notesRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'assets-migration-')) + tempDirs.push(notesRoot) + const metaDirPath = path.join(notesRoot, '.masscode') + + return { + assetsPath: path.join(metaDirPath, 'assets'), + inboxDirPath: path.join(metaDirPath, 'inbox'), + legacyAssetsPath: path.join(notesRoot, 'assets'), + metaDirPath, + notesRoot, + statePath: path.join(metaDirPath, 'state.json'), + trashDirPath: path.join(metaDirPath, 'trash'), + } +} + +function createNote( + id: number, + content: string | null, + pendingCloudDownload = false, +): MarkdownNote { + return { + content, + createdAt: 1, + description: null, + filePath: `Note ${id}.md`, + folderId: null, + id, + isDeleted: 0, + isFavorites: 0, + name: `Note ${id}`, + ...(pendingCloudDownload ? { pendingCloudDownload: true } : {}), + properties: {}, + tags: [], + updatedAt: 1, + } +} + +function createCache( + paths: NotesPaths, + notes: MarkdownNote[], +): NotesRuntimeCache { + return { + folderById: new Map(), + noteById: new Map(notes.map(note => [note.id, note])), + notes, + paths, + searchIndex: {} as NotesRuntimeCache['searchIndex'], + state: { + counters: { folderId: 0, noteId: notes.length, tagId: 0 }, + folderUi: {}, + folders: [], + notes: notes.map(note => ({ filePath: note.filePath, id: note.id })), + tags: [], + version: 1, + }, + } +} + +function assetUrl(fileName: string): string { + return `![asset](masscode://notes-asset/${fileName})` +} + +function writeLegacyAsset(paths: NotesPaths, fileName: string, data: string) { + fs.ensureDirSync(paths.legacyAssetsPath) + fs.writeFileSync(path.join(paths.legacyAssetsPath, fileName), data) +} + +function writeSparseFile(filePath: string): void { + fs.ensureDirSync(path.dirname(filePath)) + const fd = fs.openSync(filePath, 'w') + fs.ftruncateSync(fd, 4096) + fs.closeSync(fd) +} + +afterEach(() => { + cancelNotesAssetsMigration() + setDatalessProbeForTests(null) + vi.clearAllMocks() + for (const dirPath of tempDirs.splice(0)) { + fs.removeSync(dirPath) + } +}) + +describe('notes assets migration discovery', () => { + it('extracts unique valid notes asset names from markdown source', () => { + const validName = 'abcdefghijklmnop.png' + + expect([ + ...extractNotesAssetNames( + [ + assetUrl(validName), + assetUrl(validName), + 'masscode://notes-asset/../invalid.png', + 'masscode://notes-asset/not-an-asset.txt', + ].join(' '), + ), + ]).toEqual([validName]) + }) + + it('collects only valid references from available note content', () => { + const paths = createPaths() + const availableName = 'abcdefghijklmnop.png' + const pendingName = 'ponmlkjihgfedcba.jpg' + const cache = createCache(paths, [ + createNote(1, `${assetUrl(availableName)} ${assetUrl('../bad.png')}`), + createNote(2, assetUrl(pendingName), true), + ]) + + const discovery = discoverNotesAssetReferences(cache) + + expect(discovery.complete).toBe(false) + expect([...discovery.fileNames]).toEqual([availableName]) + expect(enqueueCloudDownload).toHaveBeenCalledWith( + path.join(paths.notesRoot, 'Note 2.md'), + ) + }) +}) + +describe('runNotesAssetsMigration', () => { + it('migrates only referenced direct legacy assets', async () => { + const paths = createPaths() + const referenced = 'abcdefghijklmnop.png' + const unreferenced = 'ponmlkjihgfedcba.png' + writeLegacyAsset(paths, referenced, 'referenced bytes') + writeLegacyAsset(paths, unreferenced, 'unreferenced bytes') + + const summary = await runNotesAssetsMigration( + createCache(paths, [createNote(1, assetUrl(referenced))]), + ) + + expect(summary.migrated).toBe(1) + expect( + fs.readFileSync(path.join(paths.assetsPath, referenced), 'utf8'), + ).toBe('referenced bytes') + expect( + fs.pathExistsSync(path.join(paths.legacyAssetsPath, referenced)), + ).toBe(false) + expect( + fs.pathExistsSync(path.join(paths.legacyAssetsPath, unreferenced)), + ).toBe(true) + }) + + it('removes a verified equal source and preserves a conflict', async () => { + const paths = createPaths() + const equalName = 'abcdefghijklmnop.png' + const conflictName = 'ponmlkjihgfedcba.jpg' + writeLegacyAsset(paths, equalName, 'same') + writeLegacyAsset(paths, conflictName, 'source') + fs.ensureDirSync(paths.assetsPath) + fs.writeFileSync(path.join(paths.assetsPath, equalName), 'same') + fs.writeFileSync(path.join(paths.assetsPath, conflictName), 'destination') + + const summary = await runNotesAssetsMigration( + createCache(paths, [ + createNote(1, `${assetUrl(equalName)} ${assetUrl(conflictName)}`), + ]), + ) + + expect(summary.removedEqual).toBe(1) + expect(summary.conflicts).toBe(1) + expect( + fs.pathExistsSync(path.join(paths.legacyAssetsPath, equalName)), + ).toBe(false) + expect( + fs.readFileSync(path.join(paths.legacyAssetsPath, conflictName), 'utf8'), + ).toBe('source') + expect( + fs.readFileSync(path.join(paths.assetsPath, conflictName), 'utf8'), + ).toBe('destination') + }) + + it('re-evaluates atomic EEXIST without overwriting a concurrent destination', async () => { + const paths = createPaths() + const fileName = 'abcdefghijklmnop.png' + writeLegacyAsset(paths, fileName, 'source') + + const summary = await runNotesAssetsMigrationForTests( + createCache(paths, [createNote(1, assetUrl(fileName))]), + () => false, + { + beforePublish: ({ destinationPath }) => { + fs.writeFileSync(destinationPath, 'concurrent') + }, + }, + ) + + expect(summary.conflicts).toBe(1) + expect(fs.readFileSync(path.join(paths.assetsPath, fileName), 'utf8')).toBe( + 'concurrent', + ) + expect( + fs.readFileSync(path.join(paths.legacyAssetsPath, fileName), 'utf8'), + ).toBe('source') + }) + + it('does not remove a concurrent replacement after publish mismatch', async () => { + const paths = createPaths() + const fileName = 'abcdefghijklmnop.png' + writeLegacyAsset(paths, fileName, 'source') + + const summary = await runNotesAssetsMigrationForTests( + createCache(paths, [createNote(1, assetUrl(fileName))]), + () => false, + { + afterPublish: ({ destinationPath }) => { + fs.unlinkSync(destinationPath) + fs.writeFileSync(destinationPath, 'replacement') + }, + }, + ) + + expect(summary.conflicts).toBe(1) + expect(fs.readFileSync(path.join(paths.assetsPath, fileName), 'utf8')).toBe( + 'replacement', + ) + expect(fs.pathExistsSync(path.join(paths.legacyAssetsPath, fileName))).toBe( + true, + ) + }) + + it('removes an owned post-publish mismatch and always cleans its temp', async () => { + const paths = createPaths() + const fileName = 'abcdefghijklmnop.png' + writeLegacyAsset(paths, fileName, 'source') + + const summary = await runNotesAssetsMigrationForTests( + createCache(paths, [createNote(1, assetUrl(fileName))]), + () => false, + { + afterPublish: ({ destinationPath }) => { + fs.writeFileSync(destinationPath, 'corrupt') + }, + }, + ) + + expect(summary.conflicts).toBe(1) + expect(fs.pathExistsSync(path.join(paths.assetsPath, fileName))).toBe( + false, + ) + expect(fs.readdirSync(paths.assetsPath)).toEqual([]) + expect(fs.pathExistsSync(path.join(paths.legacyAssetsPath, fileName))).toBe( + true, + ) + }) + + it('checks cancellation immediately before final source deletion', async () => { + const paths = createPaths() + const fileName = 'abcdefghijklmnop.png' + writeLegacyAsset(paths, fileName, 'source') + let cancelled = false + + const summary = await runNotesAssetsMigrationForTests( + createCache(paths, [createNote(1, assetUrl(fileName))]), + () => cancelled, + { + afterFinalDestinationHash: () => { + cancelled = true + }, + }, + ) + + expect(summary.deferred).toBe(1) + expect(fs.pathExistsSync(path.join(paths.legacyAssetsPath, fileName))).toBe( + true, + ) + expect(fs.readFileSync(path.join(paths.assetsPath, fileName), 'utf8')).toBe( + 'source', + ) + }) + + it('preserves a source replaced during final destination verification', async () => { + const paths = createPaths() + const fileName = 'abcdefghijklmnop.png' + writeLegacyAsset(paths, fileName, 'source') + + const summary = await runNotesAssetsMigrationForTests( + createCache(paths, [createNote(1, assetUrl(fileName))]), + () => false, + { + afterFinalDestinationHash: ({ sourcePath }) => { + fs.unlinkSync(sourcePath) + fs.writeFileSync(sourcePath, 'replacement source') + }, + }, + ) + + expect(summary.conflicts).toBe(1) + expect( + fs.readFileSync(path.join(paths.legacyAssetsPath, fileName), 'utf8'), + ).toBe('replacement source') + expect(fs.pathExistsSync(path.join(paths.assetsPath, fileName))).toBe( + false, + ) + }) + + it('defers placeholders and prioritizes their hydration', async () => { + const paths = createPaths() + const sourceName = 'abcdefghijklmnop.png' + const destinationName = 'ponmlkjihgfedcba.jpg' + fs.ensureDirSync(paths.legacyAssetsPath) + writeLegacyAsset(paths, destinationName, 'source bytes') + fs.ensureDirSync(paths.assetsPath) + const sourcePath = path.join(paths.legacyAssetsPath, sourceName) + const destinationPath = path.join(paths.assetsPath, destinationName) + writeSparseFile(sourcePath) + writeSparseFile(destinationPath) + setDatalessProbeForTests( + absolutePath => + absolutePath === sourcePath || absolutePath === destinationPath, + ) + + const summary = await runNotesAssetsMigration( + createCache(paths, [ + createNote(1, `${assetUrl(sourceName)} ${assetUrl(destinationName)}`), + ]), + ) + + expect(summary.deferred).toBe(2) + expect(prioritizeCloudDownload).toHaveBeenCalledWith(sourcePath) + expect(prioritizeCloudDownload).toHaveBeenCalledWith(destinationPath) + expect(fs.pathExistsSync(sourcePath)).toBe(true) + }) + + it('isolates one item error and continues migrating the next asset', async () => { + const paths = createPaths() + const badName = 'abcdefghijklmnop.png' + const goodName = 'ponmlkjihgfedcba.jpg' + fs.ensureDirSync(path.join(paths.legacyAssetsPath, badName)) + writeLegacyAsset(paths, goodName, 'good bytes') + + const summary = await runNotesAssetsMigration( + createCache(paths, [ + createNote(1, `${assetUrl(badName)} ${assetUrl(goodName)}`), + ]), + ) + + expect(summary.errors).toBe(1) + expect(summary.migrated).toBe(1) + expect(fs.pathExistsSync(path.join(paths.assetsPath, goodName))).toBe(true) + }) + + it('is idempotent and discovers a late reference on a future run', async () => { + const paths = createPaths() + const firstName = 'abcdefghijklmnop.png' + const lateName = 'ponmlkjihgfedcba.jpg' + writeLegacyAsset(paths, firstName, 'first') + writeLegacyAsset(paths, lateName, 'late') + const cache = createCache(paths, [createNote(1, assetUrl(firstName))]) + + expect((await runNotesAssetsMigration(cache)).migrated).toBe(1) + expect((await runNotesAssetsMigration(cache)).migrated).toBe(0) + + cache.notes.push(createNote(2, assetUrl(lateName))) + expect((await runNotesAssetsMigration(cache)).migrated).toBe(1) + expect(fs.pathExistsSync(path.join(paths.assetsPath, lateName))).toBe(true) + }) + + it('cancels a scheduled run before rename or source deletion', async () => { + const paths = createPaths() + const fileName = 'abcdefghijklmnop.png' + writeLegacyAsset(paths, fileName, 'source') + + scheduleNotesAssetsMigration( + createCache(paths, [createNote(1, assetUrl(fileName))]), + ) + cancelNotesAssetsMigration() + await waitForNotesAssetsMigrationForTests() + + expect(fs.pathExistsSync(path.join(paths.legacyAssetsPath, fileName))).toBe( + true, + ) + expect(fs.pathExistsSync(path.join(paths.assetsPath, fileName))).toBe( + false, + ) + }) +}) diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts index 92b2d1d25..b0ead673a 100644 --- a/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts @@ -47,6 +47,12 @@ describe('getNotesPaths', () => { fs.pathExistsSync(path.join(notesPaths.notesRoot, 'Folder', 'note.md')), ).toBe(true) expect(fs.pathExistsSync(legacyNotesRoot)).toBe(false) + expect(notesPaths.assetsPath).toBe( + path.join(vaultPath, 'notes', '.masscode', 'assets'), + ) + expect(notesPaths.legacyAssetsPath).toBe( + path.join(vaultPath, 'notes', 'assets'), + ) }) it('merges nested notes space into existing flat notes space', () => { diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/graph.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/graph.test.ts index f487386e8..b669e5f98 100644 --- a/src/main/storage/providers/markdown/notes/runtime/__tests__/graph.test.ts +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/graph.test.ts @@ -1,4 +1,4 @@ -import type { MarkdownNote } from '../types' +import type { NotesGraphNoteLookup } from '../graph' import { describe, expect, it } from 'vitest' import { buildNotesGraph } from '../graph' @@ -11,20 +11,14 @@ function createNote( id: number, name: string, content: string, - overrides: Partial = {}, -): MarkdownNote { + overrides: Partial = {}, +): NotesGraphNoteLookup { return { content, - createdAt: 1, - description: null, - filePath: `${name}.md`, folderId: null, id, - isDeleted: 0, - isFavorites: 0, name, tags: [], - updatedAt: 1, ...overrides, } } diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/metadataIndex.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/metadataIndex.test.ts new file mode 100644 index 000000000..ef64f70da --- /dev/null +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/metadataIndex.test.ts @@ -0,0 +1,215 @@ +import type { NotesPaths } from '../types' +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ensureNoteContentLoaded } from '../notes' +import { loadNotesState } from '../state' +import { resetNotesRuntimeCache, syncNotesRuntimeWithDisk } from '../sync' + +// backlinks-хуки в notes sync тянут runtime paths, который читает store: +// вне Electron его нужно замокать. +vi.mock('electron-store', () => { + class MockStore { + get(): unknown { + return undefined + } + + set(): void {} + } + + return { default: MockStore } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir(), + }, + BrowserWindow: { + getAllWindows: () => [], + }, +})) + +vi.mock('../../../cloudDownloads', () => ({ + enqueueCloudDownload: vi.fn(), + prioritizeCloudDownload: vi.fn(), +})) + +const tempDirs: string[] = [] + +function createNotesPaths(): NotesPaths { + const notesRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'notes-metadata-index-'), + ) + tempDirs.push(notesRoot) + const metaDirPath = path.join(notesRoot, '.masscode') + + return { + assetsPath: path.join(metaDirPath, 'assets'), + inboxDirPath: path.join(metaDirPath, 'inbox'), + legacyAssetsPath: path.join(notesRoot, 'assets'), + metaDirPath, + notesRoot, + statePath: path.join(metaDirPath, 'state.json'), + trashDirPath: path.join(metaDirPath, 'trash'), + } +} + +function writeNoteFixture( + paths: NotesPaths, + relativePath: string, + id: number, + name: string, + body = 'note body', +): string { + const absolutePath = path.join(paths.notesRoot, relativePath) + const source = [ + '---', + `id: ${id}`, + `name: ${name}`, + 'createdAt: 1700000000000', + 'updatedAt: 1700000000000', + 'isDeleted: 0', + 'isFavorites: 0', + 'tags: []', + '---', + body, + '', + ].join('\n') + + fs.ensureDirSync(path.dirname(absolutePath)) + fs.writeFileSync(absolutePath, source, 'utf8') + + return absolutePath +} + +// Мутирует файл и подгоняет stat-сигнатуру индекса под новый stat: +// получается сценарий «файл не менялся» — если скан всё равно отдаёт старые +// данные из индекса, значит файл действительно не читался. +function mutateFileKeepingIndexSignature( + paths: NotesPaths, + absolutePath: string, + mutate: (source: string) => string, +): void { + const source = fs.readFileSync(absolutePath, 'utf8') + fs.writeFileSync(absolutePath, mutate(source), 'utf8') + + const stats = fs.statSync(absolutePath) + const persisted = fs.readJsonSync(paths.statePath) as { + notes: { meta?: { mtimeMs: number, size: number } }[] + } + persisted.notes[0].meta!.mtimeMs = stats.mtimeMs + persisted.notes[0].meta!.size = stats.size + fs.writeJsonSync(paths.statePath, persisted) +} + +afterEach(() => { + resetNotesRuntimeCache() + + for (const dirPath of tempDirs.splice(0)) { + fs.removeSync(dirPath) + } +}) + +describe('notes metadata index', () => { + it('fills index metadata on first scan and persists it in state.json', () => { + const paths = createNotesPaths() + writeNoteFixture(paths, 'First.md', 1, 'First') + + syncNotesRuntimeWithDisk(paths) + + const persisted = fs.readJsonSync(paths.statePath) as { + notes: { id: number, meta?: { mtimeMs: number, name: string } }[] + version: number + } + + expect(persisted.version).toBe(2) + expect(persisted.notes[0].meta?.name).toBe('First') + expect(persisted.notes[0].meta?.mtimeMs).toBeGreaterThan(0) + + // Схема записи строгая: посторонние поля не персистятся. + expect(Object.keys(persisted.notes[0]).sort()).toEqual([ + 'filePath', + 'id', + 'meta', + ]) + }) + + it('builds untouched notes from the index without reading files', () => { + const paths = createNotesPaths() + const absolutePath = writeNoteFixture(paths, 'Lazy.md', 1, 'Lazy') + + // Первый скан читает файл и заполняет индекс. + syncNotesRuntimeWithDisk(paths) + + // Имя в файле меняется, но сигнатура индекса совпадает со stat: если + // повторный скан отдаёт старое имя, файл действительно не читался. + mutateFileKeepingIndexSignature(paths, absolutePath, source => + source.replace('name: Lazy', 'name: Hack')) + + resetNotesRuntimeCache() + const cache = syncNotesRuntimeWithDisk(paths) + const note = cache.notes.find(item => item.id === 1) + + expect(note?.name).toBe('Lazy') + expect(note?.content).toBeNull() + }) + + it('lazily loads note content on demand', () => { + const paths = createNotesPaths() + writeNoteFixture(paths, 'Demand.md', 1, 'Demand') + + syncNotesRuntimeWithDisk(paths) + resetNotesRuntimeCache() + const cache = syncNotesRuntimeWithDisk(paths) + const note = cache.notes.find(item => item.id === 1) + + expect(note?.content).toBeNull() + expect(ensureNoteContentLoaded(paths, note!)).toBe(true) + expect(note?.content).toContain('note body') + }) + + it('re-reads notes whose stat signature changed', () => { + const paths = createNotesPaths() + const absolutePath = writeNoteFixture(paths, 'Changed.md', 1, 'Before') + + syncNotesRuntimeWithDisk(paths) + + // Обычная внешняя правка: mtime меняется, файл перечитывается. + writeNoteFixture(paths, 'Changed.md', 1, 'After') + fs.utimesSync(absolutePath, new Date(), new Date(Date.now() + 5_000)) + + resetNotesRuntimeCache() + const cache = syncNotesRuntimeWithDisk(paths) + const note = cache.notes.find(item => item.id === 1) + + expect(note?.name).toBe('After') + expect(note?.content).toContain('note body') + }) + + it('backfills metadata for legacy v1 index entries', () => { + const paths = createNotesPaths() + writeNoteFixture(paths, 'Legacy.md', 5, 'Legacy') + + // v1-индекс: записи без метаданных, только { id, filePath }. + fs.ensureDirSync(paths.metaDirPath) + fs.writeJsonSync(paths.statePath, { + counters: { folderId: 0, noteId: 5, tagId: 0 }, + folderUi: {}, + notes: [{ filePath: 'Legacy.md', id: 5 }], + tags: [], + version: 1, + }) + + const cache = syncNotesRuntimeWithDisk(paths) + const note = cache.notes.find(item => item.id === 5) + + // Файл прочитан один раз (тело на месте), а индекс дозаполнен. + expect(note?.name).toBe('Legacy') + expect(note?.content).toContain('note body') + + const state = loadNotesState(paths) + expect(state.notes[0].meta?.name).toBe('Legacy') + expect(state.version).toBe(2) + }) +}) diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/notes.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/notes.test.ts index 04da3d81d..a3d3f7b5e 100644 --- a/src/main/storage/providers/markdown/notes/runtime/__tests__/notes.test.ts +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/notes.test.ts @@ -3,7 +3,12 @@ import os from 'node:os' import path from 'node:path' import fs from 'fs-extra' import { afterEach, describe, expect, it, vi } from 'vitest' -import { readNoteFromFile, serializeNote } from '../notes' +import { + buildNoteFromIndexMetadata, + buildNoteIndexMetadata, + readNoteFromFile, + serializeNote, +} from '../notes' vi.mock('electron-store', () => { class MockStore { @@ -64,7 +69,9 @@ function createNotesPaths(): NotesPaths { const metaDirPath = path.join(notesRoot, '.masscode') return { + assetsPath: path.join(metaDirPath, 'assets'), inboxDirPath: path.join(metaDirPath, 'inbox'), + legacyAssetsPath: path.join(notesRoot, 'assets'), metaDirPath, notesRoot, statePath: path.join(metaDirPath, 'state.json'), @@ -123,6 +130,7 @@ describe('readNoteFromFile', () => { isDeleted: 0, isFavorites: 0, name: 'Roundtrip', + properties: {}, tags: [], updatedAt: now, } @@ -212,4 +220,134 @@ describe('readNoteFromFile', () => { vi.useRealTimers() } }) + + it('preserves unknown frontmatter properties after read and serialize', () => { + const paths = createNotesPaths() + const relativePath = 'properties.md' + const absolutePath = path.join(paths.notesRoot, relativePath) + const source + = '---\n' + + 'id: 12\n' + + 'name: Properties\n' + + 'type: task\n' + + 'status: todo\n' + + 'priority: high\n' + + 'rating: 5\n' + + 'source: book\n' + + '---\n' + + 'body' + + fs.writeFileSync(absolutePath, source, 'utf8') + + const note = readNoteFromFile( + paths, + { filePath: relativePath, id: 12 }, + new Map(), + ) + + expect(note).not.toBeNull() + expect(note?.properties).toEqual({ + priority: 'high', + rating: 5, + source: 'book', + status: 'todo', + type: 'task', + }) + + fs.writeFileSync(absolutePath, serializeNote(note!), 'utf8') + + const roundTrip = readNoteFromFile( + paths, + { filePath: relativePath, id: 12 }, + new Map(), + ) + + expect(roundTrip?.properties).toEqual(note?.properties) + }) + + it('keeps exotic YAML properties lossless and index-safe', () => { + const paths = createNotesPaths() + const relativePath = 'exotic-properties.md' + const absolutePath = path.join(paths.notesRoot, relativePath) + + // Валидный YAML: рекурсивный alias даёт циклический объект, timestamp — + // Date, .nan — NaN, !!binary — Uint8Array. Цикл без разрыва валил бы + // JSON-персист metadata-индекса, а остальные значения должны пережить + // и запись обратно во frontmatter, и JSON round-trip индекса без порчи. + const source + = '---\n' + + 'id: 21\n' + + 'name: Exotic\n' + + 'loop: &a\n' + + ' self: *a\n' + + 'date: 2024-01-15\n' + + 'score: .nan\n' + + 'blob: !!binary aGVsbG8=\n' + + '---\n' + + 'body' + + fs.writeFileSync(absolutePath, source, 'utf8') + + const note = readNoteFromFile( + paths, + { filePath: relativePath, id: 21 }, + new Map(), + ) + + expect(note).not.toBeNull() + // Цикл разорван, остальные значения сохранены как есть. + expect(note?.properties.loop).toEqual({ self: null }) + expect(note?.properties.date).toBeInstanceOf(Date) + expect(Number.isNaN(note?.properties.score)).toBe(true) + expect(note?.properties.blob).toBeInstanceOf(Uint8Array) + + // Запись обратно во frontmatter сохраняет семантику значений. + fs.writeFileSync(absolutePath, serializeNote(note!), 'utf8') + const reread = readNoteFromFile( + paths, + { filePath: relativePath, id: 21 }, + new Map(), + ) + expect((reread?.properties.date as Date).getTime()).toBe( + (note?.properties.date as Date).getTime(), + ) + expect(Number.isNaN(reread?.properties.score)).toBe(true) + expect(reread?.properties.blob).toEqual(note?.properties.blob) + + // JSON round-trip metadata-индекса обратим и не падает. + const meta = buildNoteIndexMetadata(note!, { mtimeMs: 1, size: 1 }) + const persisted = JSON.parse(JSON.stringify(meta)) + const restored = buildNoteFromIndexMetadata( + { filePath: relativePath, id: 21 }, + persisted, + ) + expect(restored.properties.date).toBeInstanceOf(Date) + expect((restored.properties.date as Date).getTime()).toBe( + (note?.properties.date as Date).getTime(), + ) + expect(Number.isNaN(restored.properties.score)).toBe(true) + expect(restored.properties.blob).toEqual(note?.properties.blob) + }) + + it('does not add task fields to plain markdown after normalization', () => { + const paths = createNotesPaths() + const relativePath = 'plain-no-task.md' + const absolutePath = path.join(paths.notesRoot, relativePath) + fs.writeFileSync(absolutePath, 'plain body', 'utf8') + + const note = readNoteFromFile( + paths, + { filePath: relativePath, id: 13 }, + new Map(), + ) + + expect(note).not.toBeNull() + expect(note?.properties).toEqual({}) + + const normalizedSource = fs.readFileSync(absolutePath, 'utf8') + expect(normalizedSource).not.toContain('type: task') + expect(normalizedSource).not.toContain('status:') + expect(normalizedSource).not.toContain('priority:') + expect(normalizedSource).not.toContain('due:') + }) }) diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/search.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/search.test.ts index 5626b901b..fc994bd0a 100644 --- a/src/main/storage/providers/markdown/notes/runtime/__tests__/search.test.ts +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/search.test.ts @@ -36,6 +36,7 @@ function createNote(id: number, name: string): MarkdownNote { isDeleted: 0, isFavorites: 0, name, + properties: {}, tags: [], updatedAt: now, } @@ -49,7 +50,9 @@ function createRuntimeCache(notes: MarkdownNote[]): NotesRuntimeCache { noteById: new Map(notes.map(note => [note.id, note])), notes, paths: { + assetsPath: '/tmp/meta/assets', inboxDirPath: '/tmp/inbox', + legacyAssetsPath: '/tmp/notes/assets', metaDirPath: '/tmp/meta', notesRoot: '/tmp/notes', statePath: '/tmp/state.json', diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/sync.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/sync.test.ts index ab8672fe8..33e8c21fc 100644 --- a/src/main/storage/providers/markdown/notes/runtime/__tests__/sync.test.ts +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/sync.test.ts @@ -3,8 +3,11 @@ import os from 'node:os' import path from 'node:path' import fs from 'fs-extra' import { afterEach, describe, expect, it, vi } from 'vitest' +import { waitForNotesAssetsMigrationForTests } from '../assetsMigration' import { createDefaultNotesState, saveNotesState } from '../state' import { + resetNotesRuntimeCache, + syncNoteFileWithDisk, syncNotesFoldersWithDisk, syncNotesRuntimeWithDisk, syncNotesWithDisk, @@ -69,7 +72,9 @@ function createNotesPaths(): NotesPaths { const metaDirPath = path.join(notesRoot, '.masscode') return { + assetsPath: path.join(metaDirPath, 'assets'), inboxDirPath: path.join(metaDirPath, 'inbox'), + legacyAssetsPath: path.join(notesRoot, 'assets'), metaDirPath, notesRoot, statePath: path.join(metaDirPath, 'state.json'), @@ -78,6 +83,7 @@ function createNotesPaths(): NotesPaths { } afterEach(() => { + resetNotesRuntimeCache() for (const dirPath of tempDirs.splice(0)) { fs.removeSync(dirPath) } @@ -186,6 +192,140 @@ describe('syncNotesWithDisk', () => { }) }) +describe('syncNoteFileWithDisk', () => { + it('adds a new note to the runtime cache incrementally', () => { + const paths = createNotesPaths() + fs.ensureDirSync(paths.inboxDirPath) + fs.writeFileSync( + path.join(paths.inboxDirPath, 'first.md'), + '---\nid: 1\nname: first\n---\nFirst body\n', + 'utf8', + ) + + const cache = syncNotesRuntimeWithDisk(paths) + expect(cache.notes).toHaveLength(1) + + fs.writeFileSync( + path.join(paths.inboxDirPath, 'second.md'), + '---\nid: 2\nname: second\n---\nSecond body\n', + 'utf8', + ) + + const nextCache = syncNoteFileWithDisk(paths, '.masscode/inbox/second.md') + + expect(nextCache).not.toBeNull() + expect(nextCache).not.toBe(cache) + expect(nextCache!.notes).toHaveLength(2) + expect(nextCache!.noteById.get(2)?.name).toBe('second') + expect(nextCache!.state.notes).toHaveLength(2) + }) + + it('keeps note id when external move processes add before unlink', () => { + const paths = createNotesPaths() + fs.ensureDirSync(paths.inboxDirPath) + const sourcePath = path.join(paths.inboxDirPath, 'source.md') + fs.writeFileSync( + sourcePath, + '---\nid: 7\nname: source\n---\nBody\n', + 'utf8', + ) + + syncNotesRuntimeWithDisk(paths) + + // Внешний mv source.md → target.md: add нового пути приходит раньше + // unlink старого. + const targetPath = path.join(paths.inboxDirPath, 'target.md') + fs.moveSync(sourcePath, targetPath) + + const afterAdd = syncNoteFileWithDisk(paths, '.masscode/inbox/target.md') + + expect(afterAdd).not.toBeNull() + expect(afterAdd!.notes).toHaveLength(1) + expect(afterAdd!.noteById.get(7)?.name).toBe('source') + expect(afterAdd!.state.notes).toHaveLength(1) + expect(afterAdd!.state.notes[0]).toMatchObject({ + filePath: '.masscode/inbox/target.md', + id: 7, + }) + + const afterUnlink = syncNoteFileWithDisk( + paths, + '.masscode/inbox/source.md', + ) + + expect(afterUnlink).not.toBeNull() + expect(afterUnlink!.notes).toHaveLength(1) + expect(afterUnlink!.noteById.get(7)?.name).toBe('source') + }) + + it('updates an existing note in the runtime cache incrementally', () => { + const paths = createNotesPaths() + fs.ensureDirSync(paths.inboxDirPath) + const notePath = path.join(paths.inboxDirPath, 'note.md') + fs.writeFileSync( + notePath, + '---\nid: 1\nname: note\n---\nOriginal body\n', + 'utf8', + ) + + syncNotesRuntimeWithDisk(paths) + + fs.writeFileSync( + notePath, + '---\nid: 1\nname: note\n---\nUpdated body\n', + 'utf8', + ) + + const nextCache = syncNoteFileWithDisk(paths, '.masscode/inbox/note.md') + + expect(nextCache).not.toBeNull() + expect(nextCache!.notes).toHaveLength(1) + expect(nextCache!.noteById.get(1)?.content).toContain('Updated body') + }) + + it('removes a deleted note from the runtime cache incrementally', () => { + const paths = createNotesPaths() + fs.ensureDirSync(paths.inboxDirPath) + const notePath = path.join(paths.inboxDirPath, 'note.md') + fs.writeFileSync(notePath, '---\nid: 1\nname: note\n---\nBody\n', 'utf8') + + const cache = syncNotesRuntimeWithDisk(paths) + expect(cache.notes).toHaveLength(1) + + fs.removeSync(notePath) + + const nextCache = syncNoteFileWithDisk(paths, '.masscode/inbox/note.md') + + expect(nextCache).not.toBeNull() + expect(nextCache!.notes).toHaveLength(0) + expect(nextCache!.noteById.has(1)).toBe(false) + expect(nextCache!.state.notes).toHaveLength(0) + }) + + it('returns null for files in unknown directories to force a full sync', () => { + const paths = createNotesPaths() + fs.ensureDirSync(paths.inboxDirPath) + syncNotesRuntimeWithDisk(paths) + + fs.ensureDirSync(path.join(paths.notesRoot, 'Unknown')) + fs.writeFileSync( + path.join(paths.notesRoot, 'Unknown', 'note.md'), + '---\nid: 3\nname: note\n---\nBody\n', + 'utf8', + ) + + expect(syncNoteFileWithDisk(paths, 'Unknown/note.md')).toBeNull() + }) + + it('returns null for non-markdown files', () => { + const paths = createNotesPaths() + fs.ensureDirSync(paths.inboxDirPath) + syncNotesRuntimeWithDisk(paths) + + expect(syncNoteFileWithDisk(paths, '.masscode/state.json')).toBeNull() + }) +}) + describe('syncNotesRuntimeWithDisk', () => { it('flushes pending state writes before loading state', () => { const paths = createNotesPaths() @@ -204,4 +344,90 @@ describe('syncNotesRuntimeWithDisk', () => { expect(cache.state.tags).toHaveLength(1) expect(cache.state.tags[0].name).toBe('pending-tag') }) + + it('schedules referenced asset migration after a full sync', async () => { + const paths = createNotesPaths() + const fileName = 'abcdefghijklmnop.png' + fs.ensureDirSync(paths.legacyAssetsPath) + fs.writeFileSync(path.join(paths.legacyAssetsPath, fileName), 'asset') + fs.writeFileSync( + path.join(paths.notesRoot, 'Note.md'), + `---\nid: 1\nname: Note\n---\n![asset](masscode://notes-asset/${fileName})\n`, + 'utf8', + ) + + syncNotesRuntimeWithDisk(paths) + await waitForNotesAssetsMigrationForTests() + + expect(fs.pathExistsSync(path.join(paths.assetsPath, fileName))).toBe(true) + expect(fs.pathExistsSync(path.join(paths.legacyAssetsPath, fileName))).toBe( + false, + ) + }) + + it('discovers a late reference after successful incremental note sync', async () => { + const paths = createNotesPaths() + const fileName = 'abcdefghijklmnop.png' + fs.ensureDirSync(paths.legacyAssetsPath) + fs.writeFileSync(path.join(paths.legacyAssetsPath, fileName), 'asset') + + syncNotesRuntimeWithDisk(paths) + await waitForNotesAssetsMigrationForTests() + + fs.writeFileSync( + path.join(paths.notesRoot, 'Late.md'), + `---\nid: 2\nname: Late\n---\n![asset](masscode://notes-asset/${fileName})\n`, + 'utf8', + ) + expect(syncNoteFileWithDisk(paths, 'Late.md')).not.toBeNull() + await waitForNotesAssetsMigrationForTests() + + expect(fs.pathExistsSync(path.join(paths.assetsPath, fileName))).toBe(true) + expect(fs.pathExistsSync(path.join(paths.legacyAssetsPath, fileName))).toBe( + false, + ) + }) +}) + +describe('provisional notes state during cloud hydration', () => { + afterEach(() => { + resetNotesRuntimeCache() + }) + + it('never persists provisional state over the real index', () => { + const paths = createNotesPaths() + fs.writeFileSync( + path.join(paths.notesRoot, 'Known.md'), + '---\nid: 1\nname: Known\n---\nbody\n', + 'utf8', + ) + const cache = syncNotesRuntimeWithDisk(paths) + const persistedContent = fs.readFileSync(paths.statePath, 'utf8') + + // Состояние помечено как provisional (state.json «ещё не докачан»): + // даже изменённый индекс не должен доехать до диска. + cache.state.provisional = true + cache.state.notes.push({ filePath: 'phantom.md', id: 99 }) + saveNotesState(paths, cache.state, { immediate: true }) + + expect(fs.readFileSync(paths.statePath, 'utf8')).toBe(persistedContent) + }) + + it('skips watcher registration of notes while state is provisional', () => { + const paths = createNotesPaths() + const cache = syncNotesRuntimeWithDisk(paths) + cache.state.provisional = true + + fs.writeFileSync( + path.join(paths.notesRoot, 'Fresh.md'), + '---\nid: 5\nname: Fresh\n---\nbody\n', + 'utf8', + ) + const result = syncNoteFileWithDisk(paths, 'Fresh.md') + + // Событие не эскалирует в полный ресинк и не регистрирует файл: + // его подберёт сверка после докачки state. + expect(result).toBe(cache) + expect(cache.state.notes).toHaveLength(0) + }) }) diff --git a/src/main/storage/providers/markdown/notes/runtime/assets.ts b/src/main/storage/providers/markdown/notes/runtime/assets.ts new file mode 100644 index 000000000..66f24905f --- /dev/null +++ b/src/main/storage/providers/markdown/notes/runtime/assets.ts @@ -0,0 +1,273 @@ +import type { NotesPaths } from './types' +import { Buffer } from 'node:buffer' +import { randomBytes } from 'node:crypto' +import { constants as fsConstants } from 'node:fs' +import { lstat, open, readFile, rename, rm } from 'node:fs/promises' +import path from 'node:path' +import fs from 'fs-extra' +import { prioritizeCloudDownload } from '../../cloudDownloads' +import { getFileAvailability } from '../../runtime/shared/cloudFiles' + +const ASSET_ID_LENGTHS = new Set([16, 21]) +const READER_MIME_BY_EXTENSION: Record = { + '.bmp': 'image/bmp', + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +} +const WRITER_EXTENSIONS = new Set(['.jpeg', '.jpg', '.png']) +const SAFE_RESPONSE_HEADERS = { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', +} + +export interface ParsedNotesAssetName { + extension: string + fileName: string + mimeType: string +} + +export interface NotesAssetWritePayload { + buffer: ArrayBuffer + ext: string +} + +export function parseNotesAssetWritePayload( + payload: unknown, +): NotesAssetWritePayload | null { + if (!payload || typeof payload !== 'object') { + return null + } + + const candidate = payload as { buffer?: unknown, ext?: unknown } + if ( + typeof candidate.ext !== 'string' + || !(candidate.buffer instanceof ArrayBuffer) + ) { + return null + } + + return { buffer: candidate.buffer, ext: candidate.ext } +} + +function hasExpectedImageSignature(data: Buffer, extension: string): boolean { + if (extension === '.png') { + return data + .subarray(0, 8) + .equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) + } + + if (extension === '.jpg' || extension === '.jpeg') { + return ( + data.length >= 3 + && data[0] === 0xFF + && data[1] === 0xD8 + && data[2] === 0xFF + ) + } + + return false +} + +export function parseNotesAssetName( + value: string, +): ParsedNotesAssetName | null { + let fileName: string + try { + fileName = decodeURIComponent(value) + } + catch { + return null + } + + if ( + fileName !== path.basename(fileName) + || fileName.includes('/') + || fileName.includes('\\') + || fileName.includes('..') + ) { + return null + } + + const extension = path.extname(fileName).toLowerCase() + const stem = fileName.slice(0, -extension.length) + const mimeType = READER_MIME_BY_EXTENSION[extension] + + if ( + !mimeType + || !ASSET_ID_LENGTHS.has(stem.length) + || !/^[\w-]+$/.test(stem) + ) { + return null + } + + return { extension, fileName, mimeType } +} + +export function getNotesAssetNameFromAbsolutePath( + paths: NotesPaths, + absolutePath: string, +): string | null { + const parentPath = path.resolve(path.dirname(absolutePath)) + if ( + parentPath !== path.resolve(paths.assetsPath) + && parentPath !== path.resolve(paths.legacyAssetsPath) + ) { + return null + } + + return parseNotesAssetName(path.basename(absolutePath))?.fileName ?? null +} + +export function normalizeNotesAssetWriterExtension( + value: string, +): string | null { + const extension = value.toLowerCase() + return WRITER_EXTENSIONS.has(extension) ? extension : null +} + +function generateAssetId(): string { + return randomBytes(12).toString('base64url') +} + +export async function writeNotesAsset( + paths: NotesPaths, + payload: ArrayBuffer, + requestedExtension: string, + generateId: () => string = generateAssetId, +): Promise { + const extension = normalizeNotesAssetWriterExtension(requestedExtension) + if (!extension) { + throw new Error('Unsupported Notes asset extension') + } + + const data = Buffer.from(payload) + if (!hasExpectedImageSignature(data, extension)) { + throw new Error('Notes asset payload does not match its extension') + } + + const fileName = `${generateId()}${extension}` + const parsedName = parseNotesAssetName(fileName) + if (!parsedName) { + throw new Error('Invalid generated Notes asset name') + } + + await fs.ensureDir(paths.assetsPath) + const destinationPath = path.join(paths.assetsPath, parsedName.fileName) + const destinationAvailability = getFileAvailability(destinationPath) + if (destinationAvailability.exists) { + throw new Error('Notes asset destination already exists') + } + + const tempPath = path.join( + paths.assetsPath, + `.${parsedName.fileName}.${randomBytes(8).toString('hex')}.tmp`, + ) + let tempCreated = false + + try { + const handle = await open( + tempPath, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, + 0o600, + ) + tempCreated = true + try { + await handle.writeFile(data) + } + finally { + await handle.close() + } + + if (getFileAvailability(destinationPath).exists) { + throw new Error('Notes asset destination already exists') + } + + await rename(tempPath, destinationPath) + tempCreated = false + return `masscode://notes-asset/${parsedName.fileName}` + } + finally { + if (tempCreated) { + await rm(tempPath, { force: true }) + } + } +} + +function unavailableResponse(): Response { + return new Response('Temporarily unavailable', { + headers: { + ...SAFE_RESPONSE_HEADERS, + 'Retry-After': '1', + }, + status: 503, + }) +} + +function notFoundResponse(): Response { + return new Response('Not found', { + headers: SAFE_RESPONSE_HEADERS, + status: 404, + }) +} + +export async function resolveNotesAsset( + rawFileName: string, + paths: NotesPaths, + prioritizeDownload: (absolutePath: string) => void = prioritizeCloudDownload, +): Promise { + const parsedName = parseNotesAssetName(rawFileName) + if (!parsedName) { + return notFoundResponse() + } + + for (const rootPath of [paths.assetsPath, paths.legacyAssetsPath]) { + const absolutePath = path.join(rootPath, parsedName.fileName) + try { + if ((await lstat(absolutePath)).isSymbolicLink()) { + return notFoundResponse() + } + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + continue + } + return notFoundResponse() + } + + const availability = getFileAvailability(absolutePath) + + if (!availability.exists) { + continue + } + + if (!availability.stats?.isFile()) { + return notFoundResponse() + } + + if (availability.isCloudPlaceholder) { + prioritizeDownload(absolutePath) + return unavailableResponse() + } + + try { + const data = await readFile(absolutePath) + const headers: Record = { + ...SAFE_RESPONSE_HEADERS, + 'Content-Type': parsedName.mimeType, + } + if (parsedName.extension === '.svg') { + headers['Content-Security-Policy'] = 'default-src \'none\'; sandbox' + } + return new Response(data, { headers }) + } + catch { + return notFoundResponse() + } + } + + return notFoundResponse() +} diff --git a/src/main/storage/providers/markdown/notes/runtime/assetsInspection.ts b/src/main/storage/providers/markdown/notes/runtime/assetsInspection.ts new file mode 100644 index 000000000..a87b9e74c --- /dev/null +++ b/src/main/storage/providers/markdown/notes/runtime/assetsInspection.ts @@ -0,0 +1,16 @@ +import { parseNotesAssetName } from './assets' + +const NOTES_ASSET_URL_PATTERN = /masscode:\/\/notes-asset\/([^\s)<>'"]+)/g + +export function extractNotesAssetNames(source: string): Set { + const fileNames = new Set() + + for (const match of source.matchAll(NOTES_ASSET_URL_PATTERN)) { + const parsedName = parseNotesAssetName(match[1]) + if (parsedName) { + fileNames.add(parsedName.fileName) + } + } + + return fileNames +} diff --git a/src/main/storage/providers/markdown/notes/runtime/assetsMigration.ts b/src/main/storage/providers/markdown/notes/runtime/assetsMigration.ts new file mode 100644 index 000000000..cb700617d --- /dev/null +++ b/src/main/storage/providers/markdown/notes/runtime/assetsMigration.ts @@ -0,0 +1,488 @@ +import type { Stats } from 'node:fs' +import type { NotesRuntimeCache } from './types' +import { createHash, randomBytes } from 'node:crypto' +import { constants as fsConstants } from 'node:fs' +import { + copyFile, + link, + lstat, + open, + readFile, + rm, + unlink, +} from 'node:fs/promises' +import path from 'node:path' +import fs from 'fs-extra' +import { log } from '../../../../../utils' +import { + enqueueCloudDownload, + prioritizeCloudDownload, +} from '../../cloudDownloads' +import { getFileAvailability } from '../../runtime/shared/cloudFiles' +import { extractNotesAssetNames } from './assetsInspection' +import { ensureNoteContentLoaded } from './notes' + +export interface NotesAssetsMigrationSummary { + conflicts: number + deferred: number + errors: number + migrated: number + referenced: number + removedEqual: number +} + +export interface NotesAssetReferenceDiscovery { + complete: boolean + fileNames: Set +} + +interface NotesAssetsMigrationHooks { + afterFinalDestinationHash?: (paths: { + destinationPath: string + sourcePath: string + tempPath?: string + }) => Promise | void + afterPublish?: (paths: { + destinationPath: string + sourcePath: string + tempPath: string + }) => Promise | void + beforePublish?: (paths: { + destinationPath: string + sourcePath: string + tempPath: string + }) => Promise | void +} + +type MigrationOutcome = + | 'conflict' + | 'deferred' + | 'migrated' + | 'missing' + | 'removed-equal' + +let migrationGeneration = 0 +let activeMigration: Promise | null = null +let pendingCache: NotesRuntimeCache | null = null + +function isDirectFilePath(rootPath: string, filePath: string): boolean { + return path.dirname(filePath) === rootPath +} + +async function assertDirectRegularFile( + rootPath: string, + filePath: string, +): Promise { + if (!isDirectFilePath(rootPath, filePath)) { + throw new Error('Notes asset migration path is not direct') + } + + const stats = await lstat(filePath) + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error('Notes asset migration source is not a regular file') + } + return stats +} + +async function hashFile(filePath: string): Promise { + const data = await readFile(filePath) + return createHash('sha256').update(data).digest('hex') +} + +async function verifyAndDeleteSource( + sourceRootPath: string, + sourcePath: string, + destinationPath: string, + expectedHash: string, + isCancelled: () => boolean, + hooks: NotesAssetsMigrationHooks, + tempPath?: string, +): Promise<'conflict' | 'deferred' | 'removed'> { + const destinationStatsBefore = await assertDirectRegularFile( + path.dirname(destinationPath), + destinationPath, + ) + if (tempPath) { + const tempStats = await lstat(tempPath) + if ( + tempStats.dev !== destinationStatsBefore.dev + || tempStats.ino !== destinationStatsBefore.ino + ) { + return 'conflict' + } + } + + const destinationHash = await hashFile(destinationPath) + await hooks.afterFinalDestinationHash?.({ + destinationPath, + sourcePath, + ...(tempPath ? { tempPath } : {}), + }) + if (destinationHash !== expectedHash) { + return 'conflict' + } + + const destinationStatsAfter = await assertDirectRegularFile( + path.dirname(destinationPath), + destinationPath, + ) + if ( + destinationStatsAfter.dev !== destinationStatsBefore.dev + || destinationStatsAfter.ino !== destinationStatsBefore.ino + ) { + return 'conflict' + } + + await assertDirectRegularFile(sourceRootPath, sourcePath) + if ((await hashFile(sourcePath)) !== expectedHash) { + return 'conflict' + } + + if (isCancelled()) { + return 'deferred' + } + await unlink(sourcePath) + return 'removed' +} + +async function removePublishedDestinationIfOwned( + tempPath: string, + destinationPath: string, +): Promise { + try { + const [tempStats, destinationStats] = await Promise.all([ + lstat(tempPath), + lstat(destinationPath), + ]) + if ( + tempStats.dev === destinationStats.dev + && tempStats.ino === destinationStats.ino + ) { + await unlink(destinationPath) + } + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + log('storage:notes:assets-migration-cleanup', error) + } + } +} + +async function evaluateExistingDestination( + cache: NotesRuntimeCache, + sourcePath: string, + destinationPath: string, + sourceHash: string, + isCancelled: () => boolean, + hooks: NotesAssetsMigrationHooks, +): Promise { + const availability = getFileAvailability(destinationPath) + if (!availability.exists) { + return null + } + + await assertDirectRegularFile(cache.paths.assetsPath, destinationPath) + if (availability.isCloudPlaceholder) { + prioritizeCloudDownload(destinationPath) + return 'deferred' + } + + const deletion = await verifyAndDeleteSource( + cache.paths.legacyAssetsPath, + sourcePath, + destinationPath, + sourceHash, + isCancelled, + hooks, + ) + if (deletion === 'removed') { + return 'removed-equal' + } + return deletion +} + +export function discoverNotesAssetReferences( + cache: NotesRuntimeCache, +): NotesAssetReferenceDiscovery { + const fileNames = new Set() + let complete = true + + for (const note of cache.notes) { + if (note.pendingCloudDownload) { + enqueueCloudDownload(path.join(cache.paths.notesRoot, note.filePath)) + complete = false + continue + } + + if (note.content === null && !ensureNoteContentLoaded(cache.paths, note)) { + complete = false + continue + } + + if (note.content === null) { + complete = false + continue + } + + for (const fileName of extractNotesAssetNames(note.content)) { + fileNames.add(fileName) + } + } + + return { complete, fileNames } +} + +async function migrateReferencedAsset( + cache: NotesRuntimeCache, + fileName: string, + isCancelled: () => boolean, + hooks: NotesAssetsMigrationHooks, +): Promise { + if (isCancelled()) { + return 'deferred' + } + + const sourcePath = path.join(cache.paths.legacyAssetsPath, fileName) + const destinationPath = path.join(cache.paths.assetsPath, fileName) + const sourceAvailability = getFileAvailability(sourcePath) + + if (!sourceAvailability.exists) { + return 'missing' + } + await assertDirectRegularFile(cache.paths.legacyAssetsPath, sourcePath) + if (isCancelled()) { + return 'deferred' + } + + const destinationAvailability = getFileAvailability(destinationPath) + if (destinationAvailability.exists) { + await assertDirectRegularFile(cache.paths.assetsPath, destinationPath) + if (isCancelled()) { + return 'deferred' + } + } + + let shouldDeferForHydration = false + if (sourceAvailability.isCloudPlaceholder) { + prioritizeCloudDownload(sourcePath) + shouldDeferForHydration = true + } + if ( + destinationAvailability.exists + && destinationAvailability.isCloudPlaceholder + ) { + prioritizeCloudDownload(destinationPath) + shouldDeferForHydration = true + } + if (shouldDeferForHydration) { + return 'deferred' + } + + if (destinationAvailability.exists) { + const sourceHash = await hashFile(sourcePath) + return ( + (await evaluateExistingDestination( + cache, + sourcePath, + destinationPath, + sourceHash, + isCancelled, + hooks, + )) ?? 'deferred' + ) + } + + const sourceHash = await hashFile(sourcePath) + if (isCancelled()) { + return 'deferred' + } + + await fs.ensureDir(cache.paths.assetsPath) + const tempPath = path.join( + cache.paths.assetsPath, + `.${fileName}.${randomBytes(8).toString('hex')}.migration.tmp`, + ) + let tempCreated = false + let shouldCleanupPublishedDestination = false + + try { + const tempHandle = await open( + tempPath, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, + 0o600, + ) + tempCreated = true + await tempHandle.close() + + await copyFile(sourcePath, tempPath) + if ((await hashFile(tempPath)) !== sourceHash) { + throw new Error('Notes asset migration temp hash mismatch') + } + + if (isCancelled()) { + return 'deferred' + } + + await hooks.beforePublish?.({ + destinationPath, + sourcePath, + tempPath, + }) + + try { + await link(tempPath, destinationPath) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error + } + + return ( + (await evaluateExistingDestination( + cache, + sourcePath, + destinationPath, + sourceHash, + isCancelled, + hooks, + )) ?? 'deferred' + ) + } + + shouldCleanupPublishedDestination = true + await hooks.afterPublish?.({ + destinationPath, + sourcePath, + tempPath, + }) + + const deletion = await verifyAndDeleteSource( + cache.paths.legacyAssetsPath, + sourcePath, + destinationPath, + sourceHash, + isCancelled, + hooks, + tempPath, + ) + if (deletion === 'conflict') { + return 'conflict' + } + shouldCleanupPublishedDestination = false + return deletion === 'removed' ? 'migrated' : 'deferred' + } + finally { + if (shouldCleanupPublishedDestination) { + await removePublishedDestinationIfOwned(tempPath, destinationPath) + } + if (tempCreated) { + await rm(tempPath, { force: true }) + } + } +} + +async function runNotesAssetsMigrationInternal( + cache: NotesRuntimeCache, + isCancelled: () => boolean, + hooks: NotesAssetsMigrationHooks, +): Promise { + const discovery = discoverNotesAssetReferences(cache) + const summary: NotesAssetsMigrationSummary = { + conflicts: 0, + deferred: discovery.complete ? 0 : 1, + errors: 0, + migrated: 0, + referenced: discovery.fileNames.size, + removedEqual: 0, + } + + for (const fileName of discovery.fileNames) { + if (isCancelled()) { + summary.deferred += 1 + break + } + + try { + const outcome = await migrateReferencedAsset( + cache, + fileName, + isCancelled, + hooks, + ) + if (outcome === 'conflict') { + summary.conflicts += 1 + } + else if (outcome === 'deferred') { + summary.deferred += 1 + } + else if (outcome === 'migrated') { + summary.migrated += 1 + } + else if (outcome === 'removed-equal') { + summary.removedEqual += 1 + } + } + catch (error) { + summary.errors += 1 + log('storage:notes:assets-migration-item', error) + } + } + + return summary +} + +export async function runNotesAssetsMigration( + cache: NotesRuntimeCache, + isCancelled: () => boolean = () => false, +): Promise { + return runNotesAssetsMigrationInternal(cache, isCancelled, {}) +} + +// Test-only fault injection entrypoint; intentionally omitted from runtime/index. +export async function runNotesAssetsMigrationForTests( + cache: NotesRuntimeCache, + isCancelled: () => boolean = () => false, + hooks: NotesAssetsMigrationHooks = {}, +): Promise { + return runNotesAssetsMigrationInternal(cache, isCancelled, hooks) +} + +export function scheduleNotesAssetsMigration(cache: NotesRuntimeCache): void { + pendingCache = cache + if (activeMigration) { + return + } + + const generation = migrationGeneration + const drain = async (): Promise => { + while (pendingCache) { + if (generation !== migrationGeneration) { + break + } + const nextCache = pendingCache + pendingCache = null + await runNotesAssetsMigration( + nextCache, + () => generation !== migrationGeneration, + ) + } + } + + activeMigration = drain() + .catch(error => log('storage:notes:assets-migration', error)) + .finally(() => { + activeMigration = null + if (pendingCache) { + scheduleNotesAssetsMigration(pendingCache) + } + }) +} + +export function cancelNotesAssetsMigration(): void { + migrationGeneration += 1 + pendingCache = null +} + +export async function waitForNotesAssetsMigrationForTests(): Promise { + await activeMigration +} diff --git a/src/main/storage/providers/markdown/notes/runtime/backlinks.ts b/src/main/storage/providers/markdown/notes/runtime/backlinks.ts index 25d77b7fd..9ac9c87cb 100644 --- a/src/main/storage/providers/markdown/notes/runtime/backlinks.ts +++ b/src/main/storage/providers/markdown/notes/runtime/backlinks.ts @@ -1,16 +1,22 @@ +import type { InternalLinkLookupItem } from '../../../../../../shared/notes/internalLinks' import type { - InternalLinkLookupItem, - InternalLinkType, -} from '../../../../../../shared/notes/internalLinks' -import type { MarkdownNote, NotesPaths, NotesState } from './types' + DeferredBacklinkRewriteOp, + MarkdownNote, + NotesPaths, + NotesState, +} from './types' import { normalizeInternalLinkLookupKey, - resolveInternalLinkTargetByTitle, rewriteInternalLinks, } from '../../../../../../shared/notes/internalLinks' import { getPaths, getVaultPath } from '../../runtime/paths' import { getRuntimeCache } from '../../runtime/sync' -import { writeNoteToFile } from './notes' +import { createInternalLinkResolver } from './internalLinkResolver' +import { + ensureAllNoteContentsLoaded, + ensureNoteContentLoaded, + writeNoteToFile, +} from './notes' import { buildNotesFolderPathMap } from './paths' import { invalidateNotesSearchIndex } from './search' @@ -42,29 +48,36 @@ interface RewriteBacklinksAfterFolderUpdateInput { newFolderPathMap: Map } -interface ShortestUniqueLookupItem { - id: number - type: InternalLinkType - name: string - folderPath: string +function buildLookupKeyCounts( + items: InternalLinkLookupItem[], +): Map { + const counts = new Map() + + for (const item of items) { + const key = normalizeInternalLinkLookupKey(item.name) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + + return counts } +/** + * The selected item is expected to be present in the lookup the counts were + * built from, so a collision exists when its key occurs more than once. + */ function pickShortestUniqueLinkTarget( - selected: ShortestUniqueLookupItem, - candidates: ShortestUniqueLookupItem[], + name: string, + folderPath: string, + lookupKeyCounts: Map, ): string { - const selectedKey = normalizeInternalLinkLookupKey(selected.name) - const hasCollision = candidates.some( - candidate => - !(candidate.id === selected.id && candidate.type === selected.type) - && normalizeInternalLinkLookupKey(candidate.name) === selectedKey, - ) + const hasCollision + = (lookupKeyCounts.get(normalizeInternalLinkLookupKey(name)) ?? 0) > 1 - if (!hasCollision || !selected.folderPath) { - return selected.name + if (!hasCollision || !folderPath) { + return name } - return `${selected.folderPath}/${selected.name}` + return `${folderPath}/${name}` } function buildNoteLookupFromState( @@ -100,6 +113,139 @@ function loadSnippetLookup(): InternalLinkLookupItem[] { } } +// Отложенные rewrite'ы для заметок, чьё содержимое ещё не докачано из +// облака в момент rename/move: применяются при гидрации заметки, чтобы её +// [[ссылки]] не остались указывать на старое имя. Очередь хранится в +// notes state сериализуемыми спеками (DeferredBacklinkRewriteOp) и потому +// переживает перезапуск приложения и смену vault. + +function addPendingLinkerToOp( + op: DeferredBacklinkRewriteOp, + noteId: number, + linkerFolderPath: string, +): void { + if (!op.pendingNoteIds.includes(noteId)) { + op.pendingNoteIds.push(noteId) + } + + op.linkerFolderPathByNoteId[String(noteId)] = linkerFolderPath +} + +function queueDeferredBacklinkRewriteOp( + state: NotesState, + op: DeferredBacklinkRewriteOp, +): void { + if (!op.pendingNoteIds.length) { + return + } + + state.deferredBacklinkRewrites ??= [] + state.deferredBacklinkRewrites.push(op) +} + +function removeNoteFromDeferredOps(state: NotesState, noteId: number): void { + if (!state.deferredBacklinkRewrites) { + return + } + + for (const op of state.deferredBacklinkRewrites) { + const index = op.pendingNoteIds.indexOf(noteId) + if (index !== -1) { + op.pendingNoteIds.splice(index, 1) + delete op.linkerFolderPathByNoteId[String(noteId)] + } + } + + state.deferredBacklinkRewrites = state.deferredBacklinkRewrites.filter( + op => op.pendingNoteIds.length > 0, + ) + + if (!state.deferredBacklinkRewrites.length) { + delete state.deferredBacklinkRewrites + } +} + +// Восстанавливает rewrite-функцию из сериализованной спеки: резолвит каждую +// [[ссылку]] по lookup имён до переименования и подменяет target, если +// ссылка указывала на переименованную заметку. +function buildDeferredRewrite( + op: DeferredBacklinkRewriteOp, + noteId: number, +): (content: string) => string | null { + const resolver = createInternalLinkResolver(op.preLookup) + const linkerFolderPath = op.linkerFolderPathByNoteId[String(noteId)] ?? '' + + return content => + rewriteInternalLinks(content, (match) => { + if (match.legacyTarget) { + return null + } + if (op.linkKind === 'bare' && match.pathSegments.length > 0) { + return null + } + if (op.linkKind === 'path' && match.pathSegments.length === 0) { + return null + } + + const resolved = resolver.resolve(match.target, { linkerFolderPath }) + if (resolved === null || resolved.type !== 'note') { + return null + } + + const newTarget = op.targetsById[String(resolved.id)] + if (newTarget === undefined || newTarget === match.target) { + return null + } + + return newTarget + }) +} + +export function applyDeferredBacklinkRewrites( + paths: NotesPaths, + state: NotesState, + note: MarkdownNote, +): boolean { + const ops = state.deferredBacklinkRewrites?.filter(op => + op.pendingNoteIds.includes(note.id), + ) + if (!ops?.length || note.pendingCloudDownload) { + return false + } + + if (note.content === null && !ensureNoteContentLoaded(paths, note)) { + return false + } + + let content = note.content ?? '' + let changed = false + + for (const op of ops) { + const rewritten = buildDeferredRewrite(op, note.id)(content) + if (rewritten !== null) { + content = rewritten + changed = true + } + } + + if (changed) { + note.content = content + note.updatedAt = Date.now() + + // Файл испарился между гидрацией и записью (eviction): заметка остаётся + // в очереди, rewrite применится при следующей гидрации. + if (!writeNoteToFile(paths, note, { skipIfUnavailable: true })) { + return false + } + + invalidateNotesSearchIndex(state) + } + + removeNoteFromDeferredOps(state, note.id) + + return changed +} + export function rewriteBacklinksAfterNoteUpdate( input: RewriteBacklinksAfterNoteUpdateInput, ): number { @@ -125,6 +271,10 @@ export function rewriteBacklinksAfterNoteUpdate( return 0 } + // Переписывание [[ссылок]] сканирует тела всех заметок: ленивые записи + // сначала дочитываются, иначе их ссылки молча остались бы битыми. + ensureAllNoteContentsLoaded(paths, notes) + const snippetLookup = loadSnippetLookup() const folderPathMap = buildNotesFolderPathMap(state) @@ -165,46 +315,36 @@ export function rewriteBacklinksAfterNoteUpdate( const preLookup = [...snippetLookup, ...buildNoteLookup('previous')] const postLookup = [...snippetLookup, ...buildNoteLookup('next')] + const preResolver = createInternalLinkResolver(preLookup) + const postLookupKeyCounts = buildLookupKeyCounts(postLookup) - const shortestUniqueCandidates: ShortestUniqueLookupItem[] = postLookup.map( - item => ({ - folderPath: item.folderPath ?? '', - id: item.id, - name: item.name, - type: item.type, - }), - ) const updatedTarget = pickShortestUniqueLinkTarget( - { - folderPath: nextFolderPath, - id: updatedNoteId, - name: nextName, - type: 'note', - }, - shortestUniqueCandidates, + nextName, + nextFolderPath, + postLookupKeyCounts, ) let rewrittenCount = 0 const now = Date.now() - for (const note of notes) { - if (note.id === updatedNoteId || note.isDeleted || !note.content) { - continue - } - - const linkerFolderPath - = note.folderId === null ? '' : (folderPathMap.get(note.folderId) ?? '') + const deferredOp: DeferredBacklinkRewriteOp = { + linkKind: 'any', + linkerFolderPathByNoteId: {}, + pendingNoteIds: [], + preLookup, + targetsById: { [String(updatedNoteId)]: updatedTarget }, + } - const rewritten = rewriteInternalLinks(note.content, (match) => { + const rewriteLinkerContent = ( + content: string, + linkerFolderPath: string, + ): string | null => + rewriteInternalLinks(content, (match) => { if (match.legacyTarget) { return null } - const resolved = resolveInternalLinkTargetByTitle( - match.target, - preLookup, - { linkerFolderPath }, - ) + const resolved = preResolver.resolve(match.target, { linkerFolderPath }) if ( resolved === null || resolved.type !== 'note' @@ -216,16 +356,47 @@ export function rewriteBacklinksAfterNoteUpdate( return updatedTarget }) + for (const note of notes) { + if (note.id === updatedNoteId || note.isDeleted) { + continue + } + + const linkerFolderPath + = note.folderId === null ? '' : (folderPathMap.get(note.folderId) ?? '') + + // Тело ещё в облаке — rewrite откладывается и применится при гидрации. + // content === null означает, что тело не догрузилось (файл стал + // placeholder после скана, флаг ещё не обновился): тоже откладываем. + if (note.pendingCloudDownload || note.content === null) { + addPendingLinkerToOp(deferredOp, note.id, linkerFolderPath) + continue + } + + if (!note.content) { + continue + } + + const rewritten = rewriteLinkerContent(note.content, linkerFolderPath) + if (rewritten === null) { continue } note.content = rewritten note.updatedAt = now - writeNoteToFile(paths, note) + + // Файл испарился между проверкой и записью (eviction): rewrite + // откладывается и применится при гидрации. + if (!writeNoteToFile(paths, note, { skipIfUnavailable: true })) { + addPendingLinkerToOp(deferredOp, note.id, linkerFolderPath) + continue + } + rewrittenCount++ } + queueDeferredBacklinkRewriteOp(state, deferredOp) + if (nameChanged) { const nextKey = normalizeInternalLinkLookupKey(nextName) const otherConflictIds = postLookup @@ -265,41 +436,54 @@ export function promoteBareBacklinksOnConflict( return 0 } + // Переписывание [[ссылок]] сканирует тела всех заметок: ленивые записи + // сначала дочитываются, иначе их ссылки молча остались бы битыми. + ensureAllNoteContentsLoaded(paths, notes) + const folderPathMap = buildNotesFolderPathMap(state) const noteFolderPath = (note: MarkdownNote): string => note.folderId === null ? '' : (folderPathMap.get(note.folderId) ?? '') + const preResolver = createInternalLinkResolver(preLookup) + const postLookupKeyCounts = buildLookupKeyCounts(postLookup) + const postNoteItemById = new Map() + for (const item of postLookup) { + if (item.type === 'note' && !postNoteItemById.has(item.id)) { + postNoteItemById.set(item.id, item) + } + } + let total = 0 const now = Date.now() const rewrittenLinkers = new Set() for (const noteId of conflictNoteIds) { - const targetItem = postLookup.find( - item => item.type === 'note' && item.id === noteId, - ) + const targetItem = postNoteItemById.get(noteId) if (!targetItem || !targetItem.folderPath) { continue } const targetKey = normalizeInternalLinkLookupKey(targetItem.name) - const hasCollision = postLookup.some( - item => - !(item.id === noteId && item.type === 'note') - && normalizeInternalLinkLookupKey(item.name) === targetKey, - ) + const hasCollision = (postLookupKeyCounts.get(targetKey) ?? 0) > 1 if (!hasCollision) { continue } const newTarget = `${targetItem.folderPath}/${targetItem.name}` - for (const linker of notes) { - if (linker.id === noteId || linker.isDeleted || !linker.content) { - continue - } + const deferredOp: DeferredBacklinkRewriteOp = { + linkKind: 'bare', + linkerFolderPathByNoteId: {}, + pendingNoteIds: [], + preLookup, + targetsById: { [String(noteId)]: newTarget }, + } - const linkerFolderPath = noteFolderPath(linker) - const rewritten = rewriteInternalLinks(linker.content, (match) => { + const rewriteLinkerContent = ( + content: string, + linkerFolderPath: string, + ): string | null => + rewriteInternalLinks(content, (match) => { if (match.legacyTarget) { return null } @@ -310,11 +494,9 @@ export function promoteBareBacklinksOnConflict( return null } - const resolved = resolveInternalLinkTargetByTitle( - match.target, - preLookup, - { linkerFolderPath }, - ) + const resolved = preResolver.resolve(match.target, { + linkerFolderPath, + }) if ( resolved === null || resolved.type !== 'note' @@ -326,18 +508,49 @@ export function promoteBareBacklinksOnConflict( return newTarget }) + for (const linker of notes) { + if (linker.id === noteId || linker.isDeleted) { + continue + } + + const linkerFolderPath = noteFolderPath(linker) + + // Тело ещё в облаке — rewrite откладывается и применится при + // гидрации. content === null означает, что тело не догрузилось (файл + // стал placeholder после скана, флаг ещё не обновился): тоже + // откладываем. + if (linker.pendingCloudDownload || linker.content === null) { + addPendingLinkerToOp(deferredOp, linker.id, linkerFolderPath) + continue + } + + if (!linker.content) { + continue + } + + const rewritten = rewriteLinkerContent(linker.content, linkerFolderPath) + if (rewritten === null) { continue } linker.content = rewritten linker.updatedAt = now - writeNoteToFile(paths, linker) + + // Файл испарился между проверкой и записью (eviction): rewrite + // откладывается и применится при гидрации. + if (!writeNoteToFile(paths, linker, { skipIfUnavailable: true })) { + addPendingLinkerToOp(deferredOp, linker.id, linkerFolderPath) + continue + } + if (!rewrittenLinkers.has(linker.id)) { rewrittenLinkers.add(linker.id) total++ } } + + queueDeferredBacklinkRewriteOp(state, deferredOp) } if (total > 0) { @@ -363,6 +576,10 @@ export function rewriteBacklinksAfterFolderUpdate( return 0 } + // Переписывание [[ссылок]] сканирует тела всех заметок: ленивые записи + // сначала дочитываются, иначе их ссылки молча остались бы битыми. + ensureAllNoteContentsLoaded(paths, notes) + const affectedNoteIds = new Set() for (const note of notes) { if ( @@ -399,30 +616,45 @@ export function rewriteBacklinksAfterFolderUpdate( const preLookup = buildLookup(oldFolderPathMap) const postLookup = buildLookup(newFolderPathMap) - - const shortestUniqueCandidates: ShortestUniqueLookupItem[] = postLookup.map( - item => ({ - folderPath: item.folderPath ?? '', - id: item.id, - name: item.name, - type: item.type, - }), - ) + const preResolver = createInternalLinkResolver(preLookup) + const postLookupKeyCounts = buildLookupKeyCounts(postLookup) + const postNoteItemById = new Map() + for (const item of postLookup) { + if (item.type === 'note' && !postNoteItemById.has(item.id)) { + postNoteItemById.set(item.id, item) + } + } let rewrittenCount = 0 const now = Date.now() - for (const linker of notes) { - if (linker.isDeleted || !linker.content) { - continue + // Новый target детерминирован по id цели: карта собирается заранее, чтобы + // отложенный rewrite восстанавливался из сериализуемой спеки. + const targetsById: Record = {} + for (const noteId of affectedNoteIds) { + const targetItem = postNoteItemById.get(noteId) + if (targetItem) { + targetsById[String(noteId)] = pickShortestUniqueLinkTarget( + targetItem.name, + targetItem.folderPath ?? '', + postLookupKeyCounts, + ) } + } - const oldLinkerFolderPath - = linker.folderId === null - ? '' - : (oldFolderPathMap.get(linker.folderId) ?? '') + const deferredOp: DeferredBacklinkRewriteOp = { + linkKind: 'path', + linkerFolderPathByNoteId: {}, + pendingNoteIds: [], + preLookup, + targetsById, + } - const rewritten = rewriteInternalLinks(linker.content, (match) => { + const rewriteLinkerContent = ( + content: string, + oldLinkerFolderPath: string, + ): string | null => + rewriteInternalLinks(content, (match) => { if (match.legacyTarget) { return null } @@ -430,11 +662,9 @@ export function rewriteBacklinksAfterFolderUpdate( return null } - const resolved = resolveInternalLinkTargetByTitle( - match.target, - preLookup, - { linkerFolderPath: oldLinkerFolderPath }, - ) + const resolved = preResolver.resolve(match.target, { + linkerFolderPath: oldLinkerFolderPath, + }) if ( resolved === null || resolved.type !== 'note' @@ -443,21 +673,15 @@ export function rewriteBacklinksAfterFolderUpdate( return null } - const targetItem = postLookup.find( - item => item.type === 'note' && item.id === resolved.id, - ) + const targetItem = postNoteItemById.get(resolved.id) if (!targetItem) { return null } const newTarget = pickShortestUniqueLinkTarget( - { - folderPath: targetItem.folderPath ?? '', - id: targetItem.id, - name: targetItem.name, - type: 'note', - }, - shortestUniqueCandidates, + targetItem.name, + targetItem.folderPath ?? '', + postLookupKeyCounts, ) if (newTarget === match.target) { @@ -467,16 +691,49 @@ export function rewriteBacklinksAfterFolderUpdate( return newTarget }) + for (const linker of notes) { + if (linker.isDeleted) { + continue + } + + const oldLinkerFolderPath + = linker.folderId === null + ? '' + : (oldFolderPathMap.get(linker.folderId) ?? '') + + // Тело ещё в облаке — rewrite откладывается и применится при гидрации. + // content === null означает, что тело не догрузилось (файл стал + // placeholder после скана, флаг ещё не обновился): тоже откладываем. + if (linker.pendingCloudDownload || linker.content === null) { + addPendingLinkerToOp(deferredOp, linker.id, oldLinkerFolderPath) + continue + } + + if (!linker.content) { + continue + } + + const rewritten = rewriteLinkerContent(linker.content, oldLinkerFolderPath) + if (rewritten === null) { continue } linker.content = rewritten linker.updatedAt = now - writeNoteToFile(paths, linker) + + // Файл испарился между проверкой и записью (eviction): rewrite + // откладывается и применится при гидрации. + if (!writeNoteToFile(paths, linker, { skipIfUnavailable: true })) { + addPendingLinkerToOp(deferredOp, linker.id, oldLinkerFolderPath) + continue + } + rewrittenCount++ } + queueDeferredBacklinkRewriteOp(state, deferredOp) + if (rewrittenCount > 0) { invalidateNotesSearchIndex(state) } diff --git a/src/main/storage/providers/markdown/notes/runtime/constants.ts b/src/main/storage/providers/markdown/notes/runtime/constants.ts index 0fdd32ddd..f4668cb5e 100644 --- a/src/main/storage/providers/markdown/notes/runtime/constants.ts +++ b/src/main/storage/providers/markdown/notes/runtime/constants.ts @@ -1,4 +1,4 @@ -import type { NotesRuntimeCache } from './types' +import type { NotesPaths, NotesRuntimeCache } from './types' import path from 'node:path' import fs from 'fs-extra' import { @@ -16,6 +16,9 @@ export { INBOX_DIR_NAME, META_DIR_NAME, META_FILE_NAME, TRASH_DIR_NAME } export const NOTES_INBOX_RELATIVE_PATH = `${META_DIR_NAME}/${INBOX_DIR_NAME}` export const NOTES_TRASH_RELATIVE_PATH = `${META_DIR_NAME}/${TRASH_DIR_NAME}` +export const NOTES_ASSETS_DIR_NAME = 'assets' +export const NOTES_ASSETS_RELATIVE_PATH = `${META_DIR_NAME}/${NOTES_ASSETS_DIR_NAME}` +export const NOTES_LEGACY_ASSETS_RELATIVE_PATH = NOTES_ASSETS_DIR_NAME export const NOTES_RESERVED_ROOT_NAMES = new Set([ INBOX_DIR_NAME, @@ -58,18 +61,37 @@ function migrateNestedNotesSpace(vaultPath: string, notesRoot: string): void { fs.removeSync(legacyNotesRoot) } -export function getNotesPaths(vaultPath: string) { +// Legacy layout migration checks run fs calls on every invocation, so +// resolved paths are memoized per vault path. The cache is reset on vault +// re-watch (stopMarkdownWatcher) and via resetNotesPathsCache(). +const notesPathsCacheByVaultPath = new Map() + +export function getNotesPaths(vaultPath: string): NotesPaths { + const cachedPaths = notesPathsCacheByVaultPath.get(vaultPath) + if (cachedPaths) { + return cachedPaths + } + const notesRoot = getSpaceDirPath(vaultPath, NOTES_SPACE_ID) migrateNestedNotesSpace(vaultPath, notesRoot) const metaDirPath = path.join(notesRoot, META_DIR_NAME) - return { + const notesPaths: NotesPaths = { + assetsPath: path.join(metaDirPath, NOTES_ASSETS_DIR_NAME), inboxDirPath: path.join(metaDirPath, INBOX_DIR_NAME), + legacyAssetsPath: path.join(notesRoot, NOTES_ASSETS_DIR_NAME), metaDirPath, notesRoot, statePath: path.join(metaDirPath, 'state.json'), trashDirPath: path.join(metaDirPath, TRASH_DIR_NAME), } + + notesPathsCacheByVaultPath.set(vaultPath, notesPaths) + return notesPaths +} + +export function resetNotesPathsCache(): void { + notesPathsCacheByVaultPath.clear() } export function peekNotesRuntimeCache(): NotesRuntimeCache | null { diff --git a/src/main/storage/providers/markdown/notes/runtime/graph.ts b/src/main/storage/providers/markdown/notes/runtime/graph.ts index c54cb0007..ed8f9fa7f 100644 --- a/src/main/storage/providers/markdown/notes/runtime/graph.ts +++ b/src/main/storage/providers/markdown/notes/runtime/graph.ts @@ -1,8 +1,13 @@ -import type { MarkdownNote } from './types' -import { - findInternalLinks, - resolveInternalLinkTargetByTitle, -} from '../../../../../../shared/notes/internalLinks' +import { findInternalLinks } from '../../../../../../shared/notes/internalLinks' +import { createInternalLinkResolver } from './internalLinkResolver' + +export interface NotesGraphNoteLookup { + id: number + name: string + content: string + folderId: number | null + tags: number[] +} export interface NotesGraphSnippetLookup { id: number @@ -28,7 +33,7 @@ export interface NotesGraphData { } interface BuildNotesGraphInput { - notes: MarkdownNote[] + notes: NotesGraphNoteLookup[] snippets: NotesGraphSnippetLookup[] } @@ -37,7 +42,7 @@ export function buildNotesGraph(input: BuildNotesGraphInput): NotesGraphData { const incomingCounts = new Map() const edgeKeys = new Set() const edges: NotesGraphEdge[] = [] - const linkLookup = [ + const linkResolver = createInternalLinkResolver([ ...input.snippets.map(snippet => ({ id: snippet.id, name: snippet.name, @@ -48,7 +53,7 @@ export function buildNotesGraph(input: BuildNotesGraphInput): NotesGraphData { name: note.name, type: 'note' as const, })), - ] + ]) for (const note of input.notes) { const links = findInternalLinks(note.content) @@ -66,10 +71,7 @@ export function buildNotesGraph(input: BuildNotesGraphInput): NotesGraphData { : null } else { - const resolvedTarget = resolveInternalLinkTargetByTitle( - link.target, - linkLookup, - ) + const resolvedTarget = linkResolver.resolve(link.target) if (!resolvedTarget || resolvedTarget.type !== 'note') { continue diff --git a/src/main/storage/providers/markdown/notes/runtime/index.ts b/src/main/storage/providers/markdown/notes/runtime/index.ts index 983214195..12137b616 100644 --- a/src/main/storage/providers/markdown/notes/runtime/index.ts +++ b/src/main/storage/providers/markdown/notes/runtime/index.ts @@ -1,3 +1,15 @@ +export * from './assets' +export * from './assetsInspection' +export { + cancelNotesAssetsMigration, + discoverNotesAssetReferences, + runNotesAssetsMigration, + scheduleNotesAssetsMigration, +} from './assetsMigration' +export type { + NotesAssetReferenceDiscovery, + NotesAssetsMigrationSummary, +} from './assetsMigration' export * from './constants' export * from './notes' export * from './parser' diff --git a/src/main/storage/providers/markdown/notes/runtime/internalLinkResolver.ts b/src/main/storage/providers/markdown/notes/runtime/internalLinkResolver.ts new file mode 100644 index 000000000..1636256ac --- /dev/null +++ b/src/main/storage/providers/markdown/notes/runtime/internalLinkResolver.ts @@ -0,0 +1,164 @@ +import type { + InternalLinkLookupItem, + InternalLinkType, +} from '../../../../../../shared/notes/internalLinks' +import { + normalizeInternalLinkLookupKey, + splitInternalLinkTarget, +} from '../../../../../../shared/notes/internalLinks' + +interface ResolvedInternalLinkTarget { + id: number + type: InternalLinkType +} + +interface NoteTitleCandidate { + folderPath: string + id: number +} + +export interface InternalLinkResolver { + resolve: ( + target: string, + options?: { linkerFolderPath?: string }, + ) => ResolvedInternalLinkTarget | null +} + +function normalizeFolderPath(folderPath: string | undefined): string { + if (!folderPath) { + return '' + } + + return folderPath + .split('/') + .map(segment => segment.trim()) + .filter(segment => segment.length > 0) + .join('/') +} + +function buildFolderAncestorWalk(folderPath: string): string[] { + const normalized = normalizeFolderPath(folderPath) + + if (!normalized) { + return [''] + } + + const segments = normalized.split('/') + const ancestors: string[] = [] + + for (let index = segments.length; index >= 0; index -= 1) { + ancestors.push(segments.slice(0, index).join('/')) + } + + return ancestors +} + +function buildPathLookupKey(pathSegments: string[], basename: string): string { + return [...pathSegments, basename] + .map(segment => normalizeInternalLinkLookupKey(segment)) + .join('/') +} + +/** + * Index-backed equivalent of `resolveInternalLinkTargetByTitle`: builds + * lookup maps once so repeated resolution over the same items is O(1) + * instead of a linear scan per link. + */ +export function createInternalLinkResolver( + items: InternalLinkLookupItem[], +): InternalLinkResolver { + const pathTargetByKey = new Map() + const snippetByTitle = new Map() + const httpRequestByTitle = new Map() + const noteCandidatesByTitle = new Map() + + for (const item of items) { + const titleKey = normalizeInternalLinkLookupKey(item.name) + const target: ResolvedInternalLinkTarget = { id: item.id, type: item.type } + + if (item.type === 'note' || item.type === 'http-request') { + const folderSegments = normalizeFolderPath(item.folderPath) + .split('/') + .filter(segment => segment.length > 0) + const pathKey = buildPathLookupKey(folderSegments, item.name) + + if (!pathTargetByKey.has(pathKey)) { + pathTargetByKey.set(pathKey, target) + } + } + + if (item.type === 'snippet') { + if (!snippetByTitle.has(titleKey)) { + snippetByTitle.set(titleKey, target) + } + continue + } + + if (item.type === 'http-request') { + if (!httpRequestByTitle.has(titleKey)) { + httpRequestByTitle.set(titleKey, target) + } + continue + } + + let candidates = noteCandidatesByTitle.get(titleKey) + if (!candidates) { + candidates = [] + noteCandidatesByTitle.set(titleKey, candidates) + } + + candidates.push({ + folderPath: normalizeFolderPath(item.folderPath), + id: item.id, + }) + } + + return { + resolve(target, options) { + const { basename, pathSegments } = splitInternalLinkTarget(target) + + if (!basename) { + return null + } + + if (pathSegments.length > 0) { + return ( + pathTargetByKey.get(buildPathLookupKey(pathSegments, basename)) + ?? null + ) + } + + const titleKey = normalizeInternalLinkLookupKey(basename) + + const snippet = snippetByTitle.get(titleKey) + if (snippet) { + return snippet + } + + const noteCandidates = noteCandidatesByTitle.get(titleKey) + if (!noteCandidates || noteCandidates.length === 0) { + return httpRequestByTitle.get(titleKey) ?? null + } + + if (noteCandidates.length === 1) { + return { id: noteCandidates[0].id, type: 'note' } + } + + const ancestors = buildFolderAncestorWalk( + options?.linkerFolderPath ?? '', + ) + + for (const ancestorPath of ancestors) { + const match = noteCandidates.find( + candidate => candidate.folderPath === ancestorPath, + ) + + if (match) { + return { id: match.id, type: 'note' } + } + } + + return { id: noteCandidates[0].id, type: 'note' } + }, + } +} diff --git a/src/main/storage/providers/markdown/notes/runtime/notes.ts b/src/main/storage/providers/markdown/notes/runtime/notes.ts index be3f4494a..2e4985f11 100644 --- a/src/main/storage/providers/markdown/notes/runtime/notes.ts +++ b/src/main/storage/providers/markdown/notes/runtime/notes.ts @@ -1,20 +1,35 @@ +import type { FileAvailability } from '../../runtime/shared/cloudFiles' import type { MarkdownNote, + NoteProperties, NotesFrontmatter, NotesIndexItem, + NotesIndexMetadata, NotesPaths, NotesState, PersistNoteOptions, } from './types' +import { Buffer } from 'node:buffer' import path from 'node:path' import fs from 'fs-extra' import yaml from 'js-yaml' +import { log } from '../../../../../utils' +import { enqueueCloudDownload } from '../../cloudDownloads' import { normalizeFlag } from '../../runtime/normalizers' import { splitFrontmatter } from '../../runtime/parser' +import { rememberAppFileChange } from '../../runtime/shared/appChanges' +import { getFileAvailability } from '../../runtime/shared/cloudFiles' +import { throwCloudContentUnavailable } from '../../runtime/shared/cloudGuards' +import { + getCachedDirectoryEntries, + removeDirectoryEntryFromCache, + upsertDirectoryEntryInCache, +} from '../../runtime/shared/directoryEntries' import { listMarkdownFiles as listMarkdownFilesShared, toPosixPath, } from '../../runtime/shared/path' +import { invalidateSearchIndex } from '../../runtime/shared/searchEngine' import { getFileTimestampFallbacks, normalizeTimestamp, @@ -30,6 +45,209 @@ import { } from './constants' import { buildNotesFolderPathMap, buildPathToNotesFolderIdMap } from './paths' +export const NOTE_SYSTEM_FRONTMATTER_KEYS = new Set([ + 'createdAt', + 'description', + 'folderId', + 'id', + 'isDeleted', + 'isFavorites', + 'name', + 'tags', + 'updatedAt', +]) + +export function isNoteSystemFrontmatterKey(key: string): boolean { + return NOTE_SYSTEM_FRONTMATTER_KEYS.has(key) +} + +// Обычное присваивание target[key] для ключа '__proto__' не создаёт +// собственное свойство (а с объектным значением мутирует прототип), и такой +// YAML-ключ молча терялся бы: значение задаётся через defineProperty. +function setOwnProperty( + target: Record, + key: string, + value: unknown, +): void { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) +} + +// js-yaml нативно round-trip'ит Date, NaN, Infinity и !!binary через +// serializeNote, поэтому runtime хранит значения frontmatter как есть. +// Единственное исключение — рекурсивные YAML alias'ы: циклический объект +// валит и yaml.dump (noRefs), и JSON-персист metadata-индекса (а с ним весь +// reconcile — Notes остаётся пустым), поэтому циклические ссылки +// разрываются (заменяются на null) прямо при чтении. +function breakPropertyCycles( + value: unknown, + ancestors: WeakSet, +): unknown { + if (value === null || typeof value !== 'object') { + return value + } + + if (value instanceof Date || ArrayBuffer.isView(value)) { + return value + } + + if (ancestors.has(value)) { + return null + } + + ancestors.add(value) + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + value[index] = breakPropertyCycles(value[index], ancestors) + } + } + else { + const record = value as Record + for (const key of Object.keys(record)) { + record[key] = breakPropertyCycles(record[key], ancestors) + } + } + + ancestors.delete(value) + + return value +} + +function extractNoteProperties(frontmatter: NotesFrontmatter): NoteProperties { + const properties: NoteProperties = {} + const ancestors = new WeakSet() + + for (const [key, value] of Object.entries(frontmatter)) { + if (NOTE_SYSTEM_FRONTMATTER_KEYS.has(key)) { + continue + } + + setOwnProperty(properties, key, breakPropertyCycles(value, ancestors)) + } + + return properties +} + +// Метаданные индекса персистятся JSON'ом, который теряет YAML-типы: Date +// стал бы строкой, NaN — null, Uint8Array — объектом с числовыми ключами, +// и при записи lazy-заметки эти искажения попали бы обратно во frontmatter. +// Поэтому properties кодируются обратимыми тегами при записи в индекс и +// декодируются при восстановлении записи из него. +const INDEX_TYPE_TAG = '$mcType' + +function encodeIndexPropertyValue(value: unknown): unknown { + if (value instanceof Date) { + return { [INDEX_TYPE_TAG]: 'date', value: value.toISOString() } + } + + if (typeof value === 'number' && !Number.isFinite(value)) { + return { [INDEX_TYPE_TAG]: 'number', value: String(value) } + } + + if (ArrayBuffer.isView(value)) { + return { + [INDEX_TYPE_TAG]: 'binary', + value: Buffer.from( + value.buffer, + value.byteOffset, + value.byteLength, + ).toString('base64'), + } + } + + if (Array.isArray(value)) { + return value.map(encodeIndexPropertyValue) + } + + if (value && typeof value === 'object') { + const record = value as Record + const encoded: Record = {} + for (const [key, item] of Object.entries(record)) { + setOwnProperty(encoded, key, encodeIndexPropertyValue(item)) + } + + // Пользовательский объект с собственным ключом $mcType не должен + // распаковаться при декодировании как служебный тег. + return INDEX_TYPE_TAG in record + ? { [INDEX_TYPE_TAG]: 'literal', value: encoded } + : encoded + } + + return value +} + +function decodeIndexPropertyValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(decodeIndexPropertyValue) + } + + if (value && typeof value === 'object') { + const record = value as Record + const tag = record[INDEX_TYPE_TAG] + + if (tag === 'date' && typeof record.value === 'string') { + return new Date(record.value) + } + + if (tag === 'number' && typeof record.value === 'string') { + return Number(record.value) + } + + if (tag === 'binary' && typeof record.value === 'string') { + return new Uint8Array(Buffer.from(record.value, 'base64')) + } + + if ( + tag === 'literal' + && record.value + && typeof record.value === 'object' + && !Array.isArray(record.value) + ) { + // Дети декодируются, но собственный тег объекта не интерпретируется: + // это пользовательский $mcType, а не служебный. + const inner = record.value as Record + const decoded: Record = {} + for (const [key, item] of Object.entries(inner)) { + setOwnProperty(decoded, key, decodeIndexPropertyValue(item)) + } + return decoded + } + + const decoded: Record = {} + for (const [key, item] of Object.entries(record)) { + setOwnProperty(decoded, key, decodeIndexPropertyValue(item)) + } + return decoded + } + + return value +} + +export function encodeIndexProperties( + properties: NoteProperties, +): NoteProperties { + const encoded: NoteProperties = {} + for (const [key, value] of Object.entries(properties)) { + setOwnProperty(encoded, key, encodeIndexPropertyValue(value)) + } + return encoded +} + +export function decodeIndexProperties( + properties: NoteProperties, +): NoteProperties { + const decoded: NoteProperties = {} + for (const [key, value] of Object.entries(properties)) { + setOwnProperty(decoded, key, decodeIndexPropertyValue(value)) + } + return decoded +} + export function toNoteFileName(name: string): string { const normalized = validateEntryName(name, 'note') @@ -41,7 +259,7 @@ export function toNoteFileName(name: string): string { } export function serializeNote(note: MarkdownNote): string { - const frontmatter: NotesFrontmatter = { + const frontmatter: NotesFrontmatter & NoteProperties = { createdAt: note.createdAt, description: note.description, folderId: note.folderId, @@ -51,6 +269,7 @@ export function serializeNote(note: MarkdownNote): string { name: note.name, tags: note.tags, updatedAt: note.updatedAt, + ...note.properties, } const frontmatterText = yaml @@ -68,22 +287,247 @@ export function serializeNote(note: MarkdownNote): string { return `---\n${frontmatterText}\n---\n${note.content}` } +// Метаданные индекса собираются только по реально прочитанному файлу, а +// stat-сигнатура — по stat до чтения. Записи приложения не обновляют meta: +// изменённый mtime просто заставит перечитать файл на следующем старте. +export function buildNoteIndexMetadata( + note: MarkdownNote, + stats: { mtimeMs: number, size: number }, +): NotesIndexMetadata { + return { + createdAt: note.createdAt, + description: note.description, + folderId: note.folderId, + isDeleted: note.isDeleted, + isFavorites: note.isFavorites, + mtimeMs: stats.mtimeMs, + name: note.name, + properties: encodeIndexProperties(note.properties), + size: stats.size, + tags: [...note.tags], + updatedAt: note.updatedAt, + } +} + +// state.json синхронизируется между устройствами и правится извне, поэтому +// метаданные индекса перед использованием проверяются по форме: битая запись +// не роняет скан, файл просто перечитывается. +function isValidNoteIndexMetadata( + meta: NotesIndexMetadata | undefined, +): meta is NotesIndexMetadata { + return ( + !!meta + && typeof meta === 'object' + && typeof meta.name === 'string' + && Array.isArray(meta.tags) + && !!meta.properties + && typeof meta.properties === 'object' + && typeof meta.mtimeMs === 'number' + && Number.isFinite(meta.mtimeMs) + && typeof meta.size === 'number' + && Number.isFinite(meta.size) + && typeof meta.createdAt === 'number' + && typeof meta.updatedAt === 'number' + ) +} + +export function hasFreshNoteIndexMetadata( + entry: NotesIndexItem, + stats: { mtimeMs: number, size: number } | null, +): boolean { + return ( + !!stats + && isValidNoteIndexMetadata(entry.meta) + && entry.meta.mtimeMs === stats.mtimeMs + && entry.meta.size === stats.size + ) +} + +// Запись списка из метаданных индекса, без чтения файла: тело остаётся +// content: null и дочитывается лениво (ensureNoteContentLoaded). +export function buildNoteFromIndexMetadata( + entry: NotesIndexItem, + meta: NotesIndexMetadata, + options?: { pendingCloudDownload?: boolean }, +): MarkdownNote { + const isTrashed = entry.filePath.startsWith(`${NOTES_TRASH_RELATIVE_PATH}/`) + + return { + content: null, + createdAt: meta.createdAt, + description: meta.description ?? null, + filePath: entry.filePath, + // folderId в meta — как его разрешило последнее чтение (frontmatter + // приоритетнее пути, см. readNoteFromFile). + folderId: meta.folderId ?? null, + id: entry.id, + isDeleted: isTrashed ? 1 : normalizeFlag(meta.isDeleted), + isFavorites: normalizeFlag(meta.isFavorites), + name: meta.name, + ...(options?.pendingCloudDownload ? { pendingCloudDownload: true } : {}), + properties: decodeIndexProperties(meta.properties), + tags: meta.tags.filter(tagId => typeof tagId === 'number' && tagId > 0), + updatedAt: meta.updatedAt, + } +} + +// Дочитывает тело заметки, построенной из индекса. Заполняется только +// content === null: метаданные runtime-объекта авторитетны и могут содержать +// ещё не сохранённые правки. Возвращает false, если содержимое сейчас +// недоступно (плейсхолдер, сбой чтения). +export function ensureNoteContentLoaded( + paths: NotesPaths, + note: MarkdownNote, +): boolean { + if (note.pendingCloudDownload) { + return false + } + + if (note.content !== null) { + return true + } + + const absolutePath = path.join(paths.notesRoot, note.filePath) + const availability = getFileAvailability(absolutePath) + + if (!availability.exists) { + return false + } + + if (availability.isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + return false + } + + let source: string + try { + source = fs.readFileSync(absolutePath, 'utf8') + } + catch (error) { + log('storage:notes:load-note-content', error) + enqueueCloudDownload(absolutePath) + return false + } + + note.content = splitFrontmatter(source).body + + // Тело догружено после построения поискового индекса: индекс мог быть + // собран без тел, и body-запросы не находили заметку. Любая гидрация + // (открытие заметки, dashboard, graph, поиск) помечает индекс dirty. + const cache = notesRuntimeRef.cache + if (cache?.noteById.get(note.id) === note) { + invalidateSearchIndex(cache.searchIndex) + } + + return true +} + +// Дочитывает тела всех ленивых заметок: нужно потокам, которые читают +// content поперёк всего пространства (поиск, graph, dashboard, backlinks). +export function ensureAllNoteContentsLoaded( + paths: NotesPaths, + notes: MarkdownNote[], +): boolean { + let hasLoadedContent = false + + for (const note of notes) { + if ( + !note.pendingCloudDownload + && note.content === null + && ensureNoteContentLoaded(paths, note) + ) { + hasLoadedContent = true + } + } + + return hasLoadedContent +} + +export function buildPlaceholderNote( + entry: NotesIndexItem, + pathToFolderIdMap: Map, + timestampFallbacks: { createdAt: number, updatedAt: number }, +): MarkdownNote { + const dirPath = toPosixPath(path.posix.dirname(entry.filePath)) + const folderId + = dirPath && dirPath !== '.' && !dirPath.startsWith('.masscode') + ? (pathToFolderIdMap.get(dirPath) ?? null) + : null + + return { + content: '', + createdAt: timestampFallbacks.createdAt, + description: null, + filePath: entry.filePath, + folderId, + id: entry.id, + isDeleted: entry.filePath.startsWith(`${NOTES_TRASH_RELATIVE_PATH}/`) + ? 1 + : 0, + isFavorites: 0, + name: path.basename(entry.filePath, '.md'), + pendingCloudDownload: true, + properties: {}, + tags: [], + updatedAt: timestampFallbacks.updatedAt, + } +} + export function readNoteFromFile( paths: NotesPaths, entry: NotesIndexItem, pathToFolderIdMap: Map, + knownAvailability?: FileAvailability, ): MarkdownNote | null { const absolutePath = path.join(paths.notesRoot, entry.filePath) + // Горячий путь скана уже статил файл: повторный stat не нужен. + const availability = knownAvailability ?? getFileAvailability(absolutePath) - if (!fs.pathExistsSync(absolutePath)) { + if (!availability.exists) { return null } - const source = fs.readFileSync(absolutePath, 'utf8') + const readNow = Date.now() + const placeholderTimestamps = getFileTimestampFallbacks( + absolutePath, + readNow, + availability.stats, + ) + + let source: string | null = null + + if (!availability.isCloudPlaceholder) { + try { + source = fs.readFileSync(absolutePath, 'utf8') + } + catch (error) { + // Сорвавшееся чтение не валит весь скан: заметка обрабатывается как + // недокачанная. + log('storage:notes:read-note', error) + } + } + + // Плейсхолдер (или файл со сбоем чтения) не читается синхронно: заметка + // сразу видна в списке по данным индекса и имени файла, содержимое + // докачивается в фоне. + if (source === null) { + enqueueCloudDownload(absolutePath) + + return buildPlaceholderNote( + entry, + pathToFolderIdMap, + placeholderTimestamps, + ) + } + const { body, frontmatter, hasFrontmatter } = splitFrontmatter(source) const fm = frontmatter as NotesFrontmatter const now = Date.now() - const timestampFallbacks = getFileTimestampFallbacks(absolutePath, now) + const timestampFallbacks = getFileTimestampFallbacks( + absolutePath, + now, + availability.stats, + ) // Infer folderId from file path if not in frontmatter let folderId: number | null = fm.folderId ?? null @@ -104,6 +548,7 @@ export function readNoteFromFile( isDeleted: normalizeFlag(fm.isDeleted), isFavorites: normalizeFlag(fm.isFavorites), name: fm.name || path.basename(entry.filePath, '.md'), + properties: extractNoteProperties(fm), tags: Array.isArray(fm.tags) ? fm.tags.filter(t => typeof t === 'number') : [], @@ -111,25 +556,60 @@ export function readNoteFromFile( } if (!hasFrontmatter) { - writeNoteToFile(paths, note) + writeNoteToFile(paths, note, { skipIfUnavailable: true }) } return note } -export function writeNoteToFile(paths: NotesPaths, note: MarkdownNote): void { +// Возвращает false, только если запись пропущена из-за недокачанного файла +// (возможно лишь при skipIfUnavailable): вызывающий может переотложить +// операцию вместо потери правки. +export function writeNoteToFile( + paths: NotesPaths, + note: MarkdownNote, + options?: { skipIfUnavailable?: boolean }, +): boolean { const absolutePath = path.join(paths.notesRoot, note.filePath) + + // Запись в плейсхолдер уничтожила бы ещё не скачанное облачное + // содержимое, поэтому она запрещена: файл сначала докачивается в фоне. + // По умолчанию сбой поднимается наверх: тихий пропуск означал бы «принятую» + // правку, которую докачка затем молча перезапишет облачным содержимым. + // Пропуск допустим только там, где запись — необязательный write-back + // (scan, move, bulk-очистка тегов) или переоткладываемый rewrite ссылок. + const availability = getFileAvailability(absolutePath) + if (note.pendingCloudDownload || availability.isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + if (options?.skipIfUnavailable) { + return false + } + throwCloudContentUnavailable() + } + + // Ленивая запись (тело ещё не дочитано из индекса): content дочитывается + // с диска перед сериализацией, иначе запись затёрла бы тело пустым. + // Тихий пропуск записи потерял бы правку метаданных при следующем скане, + // поэтому сбой поднимается наверх. В scan-путях (write-back после чтения) + // заметка уже прочитана и ветка недостижима. + if (!ensureNoteContentLoaded(paths, note)) { + throwCloudContentUnavailable() + } + const nextContent = serializeNote(note) - if (fs.pathExistsSync(absolutePath)) { + if (availability.exists) { const currentContent = fs.readFileSync(absolutePath, 'utf8') if (currentContent === nextContent) { - return + return true } } fs.ensureDirSync(path.dirname(absolutePath)) fs.writeFileSync(absolutePath, nextContent, 'utf8') + rememberAppFileChange(absolutePath) + + return true } export function loadNotes( @@ -140,16 +620,64 @@ export function loadNotes( const notes: MarkdownNote[] = [] for (const entry of state.notes) { - const note = readNoteFromFile(paths, entry, pathToFolderIdMap) - if (note) { - notes.push(note) + const absolutePath = path.join(paths.notesRoot, entry.filePath) + const availability = getFileAvailability(absolutePath) + + if (!availability.exists) { + continue } + + // Свежая stat-сигнатура: запись строится из индекса без чтения файла, + // тело дочитывается лениво по первому обращению. + if ( + !availability.isCloudPlaceholder + && hasFreshNoteIndexMetadata(entry, availability.stats) + ) { + notes.push(buildNoteFromIndexMetadata(entry, entry.meta!)) + continue + } + + // Плейсхолдер с известными метаданными: полноценная запись списка без + // чтения (и без сетевой материализации), контент докачивается в фоне. + if ( + availability.isCloudPlaceholder + && isValidNoteIndexMetadata(entry.meta) + ) { + enqueueCloudDownload(absolutePath) + notes.push( + buildNoteFromIndexMetadata(entry, entry.meta, { + pendingCloudDownload: true, + }), + ) + continue + } + + const note = readNoteFromFile( + paths, + entry, + pathToFolderIdMap, + availability, + ) + if (!note) { + continue + } + + // Индекс обновляется только по реально прочитанному файлу. + if (!note.pendingCloudDownload && availability.stats) { + entry.meta = buildNoteIndexMetadata(note, availability.stats) + } + + notes.push(note) } return notes } -function getNoteTargetDirectory(state: NotesState, note: MarkdownNote): string { +function getNoteTargetDirectory( + state: NotesState, + note: MarkdownNote, + folderPathMap?: Map, +): string { if (note.isDeleted) { return NOTES_TRASH_RELATIVE_PATH } @@ -158,8 +686,8 @@ function getNoteTargetDirectory(state: NotesState, note: MarkdownNote): string { return NOTES_INBOX_RELATIVE_PATH } - const folderPathMap = buildNotesFolderPathMap(state) - const folderPath = folderPathMap.get(note.folderId) + const resolvedFolderPathMap = folderPathMap ?? buildNotesFolderPathMap(state) + const folderPath = resolvedFolderPathMap.get(note.folderId) return folderPath || NOTES_INBOX_RELATIVE_PATH } @@ -167,31 +695,48 @@ function getNoteTargetDirectory(state: NotesState, note: MarkdownNote): string { export function buildNoteTargetPath( state: NotesState, note: MarkdownNote, + folderPathMap?: Map, ): string { - const directory = getNoteTargetDirectory(state, note) + const directory = getNoteTargetDirectory(state, note, folderPathMap) const fileName = toNoteFileName(note.name) return path.posix.join(directory, fileName) } function getUniqueNotePath( paths: NotesPaths, - state: NotesState, targetPath: string, currentFilePath: string | null, + directoryEntriesCache?: Map, ): string { const targetAbsolutePath = path.join(paths.notesRoot, targetPath) + const targetDirectory = path.dirname(targetAbsolutePath) + const targetFileName = path.basename(targetAbsolutePath) + const currentAbsolutePath = currentFilePath + ? path.join(paths.notesRoot, currentFilePath) + : null - if (!fs.pathExistsSync(targetAbsolutePath)) { - return targetPath - } + fs.ensureDirSync(targetDirectory) - if (currentFilePath) { - const currentAbsolutePath = path.join(paths.notesRoot, currentFilePath) - if ( - targetAbsolutePath.toLowerCase() === currentAbsolutePath.toLowerCase() - ) { - return targetPath - } + const entries = getCachedDirectoryEntries( + targetDirectory, + directoryEntriesCache, + ) + const hasCaseInsensitiveConflict = (candidateFileName: string): boolean => + entries.some((entry) => { + const entryAbsolutePath = path.join(targetDirectory, entry) + + if ( + currentAbsolutePath + && entryAbsolutePath.toLowerCase() === currentAbsolutePath.toLowerCase() + ) { + return false + } + + return entry.toLowerCase() === candidateFileName.toLowerCase() + }) + + if (!hasCaseInsensitiveConflict(targetFileName)) { + return targetPath } const dir = path.posix.dirname(targetPath) @@ -199,11 +744,10 @@ function getUniqueNotePath( const baseName = path.posix.basename(targetPath, ext) for (let suffix = 1; suffix <= 10_000; suffix += 1) { - const candidatePath = path.posix.join(dir, `${baseName} ${suffix}${ext}`) - const candidateAbsolutePath = path.join(paths.notesRoot, candidatePath) + const candidateFileName = `${baseName} ${suffix}${ext}` - if (!fs.pathExistsSync(candidateAbsolutePath)) { - return candidatePath + if (!hasCaseInsensitiveConflict(candidateFileName)) { + return path.posix.join(dir, candidateFileName) } } @@ -217,12 +761,18 @@ export function persistNote( previousFilePath?: string, options?: PersistNoteOptions, ): void { - const targetPath = buildNoteTargetPath(state, note) + const targetPath = buildNoteTargetPath(state, note, options?.folderPathMap) const currentFilePath = previousFilePath || note.filePath + const directoryEntriesCache = options?.directoryEntriesCache let resolvedPath: string if (options?.allowRenameOnConflict) { - resolvedPath = getUniqueNotePath(paths, state, targetPath, currentFilePath) + resolvedPath = getUniqueNotePath( + paths, + targetPath, + currentFilePath, + directoryEntriesCache, + ) if (resolvedPath !== targetPath) { note.name = path.posix.basename(resolvedPath, '.md') } @@ -231,15 +781,23 @@ export function persistNote( resolvedPath = targetPath } + const resolvedAbsolutePath = path.join(paths.notesRoot, resolvedPath) + // Move file if path changed if (currentFilePath && currentFilePath !== resolvedPath) { const currentAbsolutePath = path.join(paths.notesRoot, currentFilePath) if (fs.pathExistsSync(currentAbsolutePath)) { - const targetAbsolutePath = path.join(paths.notesRoot, resolvedPath) - fs.ensureDirSync(path.dirname(targetAbsolutePath)) - fs.moveSync(currentAbsolutePath, targetAbsolutePath, { + fs.ensureDirSync(path.dirname(resolvedAbsolutePath)) + fs.moveSync(currentAbsolutePath, resolvedAbsolutePath, { overwrite: false, }) + rememberAppFileChange(currentAbsolutePath) + rememberAppFileChange(resolvedAbsolutePath) + removeDirectoryEntryFromCache( + path.dirname(currentAbsolutePath), + path.basename(currentAbsolutePath), + directoryEntriesCache, + ) } } @@ -255,7 +813,14 @@ export function persistNote( } // Write content - writeNoteToFile(paths, note) + writeNoteToFile(paths, note, { + skipIfUnavailable: options?.skipWriteIfUnavailable, + }) + upsertDirectoryEntryInCache( + path.dirname(resolvedAbsolutePath), + path.basename(resolvedAbsolutePath), + directoryEntriesCache, + ) } export function findNoteById( diff --git a/src/main/storage/providers/markdown/notes/runtime/parser.ts b/src/main/storage/providers/markdown/notes/runtime/parser.ts index 5152c9d86..75ce33464 100644 --- a/src/main/storage/providers/markdown/notes/runtime/parser.ts +++ b/src/main/storage/providers/markdown/notes/runtime/parser.ts @@ -6,7 +6,16 @@ import type { import path from 'node:path' import fs from 'fs-extra' import yaml from 'js-yaml' -import { readYamlObjectFile } from '../../runtime/shared/yaml' +import { + enqueueCloudDownload, + prioritizeCloudDownload, +} from '../../cloudDownloads' +import { rememberAppFileChange } from '../../runtime/shared/appChanges' +import { getFileAvailability } from '../../runtime/shared/cloudFiles' +import { + isYamlFileCloudUnavailable, + readYamlObjectFile, +} from '../../runtime/shared/yaml' import { META_FILE_NAME } from './constants' export function readNotesFolderMetadata( @@ -21,6 +30,14 @@ export function readNotesFolderMetadata( return metaData } + // Недокачанный .meta.yaml — не «метаданных нет»: id папки существует, но + // сейчас неизвестен. Сам файл (крошечный и критичный для стабильности id) + // поднимается в начало очереди докачки. + if (isYamlFileCloudUnavailable(metaPath)) { + prioritizeCloudDownload(metaPath) + return { unavailable: true } + } + return {} } @@ -55,8 +72,16 @@ export function writeNotesFolderMetadataFile( .trim() const nextContent = `${body}\n` + const availability = getFileAvailability(metaPath) + + // Запись в недокачанный .meta.yaml затёрла бы облачные метаданные папки + // (включая её id): файл сначала докачивается в фоне. + if (availability.isCloudPlaceholder) { + enqueueCloudDownload(metaPath) + return + } - if (fs.pathExistsSync(metaPath)) { + if (availability.exists) { const currentContent = fs.readFileSync(metaPath, 'utf8') if (currentContent === nextContent) { return @@ -65,4 +90,5 @@ export function writeNotesFolderMetadataFile( fs.ensureDirSync(folderAbsPath) fs.writeFileSync(metaPath, nextContent, 'utf8') + rememberAppFileChange(metaPath) } diff --git a/src/main/storage/providers/markdown/notes/runtime/search.ts b/src/main/storage/providers/markdown/notes/runtime/search.ts index 8c8073d0b..c05a213eb 100644 --- a/src/main/storage/providers/markdown/notes/runtime/search.ts +++ b/src/main/storage/providers/markdown/notes/runtime/search.ts @@ -5,6 +5,7 @@ import { querySearchIndex, } from '../../runtime/shared/searchEngine' import { notesRuntimeRef } from './constants' +import { ensureAllNoteContentsLoaded } from './notes' export function buildNoteSearchText(note: MarkdownNote): string { const parts: string[] = [note.name] @@ -24,6 +25,13 @@ export function getNoteIdsBySearchQuery( const cache = notesRuntimeRef.cache const runtimeCache = cache?.notes === notes ? cache : null + // Полнотекстовый поиск требует тел: ленивые записи (построенные из + // индекса без чтения файлов) дочитываются перед построением поискового + // индекса. Стоимость — одно чтение файла на запись при первом поиске. + if (runtimeCache && ensureAllNoteContentsLoaded(runtimeCache.paths, notes)) { + invalidateSearchIndex(runtimeCache.searchIndex) + } + if (runtimeCache && runtimeCache.searchIndex.dirty) { runtimeCache.searchIndex = buildSearchIndex(notes, buildNoteSearchText) } diff --git a/src/main/storage/providers/markdown/notes/runtime/state.ts b/src/main/storage/providers/markdown/notes/runtime/state.ts index 1d7587a2d..1872621e5 100644 --- a/src/main/storage/providers/markdown/notes/runtime/state.ts +++ b/src/main/storage/providers/markdown/notes/runtime/state.ts @@ -1,8 +1,14 @@ import type { NotesPaths, NotesState, NotesStateFile } from './types' import { createStateAdapter } from '../../runtime/shared/stateAdapter' -import { syncFolderUiWithFolders } from '../../runtime/shared/stateUtils' +import { + syncFolderIdByPathWithFolders, + syncFolderUiWithFolders, +} from '../../runtime/shared/stateUtils' import { invalidateNotesSearchIndex } from './search' +// Версия 2: записи notes несут денормализованные метаданные списка и +// stat-сигнатуру (`meta`). Записи без meta (v1) дозаполняются организно: +// файл читается один раз при первом скане и метаданные попадают в индекс. export function createDefaultNotesState(): NotesState { return { counters: { @@ -14,13 +20,13 @@ export function createDefaultNotesState(): NotesState { folders: [], notes: [], tags: [], - version: 1, + version: 2, } } const adapter = createStateAdapter({ createDefaultState: createDefaultNotesState, - minVersion: 1, + minVersion: 2, getDirs: paths => [ paths.notesRoot, paths.metaDirPath, @@ -29,8 +35,24 @@ const adapter = createStateAdapter({ ], toPersistedState: state => ({ counters: state.counters, + // Очередь отложенных rewrite'ов [[ссылок]] переживает перезапуск: + // без персиста переименование цели оставило бы в ещё не докачанных + // линкерах старую ссылку навсегда. + ...(state.deferredBacklinkRewrites?.length + ? { deferredBacklinkRewrites: state.deferredBacklinkRewrites } + : {}), + // Fallback path → folder id для холодного старта с недокачанными + // .meta.yaml (см. syncFolderIdByPathWithFolders). + ...(state.folderIdByPath ? { folderIdByPath: state.folderIdByPath } : {}), folderUi: state.folderUi, - notes: state.notes, + // Записи индекса нормализуются до известной схемы: state.json + // синхронизируется между устройствами и не должен накапливать + // посторонние поля. + notes: state.notes.map(({ filePath, id, meta }) => ({ + filePath, + id, + ...(meta ? { meta } : {}), + })), tags: state.tags, version: state.version, }), @@ -39,6 +61,12 @@ const adapter = createStateAdapter({ return { counters: { ...defaults.counters, ...raw.counters }, + ...(Array.isArray(raw.deferredBacklinkRewrites) + ? { deferredBacklinkRewrites: raw.deferredBacklinkRewrites } + : {}), + ...(raw.folderIdByPath && typeof raw.folderIdByPath === 'object' + ? { folderIdByPath: raw.folderIdByPath } + : {}), folderUi: (raw.folderUi ?? {}) as NotesState['folderUi'], folders: legacyFolders, notes: Array.isArray(raw.notes) ? raw.notes : [], @@ -48,6 +76,7 @@ const adapter = createStateAdapter({ }, onBeforeSave: (state) => { syncFolderUiWithFolders(state) + syncFolderIdByPathWithFolders(state) invalidateNotesSearchIndex(state) }, }) diff --git a/src/main/storage/providers/markdown/notes/runtime/sync.ts b/src/main/storage/providers/markdown/notes/runtime/sync.ts index 3ef167f40..8a660ef2c 100644 --- a/src/main/storage/providers/markdown/notes/runtime/sync.ts +++ b/src/main/storage/providers/markdown/notes/runtime/sync.ts @@ -2,6 +2,7 @@ import type { MarkdownNote, NotesFolderMetadataFile, NotesFolderRecord, + NotesIndexItem, NotesPaths, NotesRuntimeCache, NotesState, @@ -9,19 +10,42 @@ import type { import path from 'node:path' import fs from 'fs-extra' import yaml from 'js-yaml' +import { enqueueCloudDownload } from '../../cloudDownloads' +import { + getFileAvailability, + primeDatalessChecks, +} from '../../runtime/shared/cloudFiles' import { syncFolderMetadataFilesByPathMap, syncFoldersStateFromDiskAtRoot, } from '../../runtime/shared/folderSync' -import { notesRuntimeRef } from './constants' -import { listNoteMarkdownFiles, loadNotes } from './notes' +import { isCloudFileNotDownloadedError } from '../../runtime/shared/guardedRead' +import { normalizeDirectoryPath, toPosixPath } from '../../runtime/shared/path' +import { createVaultReconciler } from '../../runtime/shared/vaultReconcile' +import { + cancelNotesAssetsMigration, + scheduleNotesAssetsMigration, +} from './assetsMigration' +import { applyDeferredBacklinkRewrites } from './backlinks' +import { + NOTES_INBOX_RELATIVE_PATH, + NOTES_TRASH_RELATIVE_PATH, + notesRuntimeRef, +} from './constants' +import { + buildNoteIndexMetadata, + listNoteMarkdownFiles, + loadNotes, + readNoteFromFile, +} from './notes' import { readNotesFolderMetadata, writeNotesFolderMetadataFile, } from './parser' -import { buildNotesFolderPathMap } from './paths' +import { buildNotesFolderPathMap, buildPathToNotesFolderIdMap } from './paths' import { buildNoteSearchText, buildSearchIndex } from './search' import { + createDefaultNotesState, flushPendingNotesStateWrite, loadNotesState, saveNotesState, @@ -49,34 +73,66 @@ export function syncNotesFoldersWithDisk( }) } +// 'unreadable' означает, что содержимое файла сейчас недоступно (облачный +// плейсхолдер или сбой чтения): каллеры обязаны пропустить такой файл до +// фоновой докачки, а не чеканить для него новый id. +function readNoteIdFromFrontmatter( + absolutePath: string, +): number | null | 'unreadable' { + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + return 'unreadable' + } + + try { + const source = fs.readFileSync(absolutePath, 'utf8') + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) { + return null + } + + const fm = yaml.load(match[1]) as { id?: unknown } | null + return fm && typeof fm.id === 'number' && fm.id ? fm.id : null + } + catch { + return 'unreadable' + } +} + export function syncNotesWithDisk(paths: NotesPaths, state: NotesState): void { const mdFiles = listNoteMarkdownFiles(paths.notesRoot) - // Build existing path -> id map from state - const existingByPath = new Map() - state.notes.forEach(entry => existingByPath.set(entry.filePath, entry.id)) + // Один batch-вызов точной проверки dataless на весь список вместо + // отдельного системного вызова на каждый подозрительный файл. + primeDatalessChecks( + mdFiles.map(filePath => path.join(paths.notesRoot, filePath)), + ) + + // Build existing path -> entry map from state + const existingByPath = new Map() + state.notes.forEach(entry => existingByPath.set(entry.filePath, entry)) - const nextNotes: { id: number, filePath: string }[] = [] + const nextNotes: NotesIndexItem[] = [] const usedIds = new Set() for (const filePath of mdFiles) { - let noteId = existingByPath.get(filePath) + const existingEntry = existingByPath.get(filePath) + let noteId = existingEntry?.id if (!noteId || usedIds.has(noteId)) { - // Try reading frontmatter for id const absolutePath = path.join(paths.notesRoot, filePath) - try { - const source = fs.readFileSync(absolutePath, 'utf8') - const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (match) { - const fm = yaml.load(match[1]) as any - if (fm?.id && typeof fm.id === 'number' && !usedIds.has(fm.id)) { - noteId = fm.id - } - } + const frontmatterId = readNoteIdFromFrontmatter(absolutePath) + + // Неизвестный файл, содержимое которого сейчас недоступно (облачный + // плейсхолдер или сбой чтения): его frontmatter-id неизвестен, а + // чеканка нового id дала бы расходящиеся id после докачки. Файл + // появится в индексе после фоновой докачки. + if (frontmatterId === 'unreadable') { + enqueueCloudDownload(absolutePath) + continue } - catch { - // ignore + + if (frontmatterId && !usedIds.has(frontmatterId)) { + noteId = frontmatterId } } @@ -86,7 +142,19 @@ export function syncNotesWithDisk(paths: NotesPaths, state: NotesState): void { } usedIds.add(noteId) - nextNotes.push({ id: noteId, filePath }) + + // Метаданные индекса переносятся только если запись осталась той же + // (тот же путь и id): stat-сверка в loadNotes решит, актуальны ли они. + const carriedMeta + = existingEntry && existingEntry.id === noteId + ? existingEntry.meta + : undefined + + nextNotes.push({ + filePath, + id: noteId, + ...(carriedMeta ? { meta: carriedMeta } : {}), + }) } state.notes = nextNotes @@ -154,20 +222,142 @@ function setNotesRuntimeCache( return cache } -export function syncNotesRuntimeWithDisk(paths: NotesPaths): NotesRuntimeCache { +const notesVaultReconciler = createVaultReconciler('notes') + +// Полные обходы диска (например, Vault Doctor) допустимы только после +// фоновой сверки: до неё листинги каталогов могут блокироваться сетью. +export function isNotesVaultDiskReady(paths: NotesPaths): boolean { + return notesVaultReconciler.isReconciled(paths.notesRoot) +} + +// Пустой временный кэш на период фоновой сверки: см. комментарий у +// buildProvisionalRuntimeCache. Список из state-индекса тут не строится, +// чтобы клик по ещё не подтянутой из облака записи не давал 404. Сам state +// при этом читается с диска: мутации в этот период работают с настоящими +// счётчиками и тегами, а не чеканят id заново поверх существующего индекса. +function buildProvisionalNotesCache(paths: NotesPaths): NotesRuntimeCache { + if ( + notesRuntimeRef.cache + && notesRuntimeRef.cache.paths.notesRoot === paths.notesRoot + ) { + return notesRuntimeRef.cache + } + + // state.json сам может быть облачным плейсхолдером: тогда loadNotesState + // бросает, а кэш строится на неперсистируемом дефолтном state (флаг + // provisional блокирует запись и мутации до докачки). + let state: NotesState + try { + state = loadNotesState(paths) + } + catch (error) { + if (!isCloudFileNotDownloadedError(error)) { + throw error + } + + state = createDefaultNotesState() + state.provisional = true + } + + return setNotesRuntimeCache(paths, state, []) +} + +// Настоящая сверка с диском: может бросить, если .state.yaml сам недокачан +// из облака (reconciler ретраит по этой ошибке). +export interface SyncNotesRuntimeOptions { + scheduleAssetsMigration?: boolean +} + +function performFullNotesSync( + paths: NotesPaths, + options: SyncNotesRuntimeOptions, +): NotesRuntimeCache { flushPendingNotesStateWrite(paths) const state = loadNotesState(paths) syncNotesFoldersWithDisk(paths, state) syncNotesWithDisk(paths, state) syncNotesCounters(state) - - saveNotesState(paths, state, { immediate: true }) syncNotesFolderMetadataFiles(paths, state) + // State сохраняется после loadNotes: чтение файлов дозаполняет индекс + // метаданных, и он должен доехать до диска в этом же сохранении. const notes = loadNotes(paths, state) - return setNotesRuntimeCache(paths, state, notes) + // Заметки, гидрированные этим сканом, получают rewrite'ы ссылок, + // отложенные на время докачки. + for (const note of notes) { + applyDeferredBacklinkRewrites(paths, state, note) + } + + saveNotesState(paths, state, { immediate: true }) + + const cache = setNotesRuntimeCache(paths, state, notes) + if (options.scheduleAssetsMigration !== false) { + scheduleNotesAssetsMigration(cache) + } + return cache +} + +export function syncNotesRuntimeWithDisk( + paths: NotesPaths, + options: SyncNotesRuntimeOptions = {}, +): NotesRuntimeCache { + // Первый доступ к vault: обход диска опасен синхронно (листинги + // dataless-каталогов материализуются сетью), поэтому мгновенно отдаётся + // provisional-кэш, а настоящая сверка выполняется в фоне. + if (!notesVaultReconciler.isReconciled(paths.notesRoot)) { + const provisionalCache = buildProvisionalNotesCache(paths) + + notesVaultReconciler.begin(paths.notesRoot, () => { + if ( + notesRuntimeRef.cache + && notesRuntimeRef.cache.paths.notesRoot !== paths.notesRoot + ) { + return + } + + performFullNotesSync(paths, options) + }) + + return provisionalCache + } + + return performFullNotesSync(paths, options) +} + +// Перепроверка недокачанных заметок независимо от fs-событий: см. +// комментарий у refreshPendingSnippetFiles. +export function refreshPendingNoteFiles(paths: NotesPaths): { + changed: boolean + remaining: number +} { + const cache = notesRuntimeRef.cache + if (!cache || cache.paths.notesRoot !== paths.notesRoot) { + return { changed: false, remaining: 0 } + } + + const pendingFilePaths = cache.notes + .filter(note => note.pendingCloudDownload) + .map(note => note.filePath) + + let changed = false + for (const filePath of pendingFilePaths) { + const absolutePath = path.join(paths.notesRoot, filePath) + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + continue + } + + if (syncNoteFileWithDisk(paths, filePath)) { + changed = true + } + } + + const remaining + = notesRuntimeRef.cache?.notes.filter(note => note.pendingCloudDownload) + .length ?? 0 + + return { changed, remaining } } export function getNotesRuntimeCache(paths: NotesPaths): NotesRuntimeCache { @@ -182,5 +372,188 @@ export function getNotesRuntimeCache(paths: NotesPaths): NotesRuntimeCache { } export function resetNotesRuntimeCache(): void { + cancelNotesAssetsMigration() + // Смена vault: ретраи сверки брошенного корня останавливаются, иначе они + // продолжили бы попытки по неактивному пути и слали storage-synced. + const previousNotesRoot = notesRuntimeRef.cache?.paths.notesRoot + if (previousNotesRoot) { + notesVaultReconciler.abandon(previousNotesRoot) + } + // Отложенные backlink-rewrite'ы хранятся в notes state конкретного vault + // и потому переживают и сброс кэша, и перезапуск приложения. notesRuntimeRef.cache = null } + +function commitNotesRuntimeCache(cache: NotesRuntimeCache): NotesRuntimeCache { + // A new object identity signals watcher consumers that data changed, + // while built maps and the lazily rebuilt search index are reused. + notesRuntimeRef.cache = { ...cache } + scheduleNotesAssetsMigration(notesRuntimeRef.cache) + return notesRuntimeRef.cache +} + +function isTechnicalNotesDirectory(directoryPath: string): boolean { + return ( + directoryPath === NOTES_INBOX_RELATIVE_PATH + || directoryPath.startsWith(`${NOTES_INBOX_RELATIVE_PATH}/`) + || directoryPath === NOTES_TRASH_RELATIVE_PATH + || directoryPath.startsWith(`${NOTES_TRASH_RELATIVE_PATH}/`) + ) +} + +export function syncNoteFileWithDisk( + paths: NotesPaths, + changedFilePath: string, +): NotesRuntimeCache | null { + const cache = notesRuntimeRef.cache + if (!cache || cache.paths.notesRoot !== paths.notesRoot) { + return null + } + + // Provisional state (state.json ещё не докачан из облака) не может + // регистрировать файлы: id выдавались бы с дефолтных счётчиков. Событие + // не теряется — файл подберёт полная сверка после докачки. + if (cache.state.provisional) { + return cache + } + + const normalizedFilePath = toPosixPath(changedFilePath).trim() + if ( + !normalizedFilePath + || path.posix.extname(normalizedFilePath).toLowerCase() !== '.md' + ) { + return null + } + + const state = cache.state + const notes = cache.notes + const noteAbsolutePath = path.join(paths.notesRoot, normalizedFilePath) + const normalizedDirectory = normalizeDirectoryPath( + path.posix.dirname(normalizedFilePath), + ) + const pathToFolderIdMap = buildPathToNotesFolderIdMap(state) + + if ( + normalizedDirectory + && !isTechnicalNotesDirectory(normalizedDirectory) + && !pathToFolderIdMap.has(normalizedDirectory) + ) { + return null + } + + const normalizedFilePathKey = normalizedFilePath.toLowerCase() + const noteIndexInState = state.notes.findIndex( + entry => entry.filePath.toLowerCase() === normalizedFilePathKey, + ) + const noteExistsOnDisk = fs.pathExistsSync(noteAbsolutePath) + + if (!noteExistsOnDisk) { + if (noteIndexInState === -1) { + return cache + } + + const removedNoteId = state.notes[noteIndexInState].id + state.notes.splice(noteIndexInState, 1) + + const noteIndexInRuntime = notes.findIndex( + note => note.id === removedNoteId, + ) + if (noteIndexInRuntime !== -1) { + notes.splice(noteIndexInRuntime, 1) + } + cache.noteById.delete(removedNoteId) + + saveNotesState(paths, state) + return commitNotesRuntimeCache(cache) + } + + let noteIndexItem + = noteIndexInState !== -1 ? state.notes[noteIndexInState] : null + + if (!noteIndexItem) { + const frontmatterId = readNoteIdFromFrontmatter(noteAbsolutePath) + + // Неизвестный файл, содержимое которого сейчас недоступно (облачный + // плейсхолдер или сбой чтения): регистрировать его нельзя, иначе id + // был бы отчеканен вслепую и разошёлся бы с frontmatter-id после + // докачки. Файл появится в индексе после фоновой докачки. + if (frontmatterId === 'unreadable') { + enqueueCloudDownload(noteAbsolutePath) + return cache + } + + let noteId = frontmatterId + + // Внешнее перемещение (mv A.md → B.md) может прислать add нового пути + // раньше unlink старого: если frontmatter-id принадлежит записи, файла + // которой уже нет на диске, это та же заметка — перенацеливаем запись, + // сохраняя id, вместо аллокации нового. + if (noteId) { + const ownerEntry = state.notes.find(entry => entry.id === noteId) + + if ( + ownerEntry + && !fs.pathExistsSync(path.join(paths.notesRoot, ownerEntry.filePath)) + ) { + ownerEntry.filePath = normalizedFilePath + noteIndexItem = ownerEntry + } + } + + if (!noteIndexItem) { + const existingNoteIds = new Set( + state.notes.map(entry => entry.id), + ) + + if (!noteId || existingNoteIds.has(noteId)) { + state.counters.noteId += 1 + noteId = state.counters.noteId + } + + noteIndexItem = { filePath: normalizedFilePath, id: noteId } + state.notes.push(noteIndexItem) + } + } + else { + noteIndexItem.filePath = normalizedFilePath + } + + const syncedNote = readNoteFromFile(paths, noteIndexItem, pathToFolderIdMap) + if (!syncedNote) { + return null + } + + if (!syncedNote.pendingCloudDownload) { + // Заметка гидрирована: применяются rewrite'ы ссылок, отложенные на + // время докачки (rename/move, случившиеся, пока тело было в облаке). + applyDeferredBacklinkRewrites(paths, state, syncedNote) + + // Индекс метаданных обновляется по реально прочитанному файлу (stat + // берётся после возможной записи отложенного rewrite), чтобы следующий + // холодный старт собрал запись без чтения. + const syncedStats = getFileAvailability(noteAbsolutePath).stats + if (syncedStats) { + noteIndexItem.meta = buildNoteIndexMetadata(syncedNote, syncedStats) + } + } + + const noteIndexInRuntime = notes.findIndex( + note => note.id === syncedNote.id, + ) + if (noteIndexInRuntime === -1) { + notes.push(syncedNote) + } + else { + notes[noteIndexInRuntime] = syncedNote + } + cache.noteById.set(syncedNote.id, syncedNote) + + if (syncedNote.id > state.counters.noteId) { + state.counters.noteId = syncedNote.id + } + + // saveNotesState marks the notes search index dirty, so it is rebuilt + // lazily on the next search instead of eagerly on every file change. + saveNotesState(paths, state) + return commitNotesRuntimeCache(cache) +} diff --git a/src/main/storage/providers/markdown/notes/runtime/types.ts b/src/main/storage/providers/markdown/notes/runtime/types.ts index e526be465..7007e5609 100644 --- a/src/main/storage/providers/markdown/notes/runtime/types.ts +++ b/src/main/storage/providers/markdown/notes/runtime/types.ts @@ -1,7 +1,10 @@ +import type { InternalLinkLookupItem } from '../../../../../../shared/notes/internalLinks' import type { SearchIndex } from '../../runtime/shared/searchEngine' export interface NotesPaths { + assetsPath: string inboxDirPath: string + legacyAssetsPath: string metaDirPath: string notesRoot: string statePath: string @@ -15,9 +18,28 @@ export interface NotesTagState { updatedAt: number } +// Денормализованные метаданные списка в state.json (слой 4 плана +// icloud-lazy-vault-load): позволяют строить список и placeholder-записи без +// чтения файлов. mtimeMs/size — freshness-сигнатура последнего чтения: пока +// stat совпадает, файл не перечитывается. +export interface NotesIndexMetadata { + createdAt: number + description: string | null + folderId: number | null + isDeleted: number + isFavorites: number + mtimeMs: number + name: string + properties: NoteProperties + size: number + tags: number[] + updatedAt: number +} + export interface NotesIndexItem { filePath: string id: number + meta?: NotesIndexMetadata } export interface NotesFolderRecord { @@ -41,6 +63,8 @@ export interface NotesFolderMetadataFile { id?: number name?: string orderIndex?: number + // Файл метаданных недокачан из облака: содержимое (и id) неизвестно. + unavailable?: boolean updatedAt?: number } @@ -53,12 +77,28 @@ export interface NotesFolderUIState { isOpen: number } +// Отложенный rewrite [[ссылок]] для линкеров, чьё содержимое ещё не докачано +// из облака в момент rename/move. Хранится в state сериализуемой спекой +// (замыкание не пережило бы перезапуск): lookup имён до переименования и +// карта «id цели → новый target». Применяется при гидрации линкера. +export interface DeferredBacklinkRewriteOp { + // Ограничение на вид ссылки: bare — только [[имя]] без пути, + // path — только [[путь/имя]], any — обе. + linkKind: 'any' | 'bare' | 'path' + linkerFolderPathByNoteId: Record + pendingNoteIds: number[] + preLookup: InternalLinkLookupItem[] + targetsById: Record +} + export interface NotesStateFile { counters?: { folderId?: number noteId?: number tagId?: number } + deferredBacklinkRewrites?: DeferredBacklinkRewriteOp[] + folderIdByPath?: Record folderUi?: Record folders?: NotesFolderRecord[] notes?: NotesIndexItem[] @@ -72,9 +112,16 @@ export interface NotesState { noteId: number tagId: number } + deferredBacklinkRewrites?: DeferredBacklinkRewriteOp[] + // Персистируемый fallback path → folder id: без него недокачанный + // .meta.yaml чеканил бы папке новый id на каждом холодном старте. + folderIdByPath?: Record folderUi: Record folders: NotesFolderRecord[] notes: NotesIndexItem[] + // Дефолтный state на период, пока state.json не докачан из облака: + // такой state нельзя ни персистить, ни использовать для выдачи id. + provisional?: boolean tags: NotesTagState[] version: number } @@ -91,8 +138,12 @@ export interface NotesFrontmatter { updatedAt?: number } +export type NoteProperties = Record + export interface MarkdownNote { - content: string + // null — тело ещё не дочитано с диска (запись построена из индекса + // метаданных); дочитывается через ensureNoteContentLoaded. + content: string | null createdAt: number description: string | null filePath: string @@ -101,6 +152,12 @@ export interface MarkdownNote { isDeleted: number isFavorites: number name: string + /** + * Файл заметки — облачный плейсхолдер: содержимое ещё не скачано + * провайдером, запись показывается в списке и докачивается в фоне. + */ + pendingCloudDownload?: boolean + properties: NoteProperties tags: number[] updatedAt: number } @@ -117,4 +174,9 @@ export interface NotesRuntimeCache { export interface PersistNoteOptions { allowRenameOnConflict?: boolean directoryEntriesCache?: Map + folderPathMap?: Map + // Move-пути (перенос в trash при удалении папки): файл-плейсхолдер уже + // перемещён, а перезапись frontmatter не обязательна и не должна валить + // всю операцию. + skipWriteIfUnavailable?: boolean } diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts index 3eebc23c2..ff41f2df2 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts @@ -83,7 +83,9 @@ describe('folders storage validations', () => { const metaDirPath = path.join(notesRoot, '.masscode') ensureNotesStateFile({ + assetsPath: path.join(metaDirPath, 'assets'), inboxDirPath: path.join(metaDirPath, 'inbox'), + legacyAssetsPath: path.join(notesRoot, 'assets'), metaDirPath, notesRoot, statePath: path.join(metaDirPath, 'state.json'), diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts index efdd53b67..71bff3b1f 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts @@ -3,8 +3,13 @@ import path from 'node:path' import fs from 'fs-extra' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getNotesPaths } from '../../runtime/constants' import { ensureNotesStateFile } from '../../runtime/state' -import { resetNotesRuntimeCache } from '../../runtime/sync' +import { + getNotesRuntimeCache, + resetNotesRuntimeCache, + syncNoteFileWithDisk, +} from '../../runtime/sync' import { createNotesFoldersStorage } from '../folders' import { createNotesNotesStorage } from '../notes' @@ -83,7 +88,9 @@ describe('notes storage validations', () => { const metaDirPath = path.join(notesRoot, '.masscode') ensureNotesStateFile({ + assetsPath: path.join(metaDirPath, 'assets'), inboxDirPath: path.join(metaDirPath, 'inbox'), + legacyAssetsPath: path.join(notesRoot, 'assets'), metaDirPath, notesRoot, statePath: path.join(metaDirPath, 'state.json'), @@ -104,6 +111,98 @@ describe('notes storage validations', () => { expect(result).toEqual({ invalidInput: true, notFound: false }) }) + // Два ресинка: первый скан дозаполняет индекс метаданных, второй строит + // ленивые записи из индекса без чтения тел. + function resyncTwiceForLazyNotes() { + resetNotesRuntimeCache() + getNotesRuntimeCache(getNotesPaths(tempVaultPath)) + resetNotesRuntimeCache() + return getNotesRuntimeCache(getNotesPaths(tempVaultPath)) + } + + it('materializes lazy note content on getNoteById', () => { + const storage = createNotesNotesStorage() + const { id } = storage.createNote({ name: 'Lazy Read' }) + storage.updateNoteContent(id, 'lazy note body') + + const cache = resyncTwiceForLazyNotes() + const lazyNote = cache.notes.find(note => note.id === id) + expect(lazyNote?.content).toBeNull() + + const record = storage.getNoteById(id) + expect(record?.content).toBe('lazy note body') + }) + + it('finds lazy notes by body via search', () => { + const storage = createNotesNotesStorage() + const { id } = storage.createNote({ name: 'Search Target' }) + storage.updateNoteContent(id, 'needle-note-body') + + resyncTwiceForLazyNotes() + + const results = storage.getNotes({ search: 'needle-note-body' }) + expect(results.some(note => note.id === id)).toBe(true) + }) + + it('keeps note body intact when renaming a lazy note', () => { + const storage = createNotesNotesStorage() + const { id } = storage.createNote({ name: 'Lazy Rename' }) + storage.updateNoteContent(id, 'keep me') + + resyncTwiceForLazyNotes() + + // Переименование сериализует заметку целиком: незагруженное тело должно + // дочитаться, а не затереться пустым. + storage.updateNote(id, { name: 'Lazy Renamed' }) + + const record = storage.getNoteById(id) + expect(record?.content).toBe('keep me') + + const notesRootPath = getNotesPaths(tempVaultPath).notesRoot + const cache = getNotesRuntimeCache(getNotesPaths(tempVaultPath)) + const renamed = cache.notes.find(note => note.id === id) + const rawSource = fs.readFileSync( + path.join(notesRootPath, renamed!.filePath), + 'utf8', + ) + expect(rawSource).toContain('keep me') + }) + + it('applies deferred backlink rewrites after a pending note hydrates', () => { + const storage = createNotesNotesStorage() + const { id: targetId } = storage.createNote({ name: 'Target' }) + const { id: linkerId } = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linkerId, 'see [[Target]]') + + const paths = getNotesPaths(tempVaultPath) + const cache = getNotesRuntimeCache(paths) + const linker = cache.notes.find(note => note.id === linkerId)! + const linkerFilePath = linker.filePath + + // Имитация недокачанной заметки: тело в облаке на момент rename. + linker.pendingCloudDownload = true + + storage.updateNote(targetId, { name: 'Renamed Target' }) + + // Пока заметка pending, её файл не переписывается. + const rawBefore = fs.readFileSync( + path.join(paths.notesRoot, linkerFilePath), + 'utf8', + ) + expect(rawBefore).toContain('[[Target]]') + + // Гидрация: watcher-путь перечитывает файл и применяет отложенный + // rewrite. + syncNoteFileWithDisk(paths, linkerFilePath) + + const rawAfter = fs.readFileSync( + path.join(paths.notesRoot, linkerFilePath), + 'utf8', + ) + expect(rawAfter).toContain('[[Renamed Target]]') + expect(rawAfter).not.toContain('[[Target]]') + }) + it('createNote with bad folderId throws FOLDER_NOT_FOUND', () => { const storage = createNotesNotesStorage() expect(() => storage.createNote({ name: 'Test', folderId: 99999 })).toThrow( @@ -117,6 +216,26 @@ describe('notes storage validations', () => { expect(result.id).toBeGreaterThan(0) }) + it('createNote persists custom properties without allowing system frontmatter fields', () => { + const storage = createNotesNotesStorage() + const { id } = storage.createNote({ + name: 'Task Note', + properties: { + name: 'Ignored Name', + status: 'todo', + type: 'task', + }, + }) + + expect(storage.getNoteById(id)).toMatchObject({ + name: 'Task Note', + properties: { + status: 'todo', + type: 'task', + }, + }) + }) + it('getNoteById returns the stored note', () => { const storage = createNotesNotesStorage() const { id } = storage.createNote({ name: 'Lookup Note' }) @@ -133,6 +252,162 @@ describe('notes storage validations', () => { expect(storage.getNoteById(99999)).toBeNull() }) + it('updates note properties without allowing system frontmatter fields', () => { + const storage = createNotesNotesStorage() + const { id } = storage.createNote({ name: 'Property Note' }) + + const result = storage.updateNoteProperties(id, { + properties: { + name: 'Ignored Name', + priority: 'high', + status: 'todo', + type: 'task', + }, + }) + + expect(result).toEqual({ invalidInput: false, notFound: false }) + expect(storage.getNoteById(id)).toMatchObject({ + name: 'Property Note', + properties: { + priority: 'high', + status: 'todo', + type: 'task', + }, + }) + }) + + it('unsets note properties', () => { + const storage = createNotesNotesStorage() + const { id } = storage.createNote({ name: 'Unset Property Note' }) + + storage.updateNoteProperties(id, { + properties: { + status: 'todo', + type: 'task', + }, + }) + + const result = storage.updateNoteProperties(id, { + unset: ['status'], + }) + + expect(result).toEqual({ invalidInput: false, notFound: false }) + expect(storage.getNoteById(id)?.properties).toEqual({ + type: 'task', + }) + }) + + it('filters notes by task properties', () => { + vi.useFakeTimers() + + try { + vi.setSystemTime(new Date('2026-05-12T10:00:00.000Z')) + + const storage = createNotesNotesStorage() + const today = storage.createNote({ name: 'Today Task' }) + const upcoming = storage.createNote({ name: 'Upcoming Task' }) + const done = storage.createNote({ name: 'Done Task' }) + const regular = storage.createNote({ name: 'Regular Note' }) + + storage.updateNoteProperties(today.id, { + properties: { + due: '2026-05-12', + status: 'todo', + type: 'task', + }, + }) + storage.updateNoteProperties(upcoming.id, { + properties: { + due: '2026-05-20', + status: 'todo', + type: 'task', + }, + }) + storage.updateNoteProperties(done.id, { + properties: { + due: '2026-05-12', + status: 'done', + type: 'task', + }, + }) + + expect( + storage.getNotes({ propertyType: 'task' }).map(note => note.id), + ).toEqual(expect.arrayContaining([today.id, upcoming.id, done.id])) + expect( + storage + .getNotes({ + propertyDue: 'today', + propertyStatusNot: 'done', + propertyType: 'task', + }) + .map(note => note.id), + ).toEqual([today.id]) + expect( + storage + .getNotes({ + propertyDue: 'upcoming', + propertyStatusNot: 'done', + propertyType: 'task', + }) + .map(note => note.id), + ).toEqual([upcoming.id]) + expect( + storage + .getNotes({ + propertyStatus: 'done', + propertyType: 'task', + }) + .map(note => note.id), + ).toEqual([done.id]) + expect(storage.getNotes({}).map(note => note.id)).toContain(regular.id) + } + finally { + vi.useRealTimers() + } + }) + + it('filters date-only due dates by the local day', () => { + const previousTimeZone = process.env.TZ + process.env.TZ = 'America/Los_Angeles' + vi.useFakeTimers() + + try { + vi.setSystemTime(new Date(2026, 4, 12, 12)) + + const storage = createNotesNotesStorage() + const today = storage.createNote({ name: 'Local Today Task' }) + + storage.updateNoteProperties(today.id, { + properties: { + due: '2026-05-12', + status: 'todo', + type: 'task', + }, + }) + + expect( + storage + .getNotes({ + propertyDue: 'today', + propertyStatusNot: 'done', + propertyType: 'task', + }) + .map(note => note.id), + ).toEqual([today.id]) + } + finally { + vi.useRealTimers() + + if (previousTimeZone === undefined) { + delete process.env.TZ + } + else { + process.env.TZ = previousTimeZone + } + } + }) + it('can limit search to note names', () => { const storage = createNotesNotesStorage() const named = storage.createNote({ name: 'Compose Notes' }) @@ -239,6 +514,35 @@ describe('notes storage validations', () => { } }) + it('sorts notes by name and updated date', () => { + vi.useFakeTimers() + + try { + const storage = createNotesNotesStorage() + + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const bravo = storage.createNote({ name: 'Bravo' }) + + vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')) + const alpha = storage.createNote({ name: 'Alpha' }) + + vi.setSystemTime(new Date('2026-01-01T00:00:02.000Z')) + storage.updateNote(bravo.id, { description: 'Updated' }) + + expect( + storage.getNotes({ sort: 'name', order: 'ASC' }).map(note => note.id), + ).toEqual([alpha.id, bravo.id]) + expect( + storage + .getNotes({ sort: 'updatedAt', order: 'DESC' }) + .map(note => note.id), + ).toEqual([bravo.id, alpha.id]) + } + finally { + vi.useRealTimers() + } + }) + it('rewrites internal links in backlinking notes when a note is renamed', () => { const storage = createNotesNotesStorage() diff --git a/src/main/storage/providers/markdown/notes/storages/folders.ts b/src/main/storage/providers/markdown/notes/storages/folders.ts index e9793ac01..bd94f6e66 100644 --- a/src/main/storage/providers/markdown/notes/storages/folders.ts +++ b/src/main/storage/providers/markdown/notes/storages/folders.ts @@ -5,12 +5,18 @@ import type { NotesFoldersStorage, } from '../../../../contracts' import path from 'node:path' +import { scheduleDockBadgeRefresh } from '../../../../../dockBadge' import { normalizeFlag, normalizeNumber } from '../../runtime/normalizers' import { getVaultPath } from '../../runtime/paths' +import { + assertEntityFileWritable, + throwCloudContentUnavailable, +} from '../../runtime/shared/cloudGuards' import { collectDescendantIds } from '../../runtime/shared/folderIndex' import { applyFolderParentAndOrder, assertFolderMoveTargetValid, + assertNoUnknownDomainFiles, createFolderInStateAndDisk, getFolderPathsByDepth, getFoldersSortedByCreatedAt, @@ -34,7 +40,7 @@ import { META_DIR_NAME, NOTES_RESERVED_ROOT_NAMES, } from '../runtime/constants' -import { persistNote } from '../runtime/notes' +import { ensureNoteContentLoaded, persistNote } from '../runtime/notes' import { writeNotesFolderMetadataFile } from '../runtime/parser' import { buildNotesFolderPathMap, @@ -44,6 +50,7 @@ import { import { saveNotesState } from '../runtime/state' import { getNotesRuntimeCache, + isNotesVaultDiskReady, syncNotesFolderMetadataFiles, } from '../runtime/sync' @@ -135,6 +142,20 @@ export function createNotesFoldersStorage(): NotesFoldersStorage { input: NoteFolderUpdateInput, ): NoteFolderUpdateResult { const paths = resolvePaths() + + // Rename/move каталога до завершения фоновой сверки работал бы по + // пустому provisional-списку заметок: файлы переместились бы на диске, + // а index paths и backlinks остались бы старыми. + if ( + (input.name !== undefined || input.parentId !== undefined) + && !isNotesVaultDiskReady(paths) + ) { + throwStorageError( + 'VAULT_HYDRATING', + 'Vault is still syncing, folder rename or move is not available yet', + ) + } + const { state, notes } = getNotesRuntimeCache(paths) const folder = findNotesFolderById(state, id) @@ -258,6 +279,17 @@ export function createNotesFoldersStorage(): NotesFoldersStorage { deleteFolder(id: number) { const paths = resolvePaths() + + // До завершения фоновой сверки runtime-кэш provisional: список заметок + // пуст, перенос содержимого папки в trash ничего бы не нашёл, а + // removeFolderPathsFromDisk физически уничтожил бы файлы на диске. + if (!isNotesVaultDiskReady(paths)) { + throwStorageError( + 'VAULT_HYDRATING', + 'Vault is still syncing, folder deletion is not available yet', + ) + } + const { state, notes } = getNotesRuntimeCache(paths) const folder = findNotesFolderById(state, id) @@ -268,6 +300,42 @@ export function createNotesFoldersStorage(): NotesFoldersStorage { const descendantIds = collectDescendantIds(state.folders, id) descendantIds.add(id) + // Trash-маркер заметки — frontmatter isDeleted, а не путь: перенос + // недокачанного файла без перезаписи frontmatter «воскресил» бы заметку + // после докачки. Preflight выполняется до первой мутации в две фазы: + // сначала eager-дочитка всех тел, затем свежий stat всех файлов (флаг + // pendingCloudDownload мог устареть после eviction) — так окно между + // проверкой и мутацией не растягивается на гидрацию соседей. + const affectedNotes = notes.filter( + note => note.folderId !== null && descendantIds.has(note.folderId), + ) + for (const note of affectedNotes) { + if (!ensureNoteContentLoaded(paths, note)) { + throwCloudContentUnavailable() + } + } + + const folderPathMap = buildNotesFolderPathMap(state) + const directoryEntriesCache = new Map() + + // Доменный .md без записи в runtime (плейсхолдер с другого устройства, + // сбой чтения при скане) физически уничтожился бы вместе с каталогом: + // удаление отклоняется целиком. Обход стартует с корня удаляемой папки + // и идёт до финальной stat-фазы, чтобы не расширять окно до мутации. + const topFolderPath = folderPathMap.get(id) + assertNoUnknownDomainFiles( + paths.notesRoot, + topFolderPath ? [topFolderPath] : [], + new Set(affectedNotes.map(note => note.filePath)), + ) + + for (const note of affectedNotes) { + assertEntityFileWritable( + path.join(paths.notesRoot, note.filePath), + note, + ) + } + for (const note of notes) { if (note.folderId !== null && descendantIds.has(note.folderId)) { const previousFilePath = note.filePath @@ -276,11 +344,12 @@ export function createNotesFoldersStorage(): NotesFoldersStorage { note.updatedAt = Date.now() persistNote(paths, state, note, previousFilePath, { allowRenameOnConflict: true, + directoryEntriesCache, + folderPathMap, }) } } - const folderPathMap = buildNotesFolderPathMap(state) const folderPathsToDelete = getFolderPathsByDepth( folderPathMap, descendantIds, @@ -292,6 +361,7 @@ export function createNotesFoldersStorage(): NotesFoldersStorage { state.folders = state.folders.filter(f => !descendantIds.has(f.id)) saveNotesState(paths, state) + scheduleDockBadgeRefresh() return { deleted: true } }, diff --git a/src/main/storage/providers/markdown/notes/storages/notes.ts b/src/main/storage/providers/markdown/notes/storages/notes.ts index 9501e1d49..54711cbf1 100644 --- a/src/main/storage/providers/markdown/notes/storages/notes.ts +++ b/src/main/storage/providers/markdown/notes/storages/notes.ts @@ -1,5 +1,6 @@ import type { NoteCreateInput, + NotePropertiesUpdateInput, NoteRecord, NotesCount, NotesQueryInput, @@ -10,8 +11,17 @@ import type { NoteUpdateResult, } from '../../../../contracts' import type { MarkdownNote, NotesState } from '../runtime/types' +import path from 'node:path' +import { isAfter, isToday, parseISO, startOfToday } from 'date-fns' +import { scheduleDockBadgeRefresh } from '../../../../../dockBadge' +import { prioritizeCloudDownload } from '../../cloudDownloads' import { normalizeFlag } from '../../runtime/normalizers' import { getVaultPath } from '../../runtime/paths' +import { + assertEntityFileWritable, + markEntityPendingIfEvicted, + markEntityPendingIfFileExists, +} from '../../runtime/shared/cloudGuards' import { updateEntityBodyContent } from '../../runtime/shared/entityContent' import { filterAndSortByQuery } from '../../runtime/shared/entityQuery' import { @@ -25,6 +35,7 @@ import { } from '../../runtime/shared/entityStorage' import { assertUniqueSiblingEntryName, + assertVaultNotHydrating, throwStorageError, validateEntryName, } from '../../runtime/validation' @@ -33,7 +44,13 @@ import { rewriteBacklinksAfterNoteUpdate, } from '../runtime/backlinks' import { getNotesPaths } from '../runtime/constants' -import { findNoteById, persistNote, writeNoteToFile } from '../runtime/notes' +import { + ensureNoteContentLoaded, + findNoteById, + isNoteSystemFrontmatterKey, + persistNote, + writeNoteToFile, +} from '../runtime/notes' import { findNotesFolderById } from '../runtime/paths' import { getNoteIdsBySearchQuery, @@ -59,7 +76,10 @@ function createNoteRecord(note: MarkdownNote, state: NotesState): NoteRecord { .filter((t): t is { id: number, name: string } => t !== null) return { - content: note.content, + // Ленивые записи отдают пустой контент: список его не сериализует, а + // потокам с телом (getNoteById, поиск, graph) контент дочитывается до + // построения record. + content: note.content ?? '', createdAt: note.createdAt, description: note.description, folder, @@ -67,11 +87,139 @@ function createNoteRecord(note: MarkdownNote, state: NotesState): NoteRecord { isDeleted: note.isDeleted, isFavorites: note.isFavorites, name: note.name, + pendingCloudDownload: note.pendingCloudDownload === true, + properties: note.properties, tags, updatedAt: note.updatedAt, } } +function normalizePropertyText(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function normalizePropertyDate(value: unknown): Date | undefined { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value + } + + if (typeof value === 'string') { + const normalized = value.trim() + if (!normalized) { + return undefined + } + + const date = parseISO(normalized) + if (!Number.isNaN(date.getTime())) { + return date + } + + const fallbackDate = new Date(normalized) + return Number.isNaN(fallbackDate.getTime()) ? undefined : fallbackDate + } + + if (typeof value !== 'number') { + return undefined + } + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? undefined : date +} + +function applyNotePropertyFilters( + note: MarkdownNote, + query: NotesQueryInput, +): boolean { + if ( + query.propertyType !== undefined + && normalizePropertyText(note.properties.type) !== query.propertyType + ) { + return false + } + + if ( + query.propertyStatus !== undefined + && normalizePropertyText(note.properties.status) !== query.propertyStatus + ) { + return false + } + + if ( + query.propertyStatusNot !== undefined + && normalizePropertyText(note.properties.status) === query.propertyStatusNot + ) { + return false + } + + if ( + query.hideCompletedTasks + && normalizePropertyText(note.properties.type) === 'task' + && normalizePropertyText(note.properties.status) === 'done' + ) { + return false + } + + if (query.propertyDue !== undefined) { + const due = normalizePropertyDate(note.properties.due) + if (!due) { + return false + } + + if (query.propertyDue === 'today') { + return isToday(due) + } + + if (query.propertyDue === 'upcoming') { + return isAfter(due, startOfToday()) && !isToday(due) + } + } + + return true +} + +function applyNotePropertiesUpdate( + note: MarkdownNote, + input: NotePropertiesUpdateInput, +): boolean { + let hasAnyField = false + + for (const [key, value] of Object.entries(input.properties || {})) { + if (isNoteSystemFrontmatterKey(key)) { + continue + } + + note.properties[key] = value + hasAnyField = true + } + + for (const key of input.unset || []) { + if (isNoteSystemFrontmatterKey(key)) { + continue + } + + if (Object.hasOwn(note.properties, key)) { + delete note.properties[key] + hasAnyField = true + } + } + + return hasAnyField +} + +function createNoteProperties( + inputProperties: Record | undefined, +) { + if (!inputProperties) { + return {} + } + + return Object.fromEntries( + Object.entries(inputProperties).filter( + ([key]) => !isNoteSystemFrontmatterKey(key), + ), + ) +} + export function createNotesNotesStorage(): NotesStorage { function resolvePaths() { return getNotesPaths(getVaultPath()) @@ -111,17 +259,65 @@ export function createNotesNotesStorage(): NotesStorage { || note.isFavorites === 1, (note, query) => query.tagId === undefined || note.tags.includes(query.tagId), + (note, query) => applyNotePropertyFilters(note, query), ], - getSortValue: note => note.createdAt, + getSortValue: (note, sort) => { + if (sort === 'name') { + return note.name.toLowerCase() + } + + if (sort === 'updatedAt') { + return note.updatedAt + } + + return note.createdAt + }, query, }) + // Контент дочитывается до построения records: снимок content в record + // не обновился бы от более поздней материализации. + if (query.withContent) { + filtered.forEach((note) => { + ensureNoteContentLoaded(resolvePaths(), note) + }) + } + return filtered.map(n => createNoteRecord(n, state)) }, getNoteById(id: number): NoteRecord | null { const { state, notes } = getCache() const note = findNoteById(notes, id) + // Пользователь открыл ещё не докачанную заметку: её файл поднимается + // в начало очереди фоновой докачки, ответ при этом не блокируется. + if (note?.pendingCloudDownload) { + prioritizeCloudDownload( + path.join(resolvePaths().notesRoot, note.filePath), + ) + } + + // Запись из индекса без тела: контент дочитывается по первому запросу. + // Сбой дочитки (файл выгружен после скана, флаг ещё не обновился) + // помечает запись pending: успешный ответ с пустым content без флага + // открыл бы редактируемый пустой редактор, и набранный текст потерялся + // бы на 503 при сохранении. Для уже гидрированной записи eviction + // ловится свежим stat. Флаг снимет ресинк после докачки. + if (note) { + if (!ensureNoteContentLoaded(resolvePaths(), note)) { + markEntityPendingIfFileExists( + path.join(resolvePaths().notesRoot, note.filePath), + note, + ) + } + else { + markEntityPendingIfEvicted( + path.join(resolvePaths().notesRoot, note.filePath), + note, + ) + } + } + return note ? createNoteRecord(note, state) : null }, @@ -134,6 +330,7 @@ export function createNotesNotesStorage(): NotesStorage { const paths = resolvePaths() const { state, notes } = getNotesRuntimeCache(paths) + assertVaultNotHydrating(state) const name = validateEntryName(input.name, 'note') const folderId = input.folderId ?? null assertUniqueSiblingEntryName(notes, folderId, name, 'note') @@ -148,6 +345,7 @@ export function createNotesNotesStorage(): NotesStorage { isDeleted: 0, isFavorites: 0, name, + properties: createNoteProperties(input.properties), tags: [], updatedAt: now, }), @@ -175,6 +373,7 @@ export function createNotesNotesStorage(): NotesStorage { }) saveNotesState(paths, state) + scheduleDockBadgeRefresh() return result }, @@ -188,6 +387,10 @@ export function createNotesNotesStorage(): NotesStorage { return { invalidInput: false, notFound: true } } + // Проверка до мутации: иначе rename/move уже переместил бы файл и + // изменил runtime/state, а запись frontmatter отклонилась. + assertEntityFileWritable(path.join(paths.notesRoot, note.filePath), note) + const previousFilePath = note.filePath const previousName = note.name const previousFolderId = note.folderId @@ -248,6 +451,7 @@ export function createNotesNotesStorage(): NotesStorage { } saveNotesState(paths, state) + scheduleDockBadgeRefresh() return { invalidInput: false, notFound: false } }, @@ -255,6 +459,16 @@ export function createNotesNotesStorage(): NotesStorage { const paths = resolvePaths() const { state, notes } = getNotesRuntimeCache(paths) const note = findNoteById(notes, id) + + // Проверка до мутации: updateEntityBodyContent меняет runtime до + // записи файла. + if (note) { + assertEntityFileWritable( + path.join(paths.notesRoot, note.filePath), + note, + ) + } + const result = updateEntityBodyContent({ content, entity: note, @@ -265,6 +479,35 @@ export function createNotesNotesStorage(): NotesStorage { return { invalidInput: false, notFound: result.notFound } }, + updateNoteProperties( + id: number, + input: NotePropertiesUpdateInput, + ): NoteUpdateResult { + const paths = resolvePaths() + const { notes } = getNotesRuntimeCache(paths) + const note = findNoteById(notes, id) + + if (!note) { + return { invalidInput: false, notFound: true } + } + + // Проверка со свежим stat до мутации: флаг pendingCloudDownload мог + // устареть после eviction. + assertEntityFileWritable(path.join(paths.notesRoot, note.filePath), note) + + const hasAnyField = applyNotePropertiesUpdate(note, input) + + if (!hasAnyField) { + return { invalidInput: true, notFound: false } + } + + note.updatedAt = Date.now() + writeNoteToFile(paths, note) + scheduleDockBadgeRefresh() + + return { invalidInput: false, notFound: false } + }, + deleteNote(id: number) { const paths = resolvePaths() const { state, notes } = getNotesRuntimeCache(paths) @@ -279,6 +522,7 @@ export function createNotesNotesStorage(): NotesStorage { } saveNotesState(paths, state) + scheduleDockBadgeRefresh() return result }, @@ -296,6 +540,7 @@ export function createNotesNotesStorage(): NotesStorage { } saveNotesState(paths, state) + scheduleDockBadgeRefresh() return result }, @@ -304,6 +549,15 @@ export function createNotesNotesStorage(): NotesStorage { const { state, notes } = getNotesRuntimeCache(paths) const note = findNoteById(notes, noteId) const tag = state.tags.find(t => t.id === tagId) + + // Проверка до мутации: addTagToEntity меняет runtime до записи файла. + if (note && tag) { + assertEntityFileWritable( + path.join(paths.notesRoot, note.filePath), + note, + ) + } + const result = addTagToEntity({ entity: note, onUpdated: note => writeNoteToFile(paths, note), @@ -326,6 +580,16 @@ export function createNotesNotesStorage(): NotesStorage { const { state, notes } = getNotesRuntimeCache(paths) const note = findNoteById(notes, noteId) const tag = state.tags.find(t => t.id === tagId) + + // Проверка до мутации: deleteTagFromEntity меняет runtime до записи + // файла. + if (note && tag) { + assertEntityFileWritable( + path.join(paths.notesRoot, note.filePath), + note, + ) + } + const result = deleteTagFromEntity({ entity: note, missingRelationFound: false, diff --git a/src/main/storage/providers/markdown/notes/storages/tags.ts b/src/main/storage/providers/markdown/notes/storages/tags.ts index 941549fed..b0bf129b9 100644 --- a/src/main/storage/providers/markdown/notes/storages/tags.ts +++ b/src/main/storage/providers/markdown/notes/storages/tags.ts @@ -61,7 +61,9 @@ export function createNoteTagsStorage(): NoteTagsStorage { const paths = resolvePaths() const { state, notes } = getNotesRuntimeCache(paths) const result = deleteTagFromStateAndEntities(state, notes, id, (note) => { - writeNoteToFile(paths, note) + // Bulk-очистка тега: недокачанный файл пропускается, устаревший id + // в frontmatter безвреден (state — источник истины по тегам). + writeNoteToFile(paths, note, { skipIfUnavailable: true }) }) if (!result.deleted) { return result diff --git a/src/main/storage/providers/markdown/notesAssetEvents.ts b/src/main/storage/providers/markdown/notesAssetEvents.ts new file mode 100644 index 000000000..5f18916ee --- /dev/null +++ b/src/main/storage/providers/markdown/notesAssetEvents.ts @@ -0,0 +1,7 @@ +import { BrowserWindow } from 'electron' + +export function broadcastNotesAssetReady(fileName: string): void { + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send('system:notes-asset-ready', fileName) + }) +} diff --git a/src/main/storage/providers/markdown/runtime/__tests__/cloudPlaceholders.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/cloudPlaceholders.test.ts new file mode 100644 index 000000000..a508e3a5e --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/__tests__/cloudPlaceholders.test.ts @@ -0,0 +1,563 @@ +import type { Paths } from '../types' +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enqueueCloudDownload } from '../../cloudDownloads' +import { createFoldersStorage } from '../../storages/folders' +import { createSnippetsStorage } from '../../storages/snippets' +import { runtimeRef } from '../cache' +import { getPaths } from '../paths' +import { + getFileAvailability, + setDatalessProbeForTests, +} from '../shared/cloudFiles' +import { assertNoUnknownDomainFiles } from '../shared/foldersStorage' +import { ensureSnippetContentLoaded, writeSnippetToFile } from '../snippets' +import { saveState } from '../state' +import { + refreshPendingSnippetFiles, + resetRuntimeCache, + syncRuntimeWithDisk, + syncSnippetFileWithDisk, +} from '../sync' + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir(), + }, + BrowserWindow: { + getAllWindows: () => [], + }, +})) + +const storageMock = vi.hoisted(() => ({ vaultPath: '' })) + +vi.mock('../../../../../store', () => ({ + store: { + preferences: { + get: (key: string) => + key === 'storage.vaultPath' ? storageMock.vaultPath : undefined, + }, + }, +})) + +vi.mock('../../cloudDownloads', () => ({ + enqueueCloudDownload: vi.fn(), + prioritizeCloudDownload: vi.fn(), +})) + +const tempDirs: string[] = [] + +function createPaths(): Paths { + const vaultPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'cloud-placeholders-'), + ) + tempDirs.push(vaultPath) + const metaDirPath = path.join(vaultPath, '.masscode') + + return { + inboxDirPath: path.join(metaDirPath, 'inbox'), + metaDirPath, + statePath: path.join(metaDirPath, 'state.json'), + trashDirPath: path.join(metaDirPath, 'trash'), + vaultPath, + } +} + +function createStoragePaths(): Paths { + const vaultPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'cloud-placeholder-storage-'), + ) + tempDirs.push(vaultPath) + storageMock.vaultPath = vaultPath + + return getPaths(vaultPath) +} + +function writeSnippetFixture( + paths: Paths, + relativePath: string, + id: number, + name: string, +): string { + const absolutePath = path.join(paths.vaultPath, relativePath) + const source = [ + '---', + `id: ${id}`, + `name: ${name}`, + 'createdAt: 1700000000000', + 'updatedAt: 1700000000000', + 'contents:', + ' - id: 1', + ' label: Fragment 1', + ' language: plain_text', + '---', + '', + '## Fragment: Fragment 1', + '```plain_text', + 'body', + '```', + '', + ].join('\n') + + fs.ensureDirSync(path.dirname(absolutePath)) + fs.writeFileSync(absolutePath, source, 'utf8') + + return absolutePath +} + +// Sparse-файл воспроизводит сигнатуру облачного плейсхолдера: +// ненулевой size при нулевых blocks (проверяется в cloudFiles.test.ts). +function makeSparsePlaceholder(absolutePath: string, size = 4096): void { + fs.removeSync(absolutePath) + const fd = fs.openSync(absolutePath, 'w') + fs.ftruncateSync(fd, size) + fs.closeSync(fd) +} + +function hasPlaceholderSignature(absolutePath: string): boolean { + const stats = fs.statSync(absolutePath) + return stats.size > 0 && stats.blocks === 0 +} + +beforeEach(() => { + // Sparse-файлы имитируют плейсхолдеры по сигнатуре (size > 0, blocks 0), + // но не имеют флага SF_DATALESS: точная проверка подменяется, чтобы + // симуляция работала как настоящий облачный плейсхолдер. + setDatalessProbeForTests(() => true) +}) + +afterEach(() => { + storageMock.vaultPath = '' + setDatalessProbeForTests(null) + resetRuntimeCache() + vi.mocked(enqueueCloudDownload).mockClear() + + for (const dirPath of tempDirs.splice(0)) { + fs.removeSync(dirPath) + } +}) + +describe('cloud placeholder handling in code runtime', () => { + it('allows immediate content write and rename of an app-written file', () => { + const paths = createStoragePaths() + const targetPath = path.join(paths.vaultPath, '.masscode/inbox/Renamed.md') + const statSync = fs.statSync.bind(fs) + const statSpy = vi.spyOn(fs, 'statSync').mockImplementation((filePath) => { + const stats = statSync(filePath) + + if ( + typeof filePath === 'string' + && filePath.endsWith('.md') + && stats.size > 0 + ) { + return Object.assign(stats, { blocks: 0 }) + } + + return stats + }) + + try { + const storage = createSnippetsStorage() + const { id } = storage.createSnippet({ name: 'Resident' }) + expect(() => + storage.createSnippetContent(id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'body', + }), + ).not.toThrow() + + resetRuntimeCache() + syncRuntimeWithDisk(paths) + resetRuntimeCache() + const lazyCache = syncRuntimeWithDisk(paths) + const lazySnippet = lazyCache.snippets.find( + snippet => snippet.id === id, + ) + expect(lazySnippet?.contents[0]?.value).toBeNull() + + expect(() => + storage.updateSnippet(id, { name: 'Renamed' }), + ).not.toThrow() + + expect(fs.readFileSync(targetPath, 'utf8')).toContain('body') + } + finally { + statSpy.mockRestore() + } + }) + + it('keeps local folder contents available without rewriting placeholders', () => { + const paths = createStoragePaths() + const statSync = fs.statSync.bind(fs) + const statSpy = vi.spyOn(fs, 'statSync').mockImplementation((filePath) => { + const stats = statSync(filePath) + + if ( + typeof filePath === 'string' + && filePath.endsWith('.md') + && stats.size > 0 + ) { + return Object.assign(stats, { blocks: 0 }) + } + + return stats + }) + const foldersStorage = createFoldersStorage() + const storage = createSnippetsStorage() + const folder = foldersStorage.createFolder({ name: 'Folder' }) + let localId = 0 + let placeholderId = 0 + let placeholderSourcePath = '' + + try { + localId = storage.createSnippet({ + folderId: folder.id, + name: 'Resident', + }).id + storage.createSnippetContent(localId, { + label: 'Fragment 1', + language: 'plain_text', + value: 'local body', + }) + + placeholderId = storage.createSnippet({ + folderId: folder.id, + name: 'Pending', + }).id + storage.createSnippetContent(placeholderId, { + label: 'Fragment 1', + language: 'plain_text', + value: 'remote body', + }) + const placeholder = runtimeRef.cache!.snippets.find( + item => item.id === placeholderId, + )! + placeholderSourcePath = path.join(paths.vaultPath, placeholder.filePath) + + makeSparsePlaceholder(placeholderSourcePath) + if (!hasPlaceholderSignature(placeholderSourcePath)) { + return + } + + expect(foldersStorage.deleteFolder(folder.id).deleted).toBe(true) + + const local = runtimeRef.cache!.snippets.find( + item => item.id === localId, + )! + const pending = runtimeRef.cache!.snippets.find( + item => item.id === placeholderId, + )! + const localTargetPath = path.join(paths.vaultPath, local.filePath) + const placeholderTargetPath = path.join( + paths.vaultPath, + pending.filePath, + ) + + expect(getFileAvailability(localTargetPath).isCloudPlaceholder).toBe( + false, + ) + expect(hasPlaceholderSignature(placeholderTargetPath)).toBe(true) + expect(fs.statSync(placeholderTargetPath).size).toBe(4096) + } + finally { + statSpy.mockRestore() + } + }) + + it('keeps a committed write successful when its follow-up stat fails', () => { + const paths = createPaths() + const snippetPath = path.join(paths.vaultPath, '.masscode/inbox/saved.md') + const statSync = fs.statSync.bind(fs) + const statSpy = vi.spyOn(fs, 'statSync').mockImplementation((filePath) => { + if (filePath === snippetPath && fs.existsSync(snippetPath)) { + throw new Error('post-write stat failed') + } + + return statSync(filePath) + }) + + try { + expect(() => + writeSnippetToFile(paths, { + contents: [ + { + id: 1, + label: 'Fragment 1', + language: 'plain_text', + value: 'saved body', + }, + ], + createdAt: 1700000000000, + description: null, + filePath: '.masscode/inbox/saved.md', + folderId: null, + id: 2, + isDeleted: 0, + isFavorites: 0, + name: 'Saved', + tags: [], + updatedAt: 1700000000000, + }), + ).not.toThrow() + } + finally { + statSpy.mockRestore() + } + + expect(fs.readFileSync(snippetPath, 'utf8')).toContain('saved body') + }) + + it('keeps known placeholder snippets in the list without reading them', () => { + const paths = createPaths() + const localPath = writeSnippetFixture( + paths, + '.masscode/inbox/local.md', + 1, + 'Local', + ) + const placeholderPath = writeSnippetFixture( + paths, + '.masscode/inbox/remote.md', + 2, + 'Remote', + ) + + // Первый скан регистрирует оба файла в state как обычные. + syncRuntimeWithDisk(paths) + + // Провайдер "выгрузил" второй файл: содержимое заменяется на placeholder. + makeSparsePlaceholder(placeholderPath) + if (!hasPlaceholderSignature(placeholderPath)) { + // На ФС без поддержки sparse-файлов сценарий невоспроизводим. + return + } + + resetRuntimeCache() + const cache = syncRuntimeWithDisk(paths) + + const local = cache.snippets.find(snippet => snippet.id === 1) + const remote = cache.snippets.find(snippet => snippet.id === 2) + + expect(local?.pendingCloudDownload).toBeUndefined() + + // Нетронутый файл строится из индекса метаданных без чтения: тело + // остаётся ленивым и дочитывается по требованию. + expect(local?.contents[0]?.value).toBeNull() + expect(ensureSnippetContentLoaded(paths, local!)).toBe(true) + expect(local?.contents[0]?.value).toBe('body') + + // Плейсхолдер с известными метаданными — полноценная запись списка: + // имя и структура фрагментов из индекса, тела ждут докачки. + expect(remote).toBeDefined() + expect(remote?.pendingCloudDownload).toBe(true) + expect(remote?.name).toBe('Remote') + expect(remote?.contents).toEqual([ + { + id: 1, + label: 'Fragment 1', + language: 'plain_text', + value: null, + }, + ]) + + expect(vi.mocked(enqueueCloudDownload)).toHaveBeenCalledWith( + placeholderPath, + ) + + // Файл остался нетронутым: скан не материализовал и не переписал его. + expect(hasPlaceholderSignature(placeholderPath)).toBe(true) + expect(fs.statSync(localPath).size).toBeGreaterThan(0) + }) + + it('does not register unknown placeholder files until they are downloaded', () => { + const paths = createPaths() + writeSnippetFixture(paths, '.masscode/inbox/known.md', 1, 'Known') + syncRuntimeWithDisk(paths) + + // Файл с другого устройства: есть в каталоге, но содержимое в облаке. + const unknownPath = path.join(paths.vaultPath, '.masscode/inbox/new.md') + makeSparsePlaceholder(unknownPath) + if (!hasPlaceholderSignature(unknownPath)) { + return + } + + resetRuntimeCache() + const cache = syncRuntimeWithDisk(paths) + + // Без frontmatter id регистрировать нельзя: id был бы угадан и после + // докачки разошёлся бы с настоящим id из frontmatter. + expect( + cache.state.snippets.some(entry => entry.filePath.endsWith('new.md')), + ).toBe(false) + expect(vi.mocked(enqueueCloudDownload)).toHaveBeenCalledWith(unknownPath) + }) + + it('never writes into a placeholder file', () => { + const paths = createPaths() + const placeholderPath = writeSnippetFixture( + paths, + '.masscode/inbox/pending.md', + 3, + 'Pending', + ) + makeSparsePlaceholder(placeholderPath) + if (!hasPlaceholderSignature(placeholderPath)) { + return + } + + // Содержимое в облаке ещё не скачано: запись уничтожила бы его, а тихий + // пропуск дал бы ложный success — правка молча потерялась бы при докачке. + expect(() => + writeSnippetToFile(paths, { + contents: [], + createdAt: 1700000000000, + description: null, + filePath: '.masscode/inbox/pending.md', + folderId: null, + id: 3, + isDeleted: 0, + isFavorites: 0, + name: 'pending', + pendingCloudDownload: true, + tags: [], + updatedAt: 1700000000000, + }), + ).toThrow(/CLOUD_FILE_NOT_DOWNLOADED/) + + expect(hasPlaceholderSignature(placeholderPath)).toBe(true) + expect(vi.mocked(enqueueCloudDownload)).toHaveBeenCalledWith( + placeholderPath, + ) + }) + + it('clears the pending flag and fills content once the file is hydrated', () => { + const paths = createPaths() + const filePath = writeSnippetFixture( + paths, + '.masscode/inbox/remote.md', + 7, + 'Remote', + ) + + // Файл сперва зарегистрирован в state обычным сканом. + syncRuntimeWithDisk(paths) + + // Затем провайдер выгрузил его: повторный скан отдаёт placeholder-запись. + makeSparsePlaceholder(filePath) + if (!hasPlaceholderSignature(filePath)) { + return + } + + resetRuntimeCache() + const cache = syncRuntimeWithDisk(paths) + const pending = cache.snippets.find(snippet => snippet.id === 7) + expect(pending?.pendingCloudDownload).toBe(true) + expect(pending?.contents[0]?.value).toBeNull() + + // Облако материализовало файл (в реальности — без смены mtime, поэтому + // watcher-событие не приходит и снятие флага делает self-heal). + writeSnippetFixture(paths, '.masscode/inbox/remote.md', 7, 'Remote') + expect(hasPlaceholderSignature(filePath)).toBe(false) + + const result = refreshPendingSnippetFiles(paths) + expect(result.remaining).toBe(0) + + const refreshed = runtimeRef.cache?.snippets.find( + snippet => snippet.id === 7, + ) + expect(refreshed?.pendingCloudDownload).toBeFalsy() + expect(refreshed?.contents[0]?.value).toBe('body') + }) +}) + +describe('assertNoUnknownDomainFiles', () => { + it('rejects folder deletion when an unindexed placeholder is inside', () => { + const paths = createPaths() + const folderRelativePath = 'Folder' + const knownPath = writeSnippetFixture(paths, 'Folder/known.md', 1, 'Known') + + // Файл с другого устройства: есть в каталоге, содержимое в облаке, + // записи в state нет. Рекурсивное удаление каталога уничтожило бы его. + const unknownPath = path.join(paths.vaultPath, 'Folder/unknown.md') + makeSparsePlaceholder(unknownPath) + if (!hasPlaceholderSignature(unknownPath)) { + return + } + + expect(() => + assertNoUnknownDomainFiles( + paths.vaultPath, + [folderRelativePath], + new Set(['Folder/known.md']), + ), + ).toThrow(/CLOUD_FILE_NOT_DOWNLOADED/) + expect(vi.mocked(enqueueCloudDownload)).toHaveBeenCalledWith(unknownPath) + + // Известные записи (в том числе pending) не блокируют удаление: их + // переносят в trash отдельные preflight'ы вызывающих. + makeSparsePlaceholder(knownPath) + expect(() => + assertNoUnknownDomainFiles( + paths.vaultPath, + [folderRelativePath], + new Set(['Folder/known.md', 'Folder/unknown.md']), + ), + ).not.toThrow() + }) + + it('rejects any unknown markdown file, not only cloud placeholders', () => { + const paths = createPaths() + + // Обычный локальный .md без записи в runtime (сбой чтения при скане или + // файл создан между сканом и удалением) тоже не должен уничтожаться. + writeSnippetFixture(paths, 'Folder/orphan.md', 9, 'Orphan') + + expect(() => + assertNoUnknownDomainFiles(paths.vaultPath, ['Folder'], new Set()), + ).toThrow(/CLOUD_FILE_NOT_DOWNLOADED/) + + // Не-доменные файлы (ассеты, служебные) удаление не блокируют. + fs.removeSync(path.join(paths.vaultPath, 'Folder/orphan.md')) + fs.writeFileSync(path.join(paths.vaultPath, 'Folder/asset.png'), 'bin') + expect(() => + assertNoUnknownDomainFiles(paths.vaultPath, ['Folder'], new Set()), + ).not.toThrow() + }) +}) + +describe('provisional state during cloud hydration', () => { + it('never persists provisional state over the real index', () => { + const paths = createPaths() + writeSnippetFixture(paths, '.masscode/inbox/local.md', 1, 'Local') + const cache = syncRuntimeWithDisk(paths) + const persistedContent = fs.readFileSync(paths.statePath, 'utf8') + + // Состояние помечено как provisional (state.json «ещё не докачан»): + // даже изменённый индекс не должен доехать до диска. + cache.state.provisional = true + cache.state.snippets.push({ filePath: 'phantom.md', id: 99 }) + saveState(paths, cache.state, { immediate: true }) + + expect(fs.readFileSync(paths.statePath, 'utf8')).toBe(persistedContent) + }) + + it('skips watcher registration of files while state is provisional', () => { + const paths = createPaths() + writeSnippetFixture(paths, '.masscode/inbox/known.md', 1, 'Known') + const cache = syncRuntimeWithDisk(paths) + cache.state.provisional = true + + writeSnippetFixture(paths, '.masscode/inbox/fresh.md', 2, 'Fresh') + const result = syncSnippetFileWithDisk(paths, '.masscode/inbox/fresh.md') + + // Событие не эскалирует в полный ресинк и не регистрирует файл: + // его подберёт сверка после докачки state. + expect(result).toBe(cache) + expect( + cache.state.snippets.some(entry => entry.filePath.endsWith('fresh.md')), + ).toBe(false) + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/__tests__/metadataIndex.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/metadataIndex.test.ts new file mode 100644 index 000000000..24e555ede --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/__tests__/metadataIndex.test.ts @@ -0,0 +1,220 @@ +import type { Paths } from '../types' +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ensureSnippetContentLoaded } from '../snippets' +import { loadState } from '../state' +import { resetRuntimeCache, syncRuntimeWithDisk } from '../sync' + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir(), + }, + BrowserWindow: { + getAllWindows: () => [], + }, +})) + +vi.mock('../../../../../store', () => ({ + store: { + preferences: { + get: () => undefined, + }, + }, +})) + +vi.mock('../../cloudDownloads', () => ({ + enqueueCloudDownload: vi.fn(), + prioritizeCloudDownload: vi.fn(), +})) + +const tempDirs: string[] = [] + +function createPaths(): Paths { + const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'metadata-index-')) + tempDirs.push(vaultPath) + const metaDirPath = path.join(vaultPath, '.masscode') + + return { + inboxDirPath: path.join(metaDirPath, 'inbox'), + metaDirPath, + statePath: path.join(metaDirPath, 'state.json'), + trashDirPath: path.join(metaDirPath, 'trash'), + vaultPath, + } +} + +function writeSnippetFixture( + paths: Paths, + relativePath: string, + id: number, + name: string, + body = 'body', +): string { + const absolutePath = path.join(paths.vaultPath, relativePath) + const source = [ + '---', + `id: ${id}`, + `name: ${name}`, + 'createdAt: 1700000000000', + 'updatedAt: 1700000000000', + 'contents:', + ' - id: 1', + ' label: Fragment 1', + ' language: plain_text', + '---', + '', + '## Fragment: Fragment 1', + '```plain_text', + body, + '```', + '', + ].join('\n') + + fs.ensureDirSync(path.dirname(absolutePath)) + fs.writeFileSync(absolutePath, source, 'utf8') + + return absolutePath +} + +// Мутирует файл и подгоняет stat-сигнатуру индекса под новый stat: +// получается сценарий «файл не менялся» — если скан всё равно отдаёт старые +// данные из индекса, значит файл действительно не читался. +function mutateFileKeepingIndexSignature( + paths: Paths, + absolutePath: string, + mutate: (source: string) => string, +): void { + const source = fs.readFileSync(absolutePath, 'utf8') + fs.writeFileSync(absolutePath, mutate(source), 'utf8') + + const stats = fs.statSync(absolutePath) + const persisted = fs.readJsonSync(paths.statePath) as { + snippets: { meta?: { mtimeMs: number, size: number } }[] + } + persisted.snippets[0].meta!.mtimeMs = stats.mtimeMs + persisted.snippets[0].meta!.size = stats.size + fs.writeJsonSync(paths.statePath, persisted) +} + +afterEach(() => { + resetRuntimeCache() + + for (const dirPath of tempDirs.splice(0)) { + fs.removeSync(dirPath) + } +}) + +describe('snippet metadata index', () => { + it('fills index metadata on first scan and persists it in state.json', () => { + const paths = createPaths() + writeSnippetFixture(paths, '.masscode/inbox/first.md', 1, 'First') + + syncRuntimeWithDisk(paths) + + const persisted = fs.readJsonSync(paths.statePath) as { + snippets: { id: number, meta?: { name: string, mtimeMs: number } }[] + version: number + } + + expect(persisted.version).toBe(3) + expect(persisted.snippets[0].meta?.name).toBe('First') + expect(persisted.snippets[0].meta?.mtimeMs).toBeGreaterThan(0) + + // Схема записи строгая: посторонние поля не персистятся. + expect(Object.keys(persisted.snippets[0]).sort()).toEqual([ + 'filePath', + 'id', + 'meta', + ]) + }) + + it('builds untouched snippets from the index without reading files', () => { + const paths = createPaths() + const absolutePath = writeSnippetFixture( + paths, + '.masscode/inbox/lazy.md', + 1, + 'Lazy', + ) + + // Первый скан читает файл и заполняет индекс. + syncRuntimeWithDisk(paths) + + // Имя в файле меняется, но сигнатура индекса совпадает со stat: если + // повторный скан отдаёт старое имя, файл действительно не читался. + mutateFileKeepingIndexSignature(paths, absolutePath, source => + source.replace('name: Lazy', 'name: Hack')) + + resetRuntimeCache() + const cache = syncRuntimeWithDisk(paths) + const snippet = cache.snippets.find(item => item.id === 1) + + expect(snippet?.name).toBe('Lazy') + expect(snippet?.contents[0]?.value).toBeNull() + }) + + it('lazily loads fragment bodies on demand', () => { + const paths = createPaths() + writeSnippetFixture(paths, '.masscode/inbox/demand.md', 1, 'Demand') + + syncRuntimeWithDisk(paths) + resetRuntimeCache() + const cache = syncRuntimeWithDisk(paths) + const snippet = cache.snippets.find(item => item.id === 1) + + expect(snippet?.contents[0]?.value).toBeNull() + expect(ensureSnippetContentLoaded(paths, snippet!)).toBe(true) + expect(snippet?.contents[0]?.value).toBe('body') + }) + + it('re-reads files whose stat signature changed', () => { + const paths = createPaths() + const absolutePath = writeSnippetFixture( + paths, + '.masscode/inbox/changed.md', + 1, + 'Before', + ) + + syncRuntimeWithDisk(paths) + + // Обычная внешняя правка: mtime меняется, файл перечитывается. + writeSnippetFixture(paths, '.masscode/inbox/changed.md', 1, 'After') + fs.utimesSync(absolutePath, new Date(), new Date(Date.now() + 5_000)) + + resetRuntimeCache() + const cache = syncRuntimeWithDisk(paths) + const snippet = cache.snippets.find(item => item.id === 1) + + expect(snippet?.name).toBe('After') + expect(snippet?.contents[0]?.value).toBe('body') + }) + + it('backfills metadata for legacy v2 index entries', () => { + const paths = createPaths() + writeSnippetFixture(paths, '.masscode/inbox/legacy.md', 5, 'Legacy') + + // v2-индекс: записи без метаданных, только { id, filePath }. + fs.ensureDirSync(paths.metaDirPath) + fs.writeJsonSync(paths.statePath, { + counters: { contentId: 0, folderId: 0, snippetId: 5, tagId: 0 }, + folderUi: {}, + snippets: [{ filePath: '.masscode/inbox/legacy.md', id: 5 }], + tags: [], + version: 2, + }) + + const cache = syncRuntimeWithDisk(paths) + const snippet = cache.snippets.find(item => item.id === 5) + + // Файл прочитан один раз (тело на месте), а индекс дозаполнен. + expect(snippet?.name).toBe('Legacy') + expect(snippet?.contents[0]?.value).toBe('body') + + const state = loadState(paths) + expect(state.snippets[0].meta?.name).toBe('Legacy') + expect(state.version).toBe(3) + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/__tests__/moveVault.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/moveVault.test.ts index 5682ba469..535961605 100644 --- a/src/main/storage/providers/markdown/runtime/__tests__/moveVault.test.ts +++ b/src/main/storage/providers/markdown/runtime/__tests__/moveVault.test.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer' import os from 'node:os' import path from 'node:path' import fs from 'fs-extra' @@ -60,6 +61,18 @@ describe('moveVault', () => { fs.writeFileSync(path.join(sourcePath, 'code', 'snippet.md'), '# Snippet') fs.ensureDirSync(path.join(sourcePath, 'notes')) fs.writeFileSync(path.join(sourcePath, 'notes', 'note.md'), '# Note') + const assetBytes = Buffer.from([0x00, 0xFF, 0x41, 0x80]) + fs.ensureDirSync(path.join(sourcePath, 'notes', '.masscode', 'assets')) + fs.writeFileSync( + path.join( + sourcePath, + 'notes', + '.masscode', + 'assets', + 'abcdefghijklmnop.png', + ), + assetBytes, + ) moveVault(sourcePath, targetPath) @@ -69,6 +82,17 @@ describe('moveVault', () => { expect(fs.pathExistsSync(path.join(targetPath, 'notes', 'note.md'))).toBe( true, ) + expect( + fs.readFileSync( + path.join( + targetPath, + 'notes', + '.masscode', + 'assets', + 'abcdefghijklmnop.png', + ), + ), + ).toEqual(assetBytes) expect(fs.pathExistsSync(sourcePath)).toBe(false) }) diff --git a/src/main/storage/providers/markdown/runtime/__tests__/parser.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/parser.test.ts index d3aea36e7..eadfe0d2e 100644 --- a/src/main/storage/providers/markdown/runtime/__tests__/parser.test.ts +++ b/src/main/storage/providers/markdown/runtime/__tests__/parser.test.ts @@ -2,6 +2,7 @@ import type { MarkdownSnippet } from '../types' import { describe, expect, it } from 'vitest' import { parseBodyFragments, + parseBodyFragmentsWithMetadata, serializeSnippet, splitFrontmatter, } from '../parser' @@ -40,3 +41,80 @@ describe('parser snippet content roundtrip', () => { expect(parsed[0].value).toBe(input) }) }) + +describe('legacy snippet fence recovery', () => { + it('recovers a legacy triple-backtick wrapper with inner fenced blocks', () => { + const body = [ + '## Fragment: Fragment 1', + '```markdown', + 'Before', + '```', + 'inner block', + '```', + 'After', + '```', + '', + ].join('\n') + + const result = parseBodyFragmentsWithMetadata(body, [ + { + id: 1, + label: 'Fragment 1', + language: 'markdown', + }, + ]) + + expect(result.legacyRecovery).toBe('recovered') + expect(result.fragments).toHaveLength(1) + expect(result.fragments[0].value).toBe( + ['Before', '```', 'inner block', '```', 'After'].join('\n'), + ) + }) + + it('uses declared fragments instead of user text that looks like a fragment heading', () => { + const body = [ + '## Fragment: Fragment 1', + '```markdown', + 'Intro', + '## Fragment: Fragment 2', + '```plain_text', + 'not a fragment boundary', + '```', + 'Outro', + '```', + '', + '## Fragment: Fragment 2', + '```javascript', + 'console.log("second")', + '```', + '', + ].join('\n') + + const result = parseBodyFragmentsWithMetadata(body, [ + { + id: 1, + label: 'Fragment 1', + language: 'markdown', + }, + { + id: 2, + label: 'Fragment 2', + language: 'javascript', + }, + ]) + + expect(result.legacyRecovery).toBe('recovered') + expect(result.fragments).toHaveLength(2) + expect(result.fragments[0].value).toBe( + [ + 'Intro', + '## Fragment: Fragment 2', + '```plain_text', + 'not a fragment boundary', + '```', + 'Outro', + ].join('\n'), + ) + expect(result.fragments[1].value).toBe('console.log("second")') + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts index 2b2d3aea3..7b50ede18 100644 --- a/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts +++ b/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts @@ -2,7 +2,9 @@ import os from 'node:os' import path from 'node:path' import fs from 'fs-extra' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getPaths, hasMarkdownVaultData } from '../paths' +import { enqueueCloudDownload } from '../../cloudDownloads' +import { getPaths, hasMarkdownVaultData, resetPathsCache } from '../paths' +import { setDatalessProbeForTests } from '../shared/cloudFiles' vi.mock('electron-store', () => { class MockStore { @@ -62,6 +64,10 @@ vi.mock('../../../../../store', () => ({ }, })) +vi.mock('../../cloudDownloads', () => ({ + enqueueCloudDownload: vi.fn(), +})) + const tempDirs: string[] = [] function createTempDir(): string { @@ -70,7 +76,23 @@ function createTempDir(): string { return tempDir } +function makeSparsePlaceholder(absolutePath: string, size = 4096): void { + fs.removeSync(absolutePath) + const fd = fs.openSync(absolutePath, 'w') + fs.ftruncateSync(fd, size) + fs.closeSync(fd) +} + +function hasPlaceholderSignature(absolutePath: string): boolean { + const stats = fs.statSync(absolutePath) + return stats.size > 0 && stats.blocks === 0 +} + afterEach(() => { + setDatalessProbeForTests(null) + resetPathsCache() + vi.mocked(enqueueCloudDownload).mockClear() + while (tempDirs.length > 0) { const tempDir = tempDirs.pop() if (tempDir) { @@ -80,6 +102,59 @@ afterEach(() => { }) describe('getPaths', () => { + it('retries legacy layout migration after cloud state hydration', () => { + const vaultPath = createTempDir() + const legacyStatePath = path.join(vaultPath, '.masscode', 'state.json') + const legacyState = { + counters: { + contentId: 0, + folderId: 1, + snippetId: 1, + tagId: 0, + }, + folders: [ + { + id: 1, + name: 'Cloud', + orderIndex: 0, + parentId: null, + }, + ], + snippets: [ + { + filePath: 'Cloud/demo.md', + id: 1, + }, + ], + tags: [], + version: 2, + } + + fs.ensureDirSync(path.dirname(legacyStatePath)) + fs.writeJSONSync(legacyStatePath, legacyState) + fs.ensureDirSync(path.join(vaultPath, 'Cloud')) + fs.writeFileSync(path.join(vaultPath, 'Cloud', 'demo.md'), '# Cloud') + makeSparsePlaceholder(legacyStatePath) + if (!hasPlaceholderSignature(legacyStatePath)) { + return + } + setDatalessProbeForTests(() => true) + + expect(() => getPaths(vaultPath)).toThrow(/CLOUD_FILE_NOT_DOWNLOADED/) + expect(enqueueCloudDownload).toHaveBeenCalledWith(legacyStatePath) + expect(fs.pathExistsSync(path.join(vaultPath, 'code'))).toBe(false) + + fs.writeJSONSync(legacyStatePath, legacyState) + + const paths = getPaths(vaultPath) + + expect(paths.vaultPath).toBe(path.join(vaultPath, 'code')) + expect( + fs.pathExistsSync(path.join(paths.vaultPath, 'Cloud', 'demo.md')), + ).toBe(true) + expect(fs.pathExistsSync(path.join(vaultPath, '.masscode'))).toBe(false) + }) + it('detects existing data in legacy root vault layout', () => { const vaultPath = createTempDir() diff --git a/src/main/storage/providers/markdown/runtime/__tests__/snippets.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/snippets.test.ts index 2c08c51b4..e16b8909e 100644 --- a/src/main/storage/providers/markdown/runtime/__tests__/snippets.test.ts +++ b/src/main/storage/providers/markdown/runtime/__tests__/snippets.test.ts @@ -4,6 +4,7 @@ import path from 'node:path' import fs from 'fs-extra' import { afterEach, describe, expect, it, vi } from 'vitest' import { readSnippetFromFile } from '../snippets' +import { resetRuntimeCache, syncRuntimeWithDisk } from '../sync' vi.mock('electron-store', () => { class MockStore { @@ -80,6 +81,8 @@ function createPaths(): Paths { } afterEach(() => { + resetRuntimeCache() + for (const dirPath of tempDirs.splice(0)) { fs.removeSync(dirPath) } @@ -171,4 +174,86 @@ describe('readSnippetFromFile', () => { vi.useRealTimers() } }) + + it('recovers legacy triple-backtick snippets without rewriting on direct read', () => { + const paths = createPaths() + const relativePath = '.masscode/inbox/legacy-fence-snippet.md' + const absolutePath = path.join(paths.vaultPath, relativePath) + const source = [ + '---', + 'id: 12', + 'name: Legacy Fence Snippet', + 'contents:', + ' - id: 1', + ' label: Fragment 1', + ' language: markdown', + '---', + '', + '## Fragment: Fragment 1', + '```markdown', + 'Before', + '```', + 'inner block', + '```', + 'After', + '```', + '', + ].join('\n') + + fs.ensureDirSync(path.dirname(absolutePath)) + fs.writeFileSync(absolutePath, source, 'utf8') + + const snippet = readSnippetFromFile( + paths, + { filePath: relativePath, id: 12 }, + new Map(), + ) + + expect(snippet?.contents[0].value).toBe( + ['Before', '```', 'inner block', '```', 'After'].join('\n'), + ) + expect(fs.readFileSync(absolutePath, 'utf8')).toBe(source) + }) + + it('rewrites recovered legacy snippets during primary runtime sync', () => { + const paths = createPaths() + const relativePath = '.masscode/inbox/legacy-fence-snippet.md' + const absolutePath = path.join(paths.vaultPath, relativePath) + const source = [ + '---', + 'id: 13', + 'name: Legacy Fence Snippet', + 'createdAt: 1', + 'updatedAt: 1', + 'contents:', + ' - id: 1', + ' label: Fragment 1', + ' language: markdown', + '---', + '', + '## Fragment: Fragment 1', + '```markdown', + 'Before', + '```', + 'inner block', + '```', + 'After', + '```', + '', + ].join('\n') + + fs.ensureDirSync(path.dirname(absolutePath)) + fs.writeFileSync(absolutePath, source, 'utf8') + + const cache = syncRuntimeWithDisk(paths) + const rewrittenSource = fs.readFileSync(absolutePath, 'utf8') + + expect(cache.snippets[0].contents[0].value).toBe( + ['Before', '```', 'inner block', '```', 'After'].join('\n'), + ) + expect(rewrittenSource).toContain('````markdown') + expect(rewrittenSource).toContain( + ['Before', '```', 'inner block', '```', 'After'].join('\n'), + ) + }) }) diff --git a/src/main/storage/providers/markdown/runtime/constants.ts b/src/main/storage/providers/markdown/runtime/constants.ts index dcdefd617..1297af9b0 100644 --- a/src/main/storage/providers/markdown/runtime/constants.ts +++ b/src/main/storage/providers/markdown/runtime/constants.ts @@ -6,6 +6,7 @@ export const CODE_SPACE_ID = 'code' export const MATH_SPACE_ID = 'math' export const NOTES_SPACE_ID = 'notes' export const HTTP_SPACE_ID = 'http' +export const DRAWINGS_SPACE_ID = 'drawings' export const META_FILE_NAME = '.meta.yaml' export const SPACE_STATE_FILE_NAME = '.state.yaml' export const LEGACY_FOLDER_META_FILE_NAME = '.masscode-folder.yml' @@ -14,6 +15,7 @@ export const PERSISTED_SPACE_IDS = [ MATH_SPACE_ID, NOTES_SPACE_ID, HTTP_SPACE_ID, + DRAWINGS_SPACE_ID, ] as const export const SPACE_IDS = new Set(PERSISTED_SPACE_IDS) @@ -29,6 +31,7 @@ export const RESERVED_ROOT_NAMES = new Set([ MATH_SPACE_ID, NOTES_SPACE_ID, HTTP_SPACE_ID, + DRAWINGS_SPACE_ID, ]) export const NEW_LINE_SPLIT_RE = /\r?\n/ export const SEARCH_DIACRITICS_RE = /[\u0300-\u036F]/g diff --git a/src/main/storage/providers/markdown/runtime/index.ts b/src/main/storage/providers/markdown/runtime/index.ts index ba77f7d07..f5f5c2d4b 100644 --- a/src/main/storage/providers/markdown/runtime/index.ts +++ b/src/main/storage/providers/markdown/runtime/index.ts @@ -35,6 +35,7 @@ export { getVaultPath, hasMarkdownVaultData, normalizeDirectoryPath, + resetPathsCache, } from './paths' // Search @@ -55,6 +56,7 @@ export { readYamlObjectFile, writeYamlObjectFile } from './shared/yaml' export { buildSnippetTargetPath, createSnippetRecord, + ensureSnippetContentLoaded, findSnippetByContentId, findSnippetById, getSnippetTargetDirectory, @@ -89,6 +91,8 @@ export { // Sync & Cache export { getRuntimeCache, + isCodeVaultDiskReady, + refreshPendingSnippetFiles, resetRuntimeCache, setRuntimeCache, syncCounters, @@ -128,6 +132,7 @@ export { assertNotReservedRootFolderName, assertUniqueSiblingEntryName, assertUniqueSiblingFolderName, + assertVaultNotHydrating, getMarkdownStorageErrorMessage, resolveUniqueSiblingFolderName, throwStorageError, diff --git a/src/main/storage/providers/markdown/runtime/parser.ts b/src/main/storage/providers/markdown/runtime/parser.ts index 4d5efd05a..ce1f35a24 100644 --- a/src/main/storage/providers/markdown/runtime/parser.ts +++ b/src/main/storage/providers/markdown/runtime/parser.ts @@ -2,6 +2,7 @@ import type { FolderRecord } from '../../../contracts' import type { MarkdownBodyFragment, MarkdownFolderMetadataFile, + MarkdownFrontmatterContent, MarkdownSnippet, MarkdownSnippetFrontmatter, Paths, @@ -9,12 +10,22 @@ import type { import path from 'node:path' import fs from 'fs-extra' import yaml from 'js-yaml' +import { + enqueueCloudDownload, + prioritizeCloudDownload, +} from '../cloudDownloads' import { LEGACY_FOLDER_META_FILE_NAME, META_FILE_NAME, NEW_LINE_SPLIT_RE, } from './constants' -import { readYamlObjectFile, writeYamlObjectFile } from './shared/yaml' +import { rememberAppFileChange } from './shared/appChanges' +import { getFileAvailability } from './shared/cloudFiles' +import { + isYamlFileCloudUnavailable, + readYamlObjectFile, + writeYamlObjectFile, +} from './shared/yaml' export function readFolderMetadata( paths: Paths, @@ -30,9 +41,22 @@ export function readFolderMetadata( return metaData } + // Недокачанный .meta.yaml — не «метаданных нет»: id папки существует, но + // сейчас неизвестен. Маркер запрещает legacy-миграции писать поверх + // плейсхолдера, а сам файл (крошечный и критичный для стабильности id) + // поднимается в начало очереди докачки. + if (isYamlFileCloudUnavailable(metaPath)) { + prioritizeCloudDownload(metaPath) + return { unavailable: true } + } + // Step 2: Try legacy .masscode-folder.yml const legacyData = readYamlObjectFile(legacyPath) if (!legacyData) { + if (isYamlFileCloudUnavailable(legacyPath)) { + prioritizeCloudDownload(legacyPath) + return { unavailable: true } + } return {} } @@ -46,6 +70,8 @@ export function readFolderMetadata( try { writeYamlObjectFile(metaPath, migrated as Record) fs.removeSync(legacyPath) + rememberAppFileChange(metaPath) + rememberAppFileChange(legacyPath) } catch { // Migration failed — non-critical, we still have the data @@ -68,6 +94,21 @@ export function serializeFolderMetadata( } } +export function isFolderMetadataInSync( + metadata: MarkdownFolderMetadataFile, + folder: FolderRecord, +): boolean { + const payload = serializeFolderMetadata(folder) + const payloadKeys = Object.keys(payload) + const metadataRecord = metadata as Record + + if (Object.keys(metadataRecord).length !== payloadKeys.length) { + return false + } + + return payloadKeys.every(key => metadataRecord[key] === payload[key]) +} + export function writeFolderMetadataFile( paths: Paths, folderRelativePath: string, @@ -86,8 +127,16 @@ export function writeFolderMetadataFile( .trim() const nextContent = `${body}\n` + const availability = getFileAvailability(metaPath) + + // Запись в недокачанный .meta.yaml затёрла бы облачные метаданные папки + // (включая её id): файл сначала докачивается в фоне. + if (availability.isCloudPlaceholder) { + enqueueCloudDownload(metaPath) + return + } - if (fs.pathExistsSync(metaPath)) { + if (availability.exists) { const currentContent = fs.readFileSync(metaPath, 'utf8') if (currentContent === nextContent) { return @@ -96,12 +145,14 @@ export function writeFolderMetadataFile( fs.ensureDirSync(folderAbsPath) fs.writeFileSync(metaPath, nextContent, 'utf8') + rememberAppFileChange(metaPath) // Clean up legacy file if it exists const legacyPath = path.join(folderAbsPath, LEGACY_FOLDER_META_FILE_NAME) if (fs.pathExistsSync(legacyPath)) { try { fs.removeSync(legacyPath) + rememberAppFileChange(legacyPath) } catch { // Non-critical @@ -132,20 +183,59 @@ export function splitFrontmatter(source: string): { } } -export function parseBodyFragments(body: string): MarkdownBodyFragment[] { +interface BodyFragmentParseResult { + fragments: MarkdownBodyFragment[] + legacyRecovery: 'ambiguous' | 'none' | 'recovered' +} + +interface StrictBodyFragmentParseResult { + fragments: MarkdownBodyFragment[] + lastCursor: number +} + +function getFenceLength(line: string): number { + let fenceLength = 0 + + while (fenceLength < line.length && line.charCodeAt(fenceLength) === 96) { + fenceLength += 1 + } + + return fenceLength +} + +function parseFragmentHeader(line: string): string | null { + if (!line.startsWith('## Fragment:')) { + return null + } + + return line.slice('## Fragment:'.length).trim() || 'Fragment' +} + +function hasNonEmptyTail(lines: string[], cursor: number): boolean { + for (let index = cursor; index < lines.length; index += 1) { + if (lines[index].trim()) { + return true + } + } + + return false +} + +function parseBodyFragmentsStrict(body: string): StrictBodyFragmentParseResult { const fragments: MarkdownBodyFragment[] = [] const lines = body.split(NEW_LINE_SPLIT_RE) + let lastCursor = 0 let lineIndex = 0 while (lineIndex < lines.length) { const line = lines[lineIndex] + const label = parseFragmentHeader(line) - if (!line.startsWith('## Fragment:')) { + if (!label) { lineIndex += 1 continue } - const label = line.slice('## Fragment:'.length).trim() || 'Fragment' lineIndex += 1 if (lineIndex >= lines.length) { @@ -153,14 +243,7 @@ export function parseBodyFragments(body: string): MarkdownBodyFragment[] { } const fenceLine = lines[lineIndex] - let fenceLength = 0 - - while ( - fenceLength < fenceLine.length - && fenceLine.charCodeAt(fenceLength) === 96 - ) { - fenceLength += 1 - } + const fenceLength = getFenceLength(fenceLine) if (fenceLength < 3) { continue @@ -180,6 +263,7 @@ export function parseBodyFragments(body: string): MarkdownBodyFragment[] { lineIndex += 1 } + lastCursor = lineIndex fragments.push({ label, language, @@ -195,6 +279,195 @@ export function parseBodyFragments(body: string): MarkdownBodyFragment[] { }) } + return { fragments, lastCursor } +} + +function findLegacyFragmentOpenings( + lines: string[], + metadata: MarkdownFrontmatterContent[], +): number[][] { + return metadata.map((meta) => { + const openings: number[] = [] + + for (let index = 0; index < lines.length - 1; index += 1) { + const label = parseFragmentHeader(lines[index]) + if (!label) { + continue + } + + const fenceLine = lines[index + 1] + const fenceLength = getFenceLength(fenceLine) + if (fenceLength !== 3) { + continue + } + + const language = fenceLine.slice(fenceLength).trim() || 'plain_text' + if (meta.label && label !== meta.label) { + continue + } + + if (meta.language && language !== meta.language) { + continue + } + + openings.push(index) + } + + return openings + }) +} + +function buildLegacyOpeningSequences( + openingsByFragment: number[][], +): number[][] { + const sequences: number[][] = [] + + function visit( + fragmentIndex: number, + previousOpening: number, + sequence: number[], + ): void { + if (fragmentIndex >= openingsByFragment.length) { + sequences.push([...sequence]) + return + } + + for (const openingIndex of openingsByFragment[fragmentIndex]) { + if (openingIndex <= previousOpening) { + continue + } + + sequence.push(openingIndex) + visit(fragmentIndex + 1, openingIndex, sequence) + sequence.pop() + } + } + + visit(0, -1, []) + return sequences +} + +function parseLegacyTripleFenceFragments( + body: string, + metadata: MarkdownFrontmatterContent[], +): BodyFragmentParseResult { + if (metadata.length === 0) { + return { fragments: [], legacyRecovery: 'none' } + } + + const lines = body.split(NEW_LINE_SPLIT_RE) + const openingsByFragment = findLegacyFragmentOpenings(lines, metadata) + if (openingsByFragment.some(openings => openings.length === 0)) { + return { fragments: [], legacyRecovery: 'none' } + } + + const firstOpening = Math.min(...openingsByFragment[0]) + const sequences = buildLegacyOpeningSequences(openingsByFragment).filter( + sequence => sequence[0] === firstOpening, + ) + + const recoveredFragmentsBySignature = new Map< + string, + MarkdownBodyFragment[] + >() + + for (const sequence of sequences) { + const fragments: MarkdownBodyFragment[] = [] + let isValidSequence = true + + for (let index = 0; index < sequence.length; index += 1) { + const openingIndex = sequence[index] + const nextOpeningIndex = sequence[index + 1] ?? lines.length + const closingCandidates: number[] = [] + + for ( + let lineIndex = openingIndex + 2; + lineIndex < nextOpeningIndex; + lineIndex += 1 + ) { + if (lines[lineIndex].trim() === '```') { + closingCandidates.push(lineIndex) + } + } + + const closingIndex = closingCandidates.at(-1) + if (closingIndex === undefined) { + isValidSequence = false + break + } + + const label = parseFragmentHeader(lines[openingIndex]) || 'Fragment' + const fenceLine = lines[openingIndex + 1] + const language = fenceLine.slice(3).trim() || 'plain_text' + + fragments.push({ + label, + language, + value: lines.slice(openingIndex + 2, closingIndex).join('\n'), + }) + } + + if (!isValidSequence) { + continue + } + + recoveredFragmentsBySignature.set(JSON.stringify(fragments), fragments) + } + + if (recoveredFragmentsBySignature.size === 1) { + return { + fragments: [...recoveredFragmentsBySignature.values()][0], + legacyRecovery: 'recovered', + } + } + + if (recoveredFragmentsBySignature.size > 1) { + return { fragments: [], legacyRecovery: 'ambiguous' } + } + + return { fragments: [], legacyRecovery: 'none' } +} + +export function parseBodyFragmentsWithMetadata( + body: string, + metadata: MarkdownFrontmatterContent[], +): BodyFragmentParseResult { + const strictResult = parseBodyFragmentsStrict(body) + const declaredFragmentCount = metadata.length + + if (declaredFragmentCount === 0) { + return { fragments: strictResult.fragments, legacyRecovery: 'none' } + } + + const legacyResult = parseLegacyTripleFenceFragments(body, metadata) + const hasSuspiciousTail + = strictResult.fragments.length >= declaredFragmentCount + && hasNonEmptyTail(body.split(NEW_LINE_SPLIT_RE), strictResult.lastCursor) + const hasLegacyMismatch + = legacyResult.legacyRecovery === 'recovered' + && JSON.stringify(legacyResult.fragments) + !== JSON.stringify(strictResult.fragments) + + if ( + strictResult.fragments.length === declaredFragmentCount + && !hasSuspiciousTail + && !hasLegacyMismatch + ) { + return { fragments: strictResult.fragments, legacyRecovery: 'none' } + } + + if (legacyResult.legacyRecovery === 'recovered') { + return legacyResult + } + + return { + fragments: strictResult.fragments, + legacyRecovery: legacyResult.legacyRecovery, + } +} + +export function parseBodyFragments(body: string): MarkdownBodyFragment[] { + const { fragments } = parseBodyFragmentsStrict(body) return fragments } diff --git a/src/main/storage/providers/markdown/runtime/paths.ts b/src/main/storage/providers/markdown/runtime/paths.ts index 2a5cd96ba..d4ba089df 100644 --- a/src/main/storage/providers/markdown/runtime/paths.ts +++ b/src/main/storage/providers/markdown/runtime/paths.ts @@ -3,6 +3,7 @@ import type { MarkdownState, Paths } from './types' import path from 'node:path' import fs from 'fs-extra' import { store } from '../../../../store' +import { enqueueCloudDownload } from '../cloudDownloads' import { runtimeRef } from './cache' import { CODE_SPACE_ID, @@ -12,6 +13,7 @@ import { STATE_FILE_NAME, TRASH_DIR_NAME, } from './constants' +import { getFileAvailability } from './shared/cloudFiles' import { buildFolderPathMap as buildFolderPathMapShared, buildPathToFolderIdMap as buildPathToFolderIdMapShared, @@ -68,6 +70,16 @@ function readLegacyStateEntries(vaultPath: string): Set { return entries } + // Недокачанный legacy-state нельзя читать (блокировка main process) и + // нельзя трактовать как пустой (это запустило бы миграцию раскладки по + // неполным данным): файл докачивается, решение переносится на потом. + if (getFileAvailability(legacyStatePath).isCloudPlaceholder) { + enqueueCloudDownload(legacyStatePath) + throw new Error( + `CLOUD_FILE_NOT_DOWNLOADED:Vault file is not downloaded from cloud storage yet: ${legacyStatePath}`, + ) + } + try { const raw = fs.readJSONSync(legacyStatePath) as { folders?: unknown[] @@ -226,17 +238,34 @@ function resolveCodeVaultPath(vaultPath: string): string { return codeRootPath } +// Legacy layout checks in resolveCodeVaultPath are expensive (fs scans and +// JSON parsing), so resolved paths are memoized per vault path. The cache is +// reset on vault re-watch (stopMarkdownWatcher) and via resetPathsCache(). +const pathsCacheByVaultPath = new Map() + export function getPaths(vaultPath: string): Paths { + const cachedPaths = pathsCacheByVaultPath.get(vaultPath) + if (cachedPaths) { + return cachedPaths + } + const codeVaultPath = resolveCodeVaultPath(vaultPath) const metaDirPath = path.join(codeVaultPath, META_DIR_NAME) - return { + const paths: Paths = { inboxDirPath: path.join(metaDirPath, INBOX_DIR_NAME), metaDirPath, statePath: path.join(metaDirPath, 'state.json'), trashDirPath: path.join(metaDirPath, TRASH_DIR_NAME), vaultPath: codeVaultPath, } + + pathsCacheByVaultPath.set(vaultPath, paths) + return paths +} + +export function resetPathsCache(): void { + pathsCacheByVaultPath.clear() } export function hasMarkdownVaultData(vaultPath: string): boolean { @@ -246,6 +275,14 @@ export function hasMarkdownVaultData(vaultPath: string): boolean { return false } + // Недокачанный state.json означает, что vault существует и синхронизирован + // из облака: считаем, что данные есть (безопасное направление, миграция + // поверх такого vault не запустится), а файл докачиваем в фоне. + if (getFileAvailability(statePath).isCloudPlaceholder) { + enqueueCloudDownload(statePath) + return true + } + try { const state = fs.readJSONSync(statePath) as MarkdownStateCollectionsLike diff --git a/src/main/storage/providers/markdown/runtime/search.ts b/src/main/storage/providers/markdown/runtime/search.ts index e85bf9bea..d3da704d0 100644 --- a/src/main/storage/providers/markdown/runtime/search.ts +++ b/src/main/storage/providers/markdown/runtime/search.ts @@ -1,10 +1,11 @@ -import type { MarkdownSnippet } from './types' +import type { MarkdownSnippet, Paths } from './types' import { runtimeRef } from './cache' import { buildSearchIndex, invalidateSearchIndex, querySearchIndex, } from './shared/searchEngine' +import { ensureSnippetContentLoaded } from './snippets' export function getSnippetSearchText(snippet: MarkdownSnippet): string { return [ @@ -14,6 +15,28 @@ export function getSnippetSearchText(snippet: MarkdownSnippet): string { ].join('\n') } +// Полнотекстовый поиск требует тел: ленивые записи (построенные из индекса +// без чтения файлов) дочитываются перед построением поискового индекса. +// Стоимость — одно чтение файла на запись при первом поиске за сессию. +function ensureSearchableContentLoaded( + snippets: MarkdownSnippet[], + paths: Paths, +): boolean { + let hasLoadedContent = false + + for (const snippet of snippets) { + if ( + !snippet.pendingCloudDownload + && snippet.contents.some(content => content.value === null) + && ensureSnippetContentLoaded(paths, snippet) + ) { + hasLoadedContent = true + } + } + + return hasLoadedContent +} + export function getSnippetIdsBySearchQuery( snippets: MarkdownSnippet[], searchQuery: string, @@ -21,6 +44,13 @@ export function getSnippetIdsBySearchQuery( const cache = runtimeRef.cache const runtimeCache = cache?.snippets === snippets ? cache : null + if ( + runtimeCache + && ensureSearchableContentLoaded(snippets, runtimeCache.paths) + ) { + invalidateSearchIndex(runtimeCache.searchIndex) + } + if (runtimeCache && runtimeCache.searchIndex.dirty) { runtimeCache.searchIndex = buildSearchIndex(snippets, getSnippetSearchText) } diff --git a/src/main/storage/providers/markdown/runtime/shared/__tests__/appChanges.test.ts b/src/main/storage/providers/markdown/runtime/shared/__tests__/appChanges.test.ts new file mode 100644 index 000000000..45094d576 --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/__tests__/appChanges.test.ts @@ -0,0 +1,64 @@ +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, describe, expect, it } from 'vitest' +import { rememberAppFileChange, wasRecentAppFileChange } from '../appChanges' + +const tempDirs: string[] = [] + +function createTempFilePath(): string { + const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'app-changes-')) + tempDirs.push(dirPath) + return path.join(dirPath, 'note.md') +} + +afterEach(() => { + for (const dirPath of tempDirs.splice(0)) { + fs.removeSync(dirPath) + } +}) + +describe('app file change echo suppression', () => { + it('suppresses the echo of the app own write', () => { + const filePath = createTempFilePath() + fs.writeFileSync(filePath, 'own content', 'utf8') + rememberAppFileChange(filePath) + + expect(wasRecentAppFileChange(filePath)).toBe(true) + }) + + it('does not suppress an external edit inside the TTL window', () => { + const filePath = createTempFilePath() + fs.writeFileSync(filePath, 'own content', 'utf8') + rememberAppFileChange(filePath) + + // Внешняя правка того же файла в окне TTL меняет сигнатуру (размер) — + // событие должно быть обработано, а не проглочено как эхо. + fs.writeFileSync(filePath, 'external content with different size', 'utf8') + + expect(wasRecentAppFileChange(filePath)).toBe(false) + // Запись о собственном изменении сброшена: повторные события тоже + // не считаются эхом. + expect(wasRecentAppFileChange(filePath)).toBe(false) + }) + + it('suppresses the echo of the app own deletion', () => { + const filePath = createTempFilePath() + fs.writeFileSync(filePath, 'own content', 'utf8') + fs.removeSync(filePath) + rememberAppFileChange(filePath) + + expect(wasRecentAppFileChange(filePath)).toBe(true) + }) + + it('does not suppress an external recreation after the app own deletion', () => { + const filePath = createTempFilePath() + fs.writeFileSync(filePath, 'own content', 'utf8') + fs.removeSync(filePath) + rememberAppFileChange(filePath) + + fs.writeFileSync(filePath, 'recreated externally', 'utf8') + + expect(wasRecentAppFileChange(filePath)).toBe(false) + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/shared/__tests__/cloudFiles.test.ts b/src/main/storage/providers/markdown/runtime/shared/__tests__/cloudFiles.test.ts new file mode 100644 index 000000000..530c1e45b --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/__tests__/cloudFiles.test.ts @@ -0,0 +1,132 @@ +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getFileAvailability, + isCloudPlaceholderStats, + markFileReadableDespiteZeroBlocks, + resetCloudFileExemptions, + setDatalessProbeForTests, +} from '../cloudFiles' + +const tempDirs: string[] = [] + +function createTempDir(): string { + const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-files-')) + tempDirs.push(dirPath) + return dirPath +} + +function createSparseFile(dirPath: string, size = 4096): string { + // Sparse-файл воспроизводит сигнатуру облачного плейсхолдера: + // ненулевой size при нулевых blocks. + const filePath = path.join(dirPath, 'sparse.md') + const fd = fs.openSync(filePath, 'w') + fs.ftruncateSync(fd, size) + fs.closeSync(fd) + return filePath +} + +afterEach(() => { + setDatalessProbeForTests(null) + resetCloudFileExemptions() + + for (const dirPath of tempDirs.splice(0)) { + fs.removeSync(dirPath) + } +}) + +describe('getFileAvailability', () => { + it('reports a regular local file as available', () => { + const filePath = path.join(createTempDir(), 'note.md') + fs.writeFileSync(filePath, 'content', 'utf8') + + const availability = getFileAvailability(filePath) + + expect(availability.exists).toBe(true) + expect(availability.isCloudPlaceholder).toBe(false) + expect(availability.stats?.size).toBeGreaterThan(0) + }) + + it('reports a missing file as not existing', () => { + const filePath = path.join(createTempDir(), 'missing.md') + + const availability = getFileAvailability(filePath) + + expect(availability.exists).toBe(false) + expect(availability.isCloudPlaceholder).toBe(false) + expect(availability.stats).toBeNull() + }) + + it('does not flag an empty file as placeholder', () => { + const filePath = path.join(createTempDir(), 'empty.md') + fs.writeFileSync(filePath, '', 'utf8') + + const availability = getFileAvailability(filePath) + + expect(availability.exists).toBe(true) + expect(availability.isCloudPlaceholder).toBe(false) + }) + + it('flags a file with placeholder signature when the probe confirms it', () => { + const filePath = createSparseFile(createTempDir()) + const stats = fs.statSync(filePath) + + if (stats.blocks === 0) { + setDatalessProbeForTests(() => true) + expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(true) + } + + expect( + isCloudPlaceholderStats({ + ...stats, + blocks: 0, + isFile: () => true, + size: 4096, + }), + ).toBe(true) + }) + + it('refutes a local sparse file via the dataless probe', () => { + const filePath = createSparseFile(createTempDir()) + const stats = fs.statSync(filePath) + + if (stats.blocks !== 0) { + return + } + + // Локальный файл с сигнатурой плейсхолдера (inline/resident/sparse) не + // должен навсегда застревать в состоянии «недокачан». + setDatalessProbeForTests(() => false) + expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(false) + }) + + it('treats a verified readable zero-block file as available', () => { + const filePath = createSparseFile(createTempDir()) + const stats = fs.statSync(filePath) + + if (stats.blocks !== 0) { + return + } + + setDatalessProbeForTests(() => true) + expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(true) + + // Ридер успешно прочитал файл: с этого момента он считается локальным, + // пока не изменится (size + mtime + ctime). + markFileReadableDespiteZeroBlocks(filePath, stats) + expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(false) + + const statSpy = vi + .spyOn(fs, 'statSync') + .mockReturnValue(Object.assign(stats, { ctimeMs: stats.ctimeMs + 1 })) + + try { + expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(true) + } + finally { + statSpy.mockRestore() + } + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/shared/__tests__/entityQuery.test.ts b/src/main/storage/providers/markdown/runtime/shared/__tests__/entityQuery.test.ts index 48400c4f6..6a5faa50b 100644 --- a/src/main/storage/providers/markdown/runtime/shared/__tests__/entityQuery.test.ts +++ b/src/main/storage/providers/markdown/runtime/shared/__tests__/entityQuery.test.ts @@ -4,15 +4,16 @@ import { filterAndSortByQuery } from '../entityQuery' interface TestEntity { id: number isDeleted: number + name: string score: number } describe('filterAndSortByQuery', () => { it('applies all filters sequentially', () => { const entities: TestEntity[] = [ - { id: 1, isDeleted: 0, score: 3 }, - { id: 2, isDeleted: 1, score: 2 }, - { id: 3, isDeleted: 0, score: 1 }, + { id: 1, isDeleted: 0, name: 'Charlie', score: 3 }, + { id: 2, isDeleted: 1, name: 'Bravo', score: 2 }, + { id: 3, isDeleted: 0, name: 'Alpha', score: 1 }, ] const result = filterAndSortByQuery({ @@ -30,9 +31,9 @@ describe('filterAndSortByQuery', () => { it('sorts ascending when order is ASC', () => { const entities: TestEntity[] = [ - { id: 1, isDeleted: 0, score: 30 }, - { id: 2, isDeleted: 0, score: 10 }, - { id: 3, isDeleted: 0, score: 20 }, + { id: 1, isDeleted: 0, name: 'Charlie', score: 30 }, + { id: 2, isDeleted: 0, name: 'Bravo', score: 10 }, + { id: 3, isDeleted: 0, name: 'Alpha', score: 20 }, ] const result = filterAndSortByQuery({ @@ -47,9 +48,9 @@ describe('filterAndSortByQuery', () => { it('sorts descending by default and when order is DESC', () => { const entities: TestEntity[] = [ - { id: 1, isDeleted: 0, score: 30 }, - { id: 2, isDeleted: 0, score: 10 }, - { id: 3, isDeleted: 0, score: 20 }, + { id: 1, isDeleted: 0, name: 'Charlie', score: 30 }, + { id: 2, isDeleted: 0, name: 'Bravo', score: 10 }, + { id: 3, isDeleted: 0, name: 'Alpha', score: 20 }, ] const defaultOrderResult = filterAndSortByQuery({ @@ -68,4 +69,28 @@ describe('filterAndSortByQuery', () => { expect(defaultOrderResult.map(entity => entity.id)).toEqual([1, 3, 2]) expect(descOrderResult.map(entity => entity.id)).toEqual([1, 3, 2]) }) + + it('sorts string values', () => { + const entities: TestEntity[] = [ + { id: 1, isDeleted: 0, name: 'Charlie', score: 30 }, + { id: 2, isDeleted: 0, name: 'bravo', score: 10 }, + { id: 3, isDeleted: 0, name: 'Alpha', score: 20 }, + ] + + const ascResult = filterAndSortByQuery({ + entities, + filters: [], + getSortValue: entity => entity.name.toLowerCase(), + query: { order: 'ASC' as const }, + }) + const descResult = filterAndSortByQuery({ + entities, + filters: [], + getSortValue: entity => entity.name.toLowerCase(), + query: { order: 'DESC' as const }, + }) + + expect(ascResult.map(entity => entity.id)).toEqual([3, 2, 1]) + expect(descResult.map(entity => entity.id)).toEqual([1, 2, 3]) + }) }) diff --git a/src/main/storage/providers/markdown/runtime/shared/__tests__/folderSync.test.ts b/src/main/storage/providers/markdown/runtime/shared/__tests__/folderSync.test.ts new file mode 100644 index 000000000..932c8a643 --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/__tests__/folderSync.test.ts @@ -0,0 +1,135 @@ +import type { FolderSyncState, SyncFolderBase } from '../folderSync' +import type { FolderDiskEntry, FolderMetadataSyncSource } from '../folderTypes' +import { describe, expect, it } from 'vitest' +import { syncFoldersStateFromDisk } from '../folderSync' + +function createState( + overrides?: Partial>, +): FolderSyncState { + return { + counters: { folderId: 6 }, + folderUi: {}, + folders: [], + ...overrides, + } +} + +function diskFolder( + folderPath: string, + metadata: FolderMetadataSyncSource = {}, +): FolderDiskEntry { + return { metadata, path: folderPath } +} + +describe('syncFoldersStateFromDisk', () => { + it('keeps folder id from persisted folderIdByPath when metadata is unavailable', () => { + // Холодный старт: state.folders пуст (не персистятся), .meta.yaml + // недокачан из облака — metadata пустые. Без fallback'а папке чеканился + // бы новый id (6 → 7), и все записи со старым folderId «пропадали». + const state = createState({ folderIdByPath: { Go: 6 } }) + + syncFoldersStateFromDisk(state, [diskFolder('Go')], ({ base }) => base) + + expect(state.folders).toHaveLength(1) + expect(state.folders[0].id).toBe(6) + expect(state.counters.folderId).toBe(6) + }) + + it('prefers metadata id over the persisted path fallback', () => { + const state = createState({ folderIdByPath: { Go: 6 } }) + + syncFoldersStateFromDisk( + state, + [diskFolder('Go', { id: 3 })], + ({ base }) => base, + ) + + expect(state.folders[0].id).toBe(3) + }) + + it('mints a stable id for unavailable metadata and converges to the real one', () => { + // Первый запуск после обновления со старого state (15f86fd): ни + // state.folders, ни folderIdByPath ещё нет, а .meta.yaml недокачан. + // Id чеканится (блокировать всё пространство до докачки нельзя), но + // фиксируется в folderIdByPath и не растёт между перезапусками. + const state = createState() + syncFoldersStateFromDisk( + state, + [diskFolder('Go', { unavailable: true })], + ({ base }) => base, + ) + expect(state.folders[0].id).toBe(7) + expect(state.folderIdByPath).toEqual({ Go: 7 }) + + // Второй холодный старт (folders снова пусты, meta всё ещё недокачана): + // id стабилен, счётчик не растёт. + const secondStart = createState({ + counters: { folderId: state.counters.folderId }, + folderIdByPath: { ...state.folderIdByPath }, + }) + syncFoldersStateFromDisk( + secondStart, + [diskFolder('Go', { unavailable: true })], + ({ base }) => base, + ) + expect(secondStart.folders[0].id).toBe(7) + expect(secondStart.counters.folderId).toBe(7) + + // Meta докачалась: её id побеждает, и всё сходится к настоящему. + syncFoldersStateFromDisk( + secondStart, + [diskFolder('Go', { id: 6 })], + ({ base }) => base, + ) + expect(secondStart.folders[0].id).toBe(6) + expect(secondStart.folderIdByPath).toEqual({ Go: 6 }) + }) + + it('never mints an id that collides with metadata ids on disk', () => { + // Счётчик устройства отстаёт от метаданных, пришедших по синку + // (counter = 5, а на диске папка с id 6): чеканка для placeholder-папки + // не должна выдать занятый id — две папки с одним id смешали бы записи, + // а удаление одной унесло бы файлы обеих. + const state = createState({ counters: { folderId: 5 } }) + + syncFoldersStateFromDisk( + state, + [ + // Папка с недокачанной метой обрабатывается ПЕРВОЙ (id чеканится), + // папка с meta id 6 — после: оба направления коллизии закрыты. + diskFolder('Pending', { unavailable: true }), + diskFolder('Synced', { id: 6 }), + ], + ({ base }) => base, + ) + + const ids = state.folders.map(folder => folder.id) + expect(new Set(ids).size).toBe(ids.length) + expect(state.folders.find(f => f.name === 'Synced')?.id).toBe(6) + expect(state.folders.find(f => f.name === 'Pending')?.id).toBe(7) + }) + + it('keeps the known id even when metadata is unavailable', () => { + const state = createState({ folderIdByPath: { Go: 6 } }) + + syncFoldersStateFromDisk( + state, + [diskFolder('Go', { unavailable: true })], + ({ base }) => base, + ) + + expect(state.folders[0].id).toBe(6) + }) + + it('refreshes folderIdByPath after sync for the next cold start', () => { + const state = createState() + + syncFoldersStateFromDisk( + state, + [diskFolder('Go', { id: 2 }), diskFolder('Go/Nested', { id: 5 })], + ({ base }) => base, + ) + + expect(state.folderIdByPath).toEqual({ 'Go': 2, 'Go/Nested': 5 }) + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/shared/appChanges.ts b/src/main/storage/providers/markdown/runtime/shared/appChanges.ts new file mode 100644 index 000000000..f4ec5824e --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/appChanges.ts @@ -0,0 +1,70 @@ +import path from 'node:path' +import fs from 'fs-extra' + +// Own file mutations are remembered briefly so the vault watcher can skip +// re-syncing echoes of the app's own writes: the runtime caches are already +// updated synchronously by the storage layer before files hit the disk. +// +// The entry keeps the file signature (mtime + size) captured right after the +// app's own fs operation: an external edit of the same file inside the TTL +// window changes the signature, so it is re-synced instead of being treated +// as an echo. +const RECENT_APP_CHANGE_TTL_MS = 2500 + +interface RecentAppFileChange { + timestamp: number + // null — файла нет на диске (собственное удаление/перемещение) + signature: string | null +} + +const recentAppFileChanges = new Map() + +function getFileSignature(absolutePath: string): string | null { + try { + const stats = fs.statSync(absolutePath) + return `${stats.mtimeMs}:${stats.size}` + } + catch { + return null + } +} + +// Должен вызываться сразу ПОСЛЕ собственной fs-операции, чтобы сигнатура +// соответствовала состоянию, которое оставило приложение. +export function rememberAppFileChange(absolutePath: string): void { + const now = Date.now() + + for (const [knownPath, entry] of recentAppFileChanges) { + if (now - entry.timestamp > RECENT_APP_CHANGE_TTL_MS) { + recentAppFileChanges.delete(knownPath) + } + } + + const resolvedPath = path.resolve(absolutePath) + recentAppFileChanges.set(resolvedPath, { + signature: getFileSignature(resolvedPath), + timestamp: now, + }) +} + +export function wasRecentAppFileChange(absolutePath: string): boolean { + const resolvedPath = path.resolve(absolutePath) + const entry = recentAppFileChanges.get(resolvedPath) + + if (entry === undefined) { + return false + } + + if (Date.now() - entry.timestamp > RECENT_APP_CHANGE_TTL_MS) { + recentAppFileChanges.delete(resolvedPath) + return false + } + + // Файл изменился после нашей записи — это внешняя правка, а не эхо. + if (getFileSignature(resolvedPath) !== entry.signature) { + recentAppFileChanges.delete(resolvedPath) + return false + } + + return true +} diff --git a/src/main/storage/providers/markdown/runtime/shared/cloudFiles.ts b/src/main/storage/providers/markdown/runtime/shared/cloudFiles.ts new file mode 100644 index 000000000..0301826e4 --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/cloudFiles.ts @@ -0,0 +1,273 @@ +import type { Stats } from 'node:fs' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' +import fs from 'fs-extra' + +export interface FileAvailability { + exists: boolean + /** + * Файл виден в каталоге, но его содержимое ещё не скачано облачным + * провайдером (iCloud Drive, Dropbox, OneDrive, Google Drive): чтение + * такого файла блокируется до докачки из сети. + */ + isCloudPlaceholder: boolean + stats: Stats | null +} + +const SF_DATALESS = 0x40000000 + +type DatalessProbe = (absolutePath: string, stats: Stats) => boolean + +// Переопределение точной проверки dataless для тестов: sparse-файлы +// воспроизводят сигнатуру плейсхолдера, но не имеют флага SF_DATALESS. +let datalessProbeOverride: DatalessProbe | null = null + +export function setDatalessProbeForTests(probe: DatalessProbe | null): void { + datalessProbeOverride = probe +} + +// Ручная симуляция облачного vault без iCloud (только для отладки +// cloud-потоков): файл считается плейсхолдером, пока рядом лежит скрытый +// сайдкар `.<имя>.cloudstub`. «Докачка» симулируется удалением сайдкара — +// содержимое файла при этом настоящее, и гидрация работает end-to-end. +const simulateCloudPlaceholders + = process.env.MASSCODE_SIMULATE_CLOUD_PLACEHOLDERS === '1' + +function hasSimulatedCloudStub(absolutePath: string): boolean { + return fs.existsSync( + path.join( + path.dirname(absolutePath), + `.${path.basename(absolutePath)}.cloudstub`, + ), + ) +} + +// Файлы, у которых сигнатура плейсхолдера (size > 0, blocks === 0) оказалась +// ложной: их содержимое успешно прочитано (inline-файлы btrfs, resident-файлы +// NTFS, сетевые шары с нулевым AllocationSize). Ключ — путь, значение — +// size, mtime и ctime на момент проверки: изменение файла сбрасывает исключение. +const readableZeroBlockFiles = new Map< + string, + { ctimeMs: number, mtimeMs: number, size: number } +>() + +// Кэш точной проверки SF_DATALESS на macOS: spawn стоит миллисекунды, но +// повторные сканы не должны платить её за каждый файл заново. +const datalessCheckCache = new Map< + string, + { isDataless: boolean, mtimeMs: number, size: number } +>() + +export function markFileReadableDespiteZeroBlocks( + absolutePath: string, + stats: Stats, +): void { + readableZeroBlockFiles.set(absolutePath, { + ctimeMs: stats.ctimeMs, + mtimeMs: stats.mtimeMs, + size: stats.size, + }) +} + +export function markAppWrittenFileAsLocal(absolutePath: string): void { + try { + const stats = fs.statSync(absolutePath) + + if (hasPlaceholderSignature(stats)) { + markFileReadableDespiteZeroBlocks(absolutePath, stats) + } + } + catch { + // Запись уже завершена: сбой best-effort stat не должен превращать её + // в ошибку. Файл просто останется без исключения до успешного чтения. + } +} + +export function resetCloudFileExemptions(): void { + readableZeroBlockFiles.clear() + datalessCheckCache.clear() +} + +function hasPlaceholderSignature(stats: Stats): boolean { + return stats.isFile() && stats.size > 0 && stats.blocks === 0 +} + +// Точная проверка на macOS: у настоящего dataless-файла File Provider ядро +// выставляет флаг SF_DATALESS в st_flags. Node не пробрасывает st_flags в +// fs.Stats, поэтому используется системный /usr/bin/stat. Вызов происходит +// только для файлов с сигнатурой плейсхолдера и кэшируется по size+mtime. +function isDatalessOnMacos(absolutePath: string, stats: Stats): boolean { + const cached = datalessCheckCache.get(absolutePath) + if ( + cached + && cached.mtimeMs === stats.mtimeMs + && cached.size === stats.size + ) { + return cached.isDataless + } + + let isDataless = true + try { + const result = spawnSync('/usr/bin/stat', ['-f', '%Xf', absolutePath], { + encoding: 'utf8', + timeout: 3_000, + }) + + if (result.status === 0) { + const flags = Number.parseInt(result.stdout.trim(), 16) + isDataless = Number.isFinite(flags) && (flags & SF_DATALESS) !== 0 + } + } + catch { + // При сбое проверки консервативно считаем файл плейсхолдером: + // это не теряет данные, а лишь отправляет файл в фоновую докачку. + } + + datalessCheckCache.set(absolutePath, { + isDataless, + mtimeMs: stats.mtimeMs, + size: stats.size, + }) + + return isDataless +} + +const PRIME_CHUNK_SIZE = 400 + +// Batch-прайминг точной проверки для полного скана: один spawn системного +// stat на сотни подозрительных файлов вместо отдельного spawn на каждый. +// Вывод `stat -f %Xf` даёт одну строку на файл в порядке аргументов, поэтому +// маппинг делается по индексу; при любом сбое чанк просто не праймится и +// файлы проверяются лениво по одному. +export function primeDatalessChecks(absolutePaths: string[]): void { + if ( + process.platform !== 'darwin' + || datalessProbeOverride + || simulateCloudPlaceholders + ) { + return + } + + const suspicious: { path: string, stats: Stats }[] = [] + + for (const absolutePath of absolutePaths) { + try { + const stats = fs.statSync(absolutePath) + + if (!hasPlaceholderSignature(stats)) { + continue + } + + const cached = datalessCheckCache.get(absolutePath) + if ( + cached + && cached.mtimeMs === stats.mtimeMs + && cached.size === stats.size + ) { + continue + } + + suspicious.push({ path: absolutePath, stats }) + } + catch { + // Файл исчез между листингом и stat: лениво обработается позже. + } + } + + for (let start = 0; start < suspicious.length; start += PRIME_CHUNK_SIZE) { + const chunk = suspicious.slice(start, start + PRIME_CHUNK_SIZE) + + try { + const result = spawnSync( + '/usr/bin/stat', + ['-f', '%Xf', ...chunk.map(entry => entry.path)], + { encoding: 'utf8', timeout: 10_000 }, + ) + + if (result.status !== 0) { + continue + } + + const lines = result.stdout.trim().split('\n') + if (lines.length !== chunk.length) { + continue + } + + chunk.forEach((entry, index) => { + const flags = Number.parseInt(lines[index].trim(), 16) + if (!Number.isFinite(flags)) { + return + } + + datalessCheckCache.set(entry.path, { + isDataless: (flags & SF_DATALESS) !== 0, + mtimeMs: entry.stats.mtimeMs, + size: entry.stats.size, + }) + }) + } + catch { + // Чанк не праймится: файлы проверятся лениво по одному. + } + } +} + +// Плейсхолдер облачного провайдера сообщает через stat полный размер файла, +// но не занимает блоков на диске: на macOS у dataless-файла нет extents, +// на Windows у Cloud Filter placeholder нулевой AllocationSize. Сам stat +// содержимое не докачивает, поэтому проверка безопасна. Ложные срабатывания +// (inline/resident-файлы) отсекаются точной проверкой SF_DATALESS на macOS +// и registry успешно прочитанных файлов на остальных платформах. +export function isCloudPlaceholderStats(stats: Stats): boolean { + return hasPlaceholderSignature(stats) +} + +export function getFileAvailability(absolutePath: string): FileAvailability { + try { + const stats = fs.statSync(absolutePath) + + if (simulateCloudPlaceholders) { + return { + exists: stats.isFile() || stats.isDirectory(), + isCloudPlaceholder: + stats.isFile() && hasSimulatedCloudStub(absolutePath), + stats, + } + } + + let isCloudPlaceholder = hasPlaceholderSignature(stats) + + if (isCloudPlaceholder) { + const exemption = readableZeroBlockFiles.get(absolutePath) + + if ( + exemption + && exemption.ctimeMs === stats.ctimeMs + && exemption.mtimeMs === stats.mtimeMs + && exemption.size === stats.size + ) { + isCloudPlaceholder = false + } + else if (datalessProbeOverride) { + isCloudPlaceholder = datalessProbeOverride(absolutePath, stats) + } + else if (process.platform === 'darwin') { + isCloudPlaceholder = isDatalessOnMacos(absolutePath, stats) + } + } + + return { + exists: stats.isFile() || stats.isDirectory(), + isCloudPlaceholder, + stats, + } + } + catch { + return { + exists: false, + isCloudPlaceholder: false, + stats: null, + } + } +} diff --git a/src/main/storage/providers/markdown/runtime/shared/cloudGuards.ts b/src/main/storage/providers/markdown/runtime/shared/cloudGuards.ts new file mode 100644 index 000000000..5856447f7 --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/cloudGuards.ts @@ -0,0 +1,98 @@ +import fs from 'fs-extra' +import { enqueueCloudDownload } from '../../cloudDownloads' +import { getFileAvailability } from './cloudFiles' + +interface CloudAwareEntity { + pendingCloudDownload?: boolean +} + +export function throwCloudContentUnavailable(): never { + throw new Error( + 'CLOUD_FILE_NOT_DOWNLOADED:This item is still downloading from cloud storage. Try again once the download completes.', + ) +} + +// Мутации сущности, чей файл ещё не скачан облачным провайдером, запрещены +// до докачки: содержимое файла неизвестно, поэтому запись на диск затёрла бы +// облачные данные, а «принятая» без записи правка молча потерялась бы при +// докачке. Ошибка бросается до изменения in-memory кэша. +export function assertEntityContentAvailable( + entity: CloudAwareEntity | null | undefined, +): void { + if (entity?.pendingCloudDownload) { + throwCloudContentUnavailable() + } +} + +// Свежая проверка при выдаче записи наружу: файл мог быть выгружен уже +// после гидрации, и запись в памяти выглядит полной. Плейсхолдер на диске +// помечает запись pending (UI блокирует редактирование оверлеем вместо +// приёма правок, которые отклонятся с 503) и ставит файл в докачку; флаг +// снимет ресинк после докачки. Возвращает true, если запись помечена. +export function markEntityPendingIfEvicted( + absolutePath: string, + entity: CloudAwareEntity | null | undefined, +): boolean { + if (!entity || entity.pendingCloudDownload) { + return false + } + + if (!getFileAvailability(absolutePath).isCloudPlaceholder) { + return false + } + + entity.pendingCloudDownload = true + enqueueCloudDownload(absolutePath) + + return true +} + +// Для ветки «содержимое не дочиталось» в getById: флаг ставится только если +// файл физически существует (placeholder или сбой чтения — докачка уже +// поставлена вызывавшимся ensure*). Отсутствующий файл (внешнее удаление с +// пропущенным unlink-событием) флаг не получает: запись уберёт ближайший +// синк, а залипший pending рисовал бы вечный оверлей и блокировал сохранения +// до перезапуска. +export function markEntityPendingIfFileExists( + absolutePath: string, + entity: CloudAwareEntity | null | undefined, +): void { + if (!entity || entity.pendingCloudDownload) { + return + } + + if (getFileAvailability(absolutePath).exists) { + entity.pendingCloudDownload = true + } +} + +// То же, но со свежим stat диска: флаг pendingCloudDownload устаревает, если +// провайдер выгрузил файл после гидрации (iCloud «Optimize Storage»). +// Вызывается до изменения runtime-кэша, чтобы мутация не «повисала» в памяти +// при отклонённой записи — окно расхождения сужается до stat-vs-write. +// Отсутствующий файл не блокирует: запись создаст его заново. +export function assertEntityFileWritable( + absolutePath: string, + entity: CloudAwareEntity | null | undefined, +): void { + assertEntityContentAvailable(entity) + + // Явный stat перед проверкой плейсхолдера: getFileAvailability превращает + // любую stat-ошибку в exists:false, и EIO/EACCES проходил бы guard как + // «файла нет». Отсутствующий файл (подтверждённый ENOENT) не блокирует: + // запись создаст его заново. + try { + fs.statSync(absolutePath) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + throwCloudContentUnavailable() + } +} diff --git a/src/main/storage/providers/markdown/runtime/shared/directoryEntries.ts b/src/main/storage/providers/markdown/runtime/shared/directoryEntries.ts new file mode 100644 index 000000000..512360bc7 --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/directoryEntries.ts @@ -0,0 +1,63 @@ +import fs from 'fs-extra' + +export type DirectoryEntriesCache = Map + +export function getCachedDirectoryEntries( + directoryPath: string, + directoryEntriesCache?: DirectoryEntriesCache, +): string[] { + if (!directoryEntriesCache) { + return fs.readdirSync(directoryPath) + } + + const cachedEntries = directoryEntriesCache.get(directoryPath) + if (cachedEntries) { + return cachedEntries + } + + const entries = fs.readdirSync(directoryPath) + directoryEntriesCache.set(directoryPath, [...entries]) + return entries +} + +export function removeDirectoryEntryFromCache( + directoryPath: string, + fileName: string, + directoryEntriesCache?: DirectoryEntriesCache, +): void { + if (!directoryEntriesCache) { + return + } + + const entries = directoryEntriesCache.get(directoryPath) + if (!entries) { + return + } + + const normalizedFileName = fileName.toLowerCase() + const nextEntries = entries.filter( + entry => entry.toLowerCase() !== normalizedFileName, + ) + + directoryEntriesCache.set(directoryPath, nextEntries) +} + +export function upsertDirectoryEntryInCache( + directoryPath: string, + fileName: string, + directoryEntriesCache?: DirectoryEntriesCache, +): void { + if (!directoryEntriesCache) { + return + } + + const entries + = directoryEntriesCache.get(directoryPath) || fs.readdirSync(directoryPath) + const normalizedFileName = fileName.toLowerCase() + const nextEntries = entries.filter( + entry => entry.toLowerCase() !== normalizedFileName, + ) + + nextEntries.push(fileName) + directoryEntriesCache.set(directoryPath, nextEntries) +} diff --git a/src/main/storage/providers/markdown/runtime/shared/entityContent.ts b/src/main/storage/providers/markdown/runtime/shared/entityContent.ts index bafa66685..599789a52 100644 --- a/src/main/storage/providers/markdown/runtime/shared/entityContent.ts +++ b/src/main/storage/providers/markdown/runtime/shared/entityContent.ts @@ -1,10 +1,15 @@ +import { assertEntityContentAvailable } from './cloudGuards' + interface EntityWithBodyContent { - content: string + // null — ленивое тело, ещё не дочитанное из индекса метаданных. + content: string | null + pendingCloudDownload?: boolean updatedAt: number } interface OwnerWithNestedContent { contents: TContent[] + pendingCloudDownload?: boolean updatedAt: number } @@ -28,6 +33,8 @@ export function updateEntityBodyContent< return { notFound: true } } + assertEntityContentAvailable(input.entity) + input.entity.content = input.content input.entity.updatedAt = Date.now() input.persistEntity(input.entity) @@ -49,6 +56,8 @@ export function createNestedContent< input.onOwnerNotFound() } + assertEntityContentAvailable(input.owner) + const contentId = input.nextContentId() input.owner.contents.push(input.createContent(contentId)) input.owner.updatedAt = Date.now() @@ -95,6 +104,9 @@ export function updateNestedContent< } const { contentIndex, owner } = input.ownedContent + + assertEntityContentAvailable(owner) + const content = owner.contents[contentIndex] as NestedContentOf input.applyPatch(content, input.patch) diff --git a/src/main/storage/providers/markdown/runtime/shared/entityQuery.ts b/src/main/storage/providers/markdown/runtime/shared/entityQuery.ts index 451f4df11..45db4cc96 100644 --- a/src/main/storage/providers/markdown/runtime/shared/entityQuery.ts +++ b/src/main/storage/providers/markdown/runtime/shared/entityQuery.ts @@ -1,11 +1,12 @@ interface QueryWithOrder { order?: 'ASC' | 'DESC' + sort?: string } interface FilterAndSortByQueryInput { entities: TEntity[] filters: Array<(entity: TEntity, query: TQuery) => boolean> - getSortValue: (entity: TEntity) => number + getSortValue: (entity: TEntity, sort?: string) => number | string query: TQuery } @@ -19,8 +20,13 @@ export function filterAndSortByQuery( input.filters.every(filter => filter(entity, input.query)), ) .sort((a, b) => { - const left = input.getSortValue(a) - const right = input.getSortValue(b) - return order === 'ASC' ? left - right : right - left + const left = input.getSortValue(a, input.query.sort) + const right = input.getSortValue(b, input.query.sort) + const result + = typeof left === 'string' || typeof right === 'string' + ? String(left).localeCompare(String(right)) + : left - right + + return order === 'ASC' ? result : -result }) } diff --git a/src/main/storage/providers/markdown/runtime/shared/entityStorage.ts b/src/main/storage/providers/markdown/runtime/shared/entityStorage.ts index b685e0657..d96047f75 100644 --- a/src/main/storage/providers/markdown/runtime/shared/entityStorage.ts +++ b/src/main/storage/providers/markdown/runtime/shared/entityStorage.ts @@ -1,5 +1,7 @@ import path from 'node:path' import fs from 'fs-extra' +import { rememberAppFileChange } from './appChanges' +import { assertEntityContentAvailable } from './cloudGuards' interface EntityIndexLike { filePath: string @@ -27,6 +29,7 @@ interface UpdatableEntityLike { isDeleted: number isFavorites: number name: string + pendingCloudDownload?: boolean } interface UpdatableEntityInput { @@ -128,6 +131,8 @@ export function applyEntityUpdateFields< } } + assertEntityContentAvailable(input.entity) + const previousIsDeleted = input.entity.isDeleted let pathMayChange = false @@ -200,6 +205,7 @@ function removeFileIfExists(rootPath: string, filePath: string): void { const absolutePath = path.join(rootPath, filePath) if (fs.pathExistsSync(absolutePath)) { fs.removeSync(absolutePath) + rememberAppFileChange(absolutePath) } } @@ -281,6 +287,7 @@ export function emptyEntityTrashFromStateAndDisk< } interface EntityWithTags { + pendingCloudDownload?: boolean tags: number[] updatedAt: number } @@ -304,6 +311,7 @@ export function addTagToEntity( } if (!input.entity.tags.includes(input.tagId)) { + assertEntityContentAvailable(input.entity) input.entity.tags.push(input.tagId) input.entity.updatedAt = Date.now() input.onUpdated(input.entity) @@ -357,6 +365,7 @@ export function deleteTagFromEntity( } } + assertEntityContentAvailable(input.entity) input.entity.tags.splice(tagIndex, 1) input.entity.updatedAt = Date.now() input.onUpdated(input.entity) diff --git a/src/main/storage/providers/markdown/runtime/shared/folderScan.ts b/src/main/storage/providers/markdown/runtime/shared/folderScan.ts index 1229e258f..54b74a25d 100644 --- a/src/main/storage/providers/markdown/runtime/shared/folderScan.ts +++ b/src/main/storage/providers/markdown/runtime/shared/folderScan.ts @@ -1,7 +1,6 @@ import type { FolderDiskEntry, FolderMetadataSyncSource } from './folderTypes' import path from 'node:path' -import fs from 'fs-extra' -import { toPosixPath } from './path' +import { readDirEntriesFailClosed, toPosixPath } from './path' export interface ListUserFoldersOptions< TMetadata extends FolderMetadataSyncSource, @@ -21,11 +20,7 @@ export function listUserFoldersFromDisk< const folders: FolderDiskEntry[] = [] function walk(currentPath: string): void { - if (!fs.pathExistsSync(currentPath)) { - return - } - - const entries = fs.readdirSync(currentPath, { withFileTypes: true }) + const entries = readDirEntriesFailClosed(currentPath) for (const entry of entries) { if (!entry.isDirectory()) { diff --git a/src/main/storage/providers/markdown/runtime/shared/folderSync.ts b/src/main/storage/providers/markdown/runtime/shared/folderSync.ts index e07638a36..f69448813 100644 --- a/src/main/storage/providers/markdown/runtime/shared/folderSync.ts +++ b/src/main/storage/providers/markdown/runtime/shared/folderSync.ts @@ -25,6 +25,10 @@ export interface FolderSyncState { counters: { folderId: number } + // Персистируемый fallback path → id (см. syncFolderIdByPathWithFolders): + // после холодного старта state.folders пуст, и без него недокачанный + // .meta.yaml приводил бы к чеканке нового id для существующей папки. + folderIdByPath?: Record folderUi: Record folders: TFolder[] } @@ -50,8 +54,14 @@ export function syncFoldersStateFromDisk< const oldFoldersById = new Map( state.folders.map(folder => [folder.id, folder]), ) + + // Путь → id: живые folders приоритетнее персистированного fallback'а + // (он нужен после холодного старта, когда state.folders ещё пуст, а + // .meta.yaml каталога может быть недокачан). + const oldFolderIdByPath = new Map( + Object.entries(state.folderIdByPath ?? {}), + ) const oldFolderPathMap = buildFolderPathMap(state.folders) - const oldFolderIdByPath = new Map() oldFolderPathMap.forEach((folderPath, folderId) => { oldFolderIdByPath.set(folderPath, folderId) }) @@ -75,6 +85,31 @@ export function syncFoldersStateFromDisk< ...state.folders.map(folder => folder.id), ) + // Все id из метаданных на диске резервируются заранее: счётчик в state + // может отставать от них (папка пришла по синку с другого устройства), и + // слепая чеканка выдала бы id, который занят или будет занят папкой ниже + // по списку — две папки с одним id смешали бы записи, а удаление одной + // унесло бы файлы обеих. + const reservedMetadataIds = new Set() + for (const diskFolder of orderedDiskFolders) { + const metadataId = normalizePositiveInteger( + diskFolder.metadata.id ?? diskFolder.metadata.masscode_id, + ) + if (metadataId) { + reservedMetadataIds.add(metadataId) + } + } + + const mintFolderId = (): number => { + do { + nextFolderId += 1 + } while ( + usedFolderIds.has(nextFolderId) + || reservedMetadataIds.has(nextFolderId) + ) + return nextFolderId + } + for (const diskFolder of orderedDiskFolders) { const { metadata } = diskFolder const folderPath = diskFolder.path @@ -96,8 +131,14 @@ export function syncFoldersStateFromDisk< } if (!folderId) { - nextFolderId += 1 - folderId = nextFolderId + // Метаданные папки недокачаны (первый запуск после обновления: в state + // ещё нет folderIdByPath) — id чеканится, но остаётся стабильным между + // перезапусками через персистируемый folderIdByPath. Дочерние записи + // получают folderId из пути, поэтому вид согласован; когда meta + // докачается, её id победит на следующей сверке и всё сойдётся. + // Блокировать всё пространство до докачки нельзя: одна застрявшая + // докачка оставила бы его пустым навсегда. + folderId = mintFolderId() } usedFolderIds.add(folderId) @@ -154,6 +195,14 @@ export function syncFoldersStateFromDisk< normalizeFolderOrderIndices(nextFolders) state.folders = nextFolders state.counters.folderId = Math.max(state.counters.folderId, nextFolderId) + + // Свежая карта path → id уходит в state как fallback для следующего + // холодного старта. + const nextFolderIdByPath: Record = {} + pathToFolderId.forEach((folderId, folderPath) => { + nextFolderIdByPath[folderPath] = folderId + }) + state.folderIdByPath = nextFolderIdByPath } export function syncFoldersStateFromDiskAtRoot< @@ -169,7 +218,7 @@ export function syncFoldersStateFromDiskAtRoot< relativePath: string }) => boolean state: FolderSyncState -}): void { +}): FolderDiskEntry[] { const diskFolders = listUserFoldersFromDisk({ readMetadata: input.readMetadata, rootPath: input.rootPath, @@ -177,6 +226,8 @@ export function syncFoldersStateFromDiskAtRoot< }) syncFoldersStateFromDisk(input.state, diskFolders, input.buildFolder) + + return diskFolders } export function syncFolderMetadataFilesByPathMap< diff --git a/src/main/storage/providers/markdown/runtime/shared/folderTypes.ts b/src/main/storage/providers/markdown/runtime/shared/folderTypes.ts index 89df40468..7c1f8b392 100644 --- a/src/main/storage/providers/markdown/runtime/shared/folderTypes.ts +++ b/src/main/storage/providers/markdown/runtime/shared/folderTypes.ts @@ -3,6 +3,10 @@ export interface FolderMetadataSyncSource { id?: unknown masscode_id?: unknown orderIndex?: unknown + // Файл метаданных существует, но недокачан из облака: содержимое (и id) + // сейчас неизвестно — в отличие от отсутствующего файла, для которого + // легитимно чеканится новый id. + unavailable?: boolean updatedAt?: unknown } diff --git a/src/main/storage/providers/markdown/runtime/shared/foldersStorage.ts b/src/main/storage/providers/markdown/runtime/shared/foldersStorage.ts index 08c616378..fd1c5aa73 100644 --- a/src/main/storage/providers/markdown/runtime/shared/foldersStorage.ts +++ b/src/main/storage/providers/markdown/runtime/shared/foldersStorage.ts @@ -1,6 +1,9 @@ import path from 'node:path' import fs from 'fs-extra' -import { throwStorageError } from '../validation' +import { enqueueCloudDownload } from '../../cloudDownloads' +import { assertVaultNotHydrating, throwStorageError } from '../validation' +import { getFileAvailability } from './cloudFiles' +import { throwCloudContentUnavailable } from './cloudGuards' import { buildFolderTree, collectDescendantIds, @@ -10,7 +13,7 @@ import { sortFoldersForTree, type WithChildren, } from './folderIndex' -import { depthOfRelativePath } from './path' +import { depthOfRelativePath, toPosixPath } from './path' export function getFoldersSortedByCreatedAt< T extends { @@ -41,6 +44,7 @@ export interface CreateFolderStateBase { folderId: number } folders: TFolder[] + provisional?: boolean } export interface CreateFolderContext { @@ -66,6 +70,8 @@ export function createFolderInStateAndDisk< folderRelativePath: string id: number } { + assertVaultNotHydrating(input.state) + const folderPathMap = input.buildFolderPathMap(input.state) const folderRelativePath = resolveFolderRelativePath( folderPathMap, @@ -105,6 +111,74 @@ export function getFolderPathsByDepth( .sort((a, b) => depthOfRelativePath(b) - depthOfRelativePath(a)) } +// Каталог удаляемой папки может содержать доменные .md-файлы, которых нет в +// runtime: облачный плейсхолдер с другого устройства, файл со сбоем чтения +// при скане или созданный между сканом и удалением. Рекурсивное удаление +// физически уничтожило бы содержимое, которое пользователь даже не видел, — +// такое удаление отклоняется целиком (fail closed: ошибка обхода тоже +// блокирует, безопасно игнорируется только подтверждённый ENOENT). Известные +// дочерние записи проверяются отдельными preflight'ами вызывающих; +// не-доменные файлы (ассеты, служебные .meta.yaml) сканом не индексируются +// и удаление не блокируют — как и раньше. +export function assertNoUnknownDomainFiles( + rootPath: string, + folderPaths: string[], + knownRelativeFilePaths: ReadonlySet, +): void { + const queue = folderPaths.map(folderPath => + path.join(rootPath, folderPath), + ) + + while (queue.length > 0) { + const currentPath = queue.pop()! + + let entries + try { + entries = fs.readdirSync(currentPath, { withFileTypes: true }) + } + catch (error) { + // Каталог мог исчезнуть между построением списка и обходом; любая + // другая ошибка (EIO, EACCES) означает, что содержимое неизвестно — + // удалять его вслепую нельзя. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + continue + } + throw error + } + + for (const entry of entries) { + const absolutePath = path.join(currentPath, entry.name) + + // Семантика доменного файла зеркалит scanner (listMarkdownFiles): + // скрытые каталоги и файлы, как и не-lowercase `.md`, сканом не + // индексируются — считать их доменными значило бы навсегда блокировать + // удаление папки файлом, который никогда не появится в приложении. + if (entry.name.startsWith('.')) { + continue + } + + if (entry.isDirectory()) { + queue.push(absolutePath) + continue + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) { + continue + } + + const relativePath = toPosixPath(path.relative(rootPath, absolutePath)) + if (knownRelativeFilePaths.has(relativePath)) { + continue + } + + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + enqueueCloudDownload(absolutePath) + } + throwCloudContentUnavailable() + } + } +} + export function removeFolderPathsFromDisk( rootPath: string, folderPaths: string[], diff --git a/src/main/storage/providers/markdown/runtime/shared/guardedRead.ts b/src/main/storage/providers/markdown/runtime/shared/guardedRead.ts new file mode 100644 index 000000000..ecd14c3a4 --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/guardedRead.ts @@ -0,0 +1,30 @@ +import fs from 'fs-extra' +import { prioritizeCloudDownload } from '../../cloudDownloads' +import { getFileAvailability } from './cloudFiles' + +// Синхронное чтение служебного файла vault (state, метаданные папок) с +// защитой от облачных плейсхолдеров: чтение dataless-файла заблокировало бы +// main process до докачки из сети. Вместо блокировки файл ставится в +// очередь фоновой докачки, а каллеру бросается ошибка: текущий скан +// прерывается и повторяется после докачки через обычный sync-конвейер. +export function isCloudFileNotDownloadedError(error: unknown): boolean { + return ( + error instanceof Error + && error.message.startsWith('CLOUD_FILE_NOT_DOWNLOADED') + ) +} + +export function readVaultTextFileSync(absolutePath: string): string { + const availability = getFileAvailability(absolutePath) + + // State-файл — критический путь открытия vault: он поднимается в начало + // очереди докачки, чтобы пространство открылось как можно раньше. + if (availability.isCloudPlaceholder) { + prioritizeCloudDownload(absolutePath) + throw new Error( + `CLOUD_FILE_NOT_DOWNLOADED:Vault file is not downloaded from cloud storage yet: ${absolutePath}`, + ) + } + + return fs.readFileSync(absolutePath, 'utf8') +} diff --git a/src/main/storage/providers/markdown/runtime/shared/path.ts b/src/main/storage/providers/markdown/runtime/shared/path.ts index 947b20642..f610fcb6c 100644 --- a/src/main/storage/providers/markdown/runtime/shared/path.ts +++ b/src/main/storage/providers/markdown/runtime/shared/path.ts @@ -5,6 +5,23 @@ export function toPosixPath(filePath: string): string { return filePath.replaceAll('\\', '/') } +// Листинг каталога для scan-путей: отсутствующий каталог — валидный пустой +// результат, но любая другая ошибка (EIO, EACCES, сбой облачного провайдера) +// бросается. Тихий пустой listing (как у existsSync, который превращает +// stat-ошибку в false) закоммитил бы усечённый индекс как успешный скан — +// без ошибки reconciler не запустит retry. +export function readDirEntriesFailClosed(currentPath: string): fs.Dirent[] { + try { + return fs.readdirSync(currentPath, { withFileTypes: true }) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + } +} + export function depthOfRelativePath(relativePath: string): number { if (!relativePath) { return 0 @@ -34,11 +51,7 @@ export function listMarkdownFiles( skipDirNames?: Set, currentPath = rootPath, ): string[] { - if (!fs.pathExistsSync(currentPath)) { - return [] - } - - const entries = fs.readdirSync(currentPath, { withFileTypes: true }) + const entries = readDirEntriesFailClosed(currentPath) const files: string[] = [] const isRoot = currentPath === rootPath diff --git a/src/main/storage/providers/markdown/runtime/shared/stateAdapter.ts b/src/main/storage/providers/markdown/runtime/shared/stateAdapter.ts index d19547fb8..b5c030cf5 100644 --- a/src/main/storage/providers/markdown/runtime/shared/stateAdapter.ts +++ b/src/main/storage/providers/markdown/runtime/shared/stateAdapter.ts @@ -1,6 +1,7 @@ import fs from 'fs-extra' import { stateContentCacheByPath } from '../cache' import { normalizeFlag, normalizeFolderUiState } from '../normalizers' +import { readVaultTextFileSync } from './guardedRead' import { flushPendingStateWriteByPath, registerStateWriteHooks, @@ -10,6 +11,7 @@ import { interface StateWithFolderUi { folders: { id: number, isOpen: number }[] folderUi: Record + provisional?: boolean version: number } @@ -62,7 +64,12 @@ export function createStateAdapter< ensureStateFile(paths) const defaults = config.createDefaultState() - const raw = fs.readJSONSync(paths.statePath) as TStateFile + // Guarded-чтение: недокачанный state.json прерывает скан ошибкой вместо + // блокировки main process (и вместо чеканки дефолтного state, которая + // раздала бы всем записям новые id). + const raw = JSON.parse( + readVaultTextFileSync(paths.statePath), + ) as TStateFile const state = config.parseRawState(raw, defaults) // Legacy folderUi migration @@ -86,6 +93,13 @@ export function createStateAdapter< state: TState, options?: { immediate?: boolean }, ): void { + // Provisional state существует только пока state-файл не докачан из + // облака: записать его — значит затереть настоящий индекс и счётчики + // почти пустым состоянием. + if (state.provisional) { + return + } + config.onBeforeSave?.(state) const nextVersion = Math.max(state.version, config.minVersion) diff --git a/src/main/storage/providers/markdown/runtime/shared/stateUtils.ts b/src/main/storage/providers/markdown/runtime/shared/stateUtils.ts index 454310f3f..a5af0038e 100644 --- a/src/main/storage/providers/markdown/runtime/shared/stateUtils.ts +++ b/src/main/storage/providers/markdown/runtime/shared/stateUtils.ts @@ -1,4 +1,5 @@ import { normalizeFlag } from '../normalizers' +import { buildFolderPathMap } from './folderIndex' interface FolderUiSyncable { folders: { id: number, isOpen: number }[] @@ -16,3 +17,33 @@ export function syncFolderUiWithFolders(state: FolderUiSyncable): void { state.folderUi = nextFolderUi } + +interface FolderIdByPathSyncable { + folderIdByPath?: Record + folders: { + id: number + name: string + orderIndex: number + parentId: number | null + }[] +} + +// Персистируемый fallback path → folder id: сами folders в state.json не +// хранятся (источник истины — .meta.yaml каталогов), но при недокачанном +// .meta.yaml scan иначе чеканил бы папке новый id на каждом холодном старте, +// а записи со старым folderId выглядели бы пропавшими. Пустой список папок +// (provisional-кэш) не затирает сохранённую карту. +export function syncFolderIdByPathWithFolders( + state: FolderIdByPathSyncable, +): void { + if (!state.folders.length) { + return + } + + const nextFolderIdByPath: Record = {} + buildFolderPathMap(state.folders).forEach((folderPath, folderId) => { + nextFolderIdByPath[folderPath] = folderId + }) + + state.folderIdByPath = nextFolderIdByPath +} diff --git a/src/main/storage/providers/markdown/runtime/shared/stateWriter.ts b/src/main/storage/providers/markdown/runtime/shared/stateWriter.ts index 4f8868657..d6f787395 100644 --- a/src/main/storage/providers/markdown/runtime/shared/stateWriter.ts +++ b/src/main/storage/providers/markdown/runtime/shared/stateWriter.ts @@ -1,12 +1,17 @@ import path from 'node:path' import process from 'node:process' import fs from 'fs-extra' +import { enqueueCloudDownload } from '../../cloudDownloads' import { pendingStateWriteByPath, stateContentCacheByPath, stateFlushTimerByPath, } from '../cache' import { STATE_WRITE_DEBOUNCE_MS } from '../constants' +import { rememberAppFileChange } from './appChanges' +import { getFileAvailability } from './cloudFiles' + +const CLOUD_STATE_FLUSH_RETRY_MS = 5_000 let hooksRegistered = false @@ -16,9 +21,15 @@ function getPersistedContent(statePath: string): string { return cached } - const content = fs.pathExistsSync(statePath) - ? fs.readFileSync(statePath, 'utf8') - : '' + const availability = getFileAvailability(statePath) + + // Плейсхолдер не читается и не кэшируется: сравнение с пустой строкой + // оставит запись в pending, а flushPath не станет писать до докачки. + if (availability.isCloudPlaceholder) { + return '' + } + + const content = availability.exists ? fs.readFileSync(statePath, 'utf8') : '' stateContentCacheByPath.set(statePath, content) return content @@ -35,10 +46,23 @@ function flushPath(statePath: string): void { stateFlushTimerByPath.delete(statePath) } + // State-файл вытеснен в облако: запись затёрла бы недокачанную версию. + // Запись остаётся в pending и повторяется после докачки. + if (getFileAvailability(statePath).isCloudPlaceholder) { + enqueueCloudDownload(statePath) + const retryTimer = setTimeout( + () => flushPath(statePath), + CLOUD_STATE_FLUSH_RETRY_MS, + ) + stateFlushTimerByPath.set(statePath, retryTimer) + return + } + const persistedContent = getPersistedContent(statePath) if (persistedContent !== pendingContent) { fs.ensureDirSync(path.dirname(statePath)) fs.writeFileSync(statePath, pendingContent, 'utf8') + rememberAppFileChange(statePath) } stateContentCacheByPath.set(statePath, pendingContent) diff --git a/src/main/storage/providers/markdown/runtime/shared/tags.ts b/src/main/storage/providers/markdown/runtime/shared/tags.ts index 25b186b27..15bc653b6 100644 --- a/src/main/storage/providers/markdown/runtime/shared/tags.ts +++ b/src/main/storage/providers/markdown/runtime/shared/tags.ts @@ -1,3 +1,5 @@ +import { assertVaultNotHydrating } from '../validation' + export interface TagLike { createdAt: number id: number @@ -9,6 +11,7 @@ export interface TagStateLike { counters: { tagId: number } + provisional?: boolean tags: TTag[] } @@ -35,6 +38,8 @@ export function createTagInState< name: string, createTag: (input: { id: number, name: string, now: number }) => TTag, ): { id: number } { + assertVaultNotHydrating(state) + state.counters.tagId += 1 const id = state.counters.tagId const now = Date.now() diff --git a/src/main/storage/providers/markdown/runtime/shared/timestamp.ts b/src/main/storage/providers/markdown/runtime/shared/timestamp.ts index 47bbc2921..75ec99a86 100644 --- a/src/main/storage/providers/markdown/runtime/shared/timestamp.ts +++ b/src/main/storage/providers/markdown/runtime/shared/timestamp.ts @@ -1,3 +1,4 @@ +import type { Stats } from 'node:fs' import fs from 'fs-extra' function isFinitePositiveTimestamp(value: unknown): value is number { @@ -30,9 +31,10 @@ export function normalizeTimestamp(value: unknown, fallback: number): number { export function getFileTimestampFallbacks( absolutePath: string, now: number, + knownStats?: Stats | null, ): { createdAt: number, updatedAt: number } { try { - const stats = fs.statSync(absolutePath) + const stats = knownStats ?? fs.statSync(absolutePath) const updatedAt = isFinitePositiveTimestamp(stats.mtimeMs) ? stats.mtimeMs : now diff --git a/src/main/storage/providers/markdown/runtime/shared/vaultReconcile.ts b/src/main/storage/providers/markdown/runtime/shared/vaultReconcile.ts new file mode 100644 index 000000000..f47cca59a --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/shared/vaultReconcile.ts @@ -0,0 +1,172 @@ +import fsp from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { BrowserWindow } from 'electron' +import { scheduleDockBadgeRefresh } from '../../../../../dockBadge' +import { log } from '../../../../../utils' + +// Первый обход только что открытого vault опасен синхронно: листинг +// dataless-каталога облачного провайдера материализуется сетевым запросом, +// и readdirSync блокировал бы main process на каждый каталог. Поэтому +// первый доступ к vault отдаёт мгновенный provisional-кэш из state-индекса +// (без обращений к диску), а обход каталогов выполняется здесь асинхронно: +// материализация происходит в UV threadpool, event loop живёт. После +// обхода запускается обычный синхронный sync (уже быстрый: листинги +// локальны, содержимое плейсхолдеров не читается) и renderer получает +// событие обновления. + +const SKIP_WALK_DIR_NAMES = new Set(['.git', 'node_modules']) + +export function broadcastStorageSynced(): void { + scheduleDockBadgeRefresh() + + try { + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send('system:storage-synced') + }) + } + catch { + // В тестовом окружении BrowserWindow может быть не замокан. + } +} + +async function materializeDirectoryListings(rootPath: string): Promise { + const queue: string[] = [rootPath] + + while (queue.length > 0) { + const currentPath = queue.shift()! + + let entries + try { + entries = await fsp.readdir(currentPath, { withFileTypes: true }) + } + catch (error) { + // Пропущенный каталог не валит прогрев: настоящий скан в runSync + // прочитает его сам (или упадёт, и attempt ретрайнет). Но след в + // логе нужен — молча неполный обход маскировал бы проблемы диска. + log('storage:reconcile:readdir', error) + continue + } + + for (const entry of entries) { + if (entry.isDirectory() && !SKIP_WALK_DIR_NAMES.has(entry.name)) { + queue.push(path.join(currentPath, entry.name)) + } + } + } +} + +export interface VaultReconciler { + // Останавливает ретраи сверки брошенного корня (смена vault): без отмены + // цикл продолжал бы попытки по неактивному пути и слал storage-synced. + abandon: (rootPath: string) => void + begin: (rootPath: string, runSync: () => void) => void + isReconciled: (rootPath: string) => boolean +} + +// Пауза перед повтором сверки, когда её заблокировал недокачанный +// служебный файл (state.json / .state.yaml сам может быть плейсхолдером). +const RECONCILE_RETRY_MS = 3_000 + +// Прочие ошибки (EIO, битый state и т.п.) ретраятся с экспоненциальным +// backoff: transient-сбой не должен навсегда оставлять пространство на +// пустом provisional-кэше (повторного begin() при живом кэше уже не будет). +const RECONCILE_MAX_RETRY_MS = 60_000 + +function isCloudFileNotDownloadedError(error: unknown): boolean { + return ( + error instanceof Error + && error.message.startsWith('CLOUD_FILE_NOT_DOWNLOADED') + ) +} + +export function createVaultReconciler(label: string): VaultReconciler { + const reconciledRoots = new Set() + const pendingRoots = new Set() + // Поколение цикла на корень: abandon() и каждый новый begin() его + // инкрементируют, и застрявший в await старый attempt после пробуждения + // видит чужое поколение и выходит. Флаг-набор здесь не годится: быстрый + // возврат на тот же vault снимал бы отметку до пробуждения старого цикла, + // и тот выполнял бы дублирующий runSync с повторным broadcast. + const generationByRoot = new Map() + + // В тестах vault всегда локальный, а существующие тесты ожидают + // синхронное поведение sync-функций. + const bypass = process.env.VITEST !== undefined + + return { + abandon(rootPath: string): void { + if (pendingRoots.has(rootPath)) { + generationByRoot.set( + rootPath, + (generationByRoot.get(rootPath) ?? 0) + 1, + ) + pendingRoots.delete(rootPath) + } + }, + + isReconciled(rootPath: string): boolean { + return bypass || reconciledRoots.has(rootPath) + }, + + begin(rootPath: string, runSync: () => void): void { + if (pendingRoots.has(rootPath)) { + return + } + + pendingRoots.add(rootPath) + + const generation = (generationByRoot.get(rootPath) ?? 0) + 1 + generationByRoot.set(rootPath, generation) + const isStale = (): boolean => + generationByRoot.get(rootPath) !== generation + + let backoffMs = RECONCILE_RETRY_MS + + const attempt = async (): Promise => { + if (isStale()) { + return + } + + try { + await materializeDirectoryListings(rootPath) + + if (isStale()) { + return + } + + runSync() + + // Успех фиксируется только после того, как настоящая сверка + // прошла: до этого isReconciled остаётся false, и обращения к + // vault продолжают получать provisional-кэш. + reconciledRoots.add(rootPath) + pendingRoots.delete(rootPath) + broadcastStorageSynced() + } + catch (error) { + // Служебный файл (state) ещё не докачан из облака: сверка + // невозможна, но файл уже поставлен в приоритетную докачку — + // повторяем попытку, пока он не станет доступен. + if (isCloudFileNotDownloadedError(error)) { + setTimeout(() => { + void attempt() + }, RECONCILE_RETRY_MS) + return + } + + // Любая другая ошибка тоже ретраится (с backoff): отказ здесь + // означал бы пустое пространство до перезапуска приложения, так + // как повторного begin() при существующем кэше не происходит. + log(`storage:${label}:reconcile`, error) + setTimeout(() => { + void attempt() + }, backoffMs) + backoffMs = Math.min(backoffMs * 2, RECONCILE_MAX_RETRY_MS) + } + } + + void attempt() + }, + } +} diff --git a/src/main/storage/providers/markdown/runtime/shared/yaml.ts b/src/main/storage/providers/markdown/runtime/shared/yaml.ts index 8a382e7cf..3d6cfc26e 100644 --- a/src/main/storage/providers/markdown/runtime/shared/yaml.ts +++ b/src/main/storage/providers/markdown/runtime/shared/yaml.ts @@ -1,9 +1,31 @@ import path from 'node:path' import fs from 'fs-extra' import yaml from 'js-yaml' +import { enqueueCloudDownload } from '../../cloudDownloads' +import { getFileAvailability } from './cloudFiles' + +// Файл существует, но недокачан из облака: содержимое сейчас не прочитать. +// Отличать от отсутствующего файла обязаны вызывающие, для которых «нет +// файла» и «файл есть, но неизвестен» имеют разные последствия (например, +// чеканка нового folder id). +export function isYamlFileCloudUnavailable(filePath: string): boolean { + const availability = getFileAvailability(filePath) + return availability.exists && availability.isCloudPlaceholder +} export function readYamlObjectFile(filePath: string): T | null { - if (!fs.pathExistsSync(filePath)) { + const availability = getFileAvailability(filePath) + + if (!availability.exists) { + return null + } + + // Недокачанный метаданные-файл не прерывает скан: он уходит в фоновую + // докачку, а до неё папка живёт на данных из state (id сохраняется по + // пути). Перезапись дефолтами исключена: writeFolderMetadataFile не пишет + // в плейсхолдеры. + if (availability.isCloudPlaceholder) { + enqueueCloudDownload(filePath) return null } diff --git a/src/main/storage/providers/markdown/runtime/snippets.ts b/src/main/storage/providers/markdown/runtime/snippets.ts index 84a6b7449..74b95b876 100644 --- a/src/main/storage/providers/markdown/runtime/snippets.ts +++ b/src/main/storage/providers/markdown/runtime/snippets.ts @@ -1,14 +1,18 @@ import type { SnippetRecord } from '../../../contracts' +import type { FileAvailability } from './shared/cloudFiles' import type { DirectoryEntriesCache, MarkdownSnippet, MarkdownSnippetIndexItem, + MarkdownSnippetIndexMetadata, MarkdownState, Paths, PersistSnippetOptions, } from './types' import path from 'node:path' import fs from 'fs-extra' +import { log } from '../../../../utils' +import { enqueueCloudDownload } from '../cloudDownloads' import { runtimeRef } from './cache' import { INBOX_DIR_NAME, @@ -21,7 +25,7 @@ import { } from './constants' import { normalizeNullableNumber, normalizeNumber } from './normalizers' import { - parseBodyFragments, + parseBodyFragmentsWithMetadata, serializeSnippet, splitFrontmatter, } from './parser' @@ -31,7 +35,19 @@ import { getFolderPathById, normalizeDirectoryPath, } from './paths' +import { rememberAppFileChange } from './shared/appChanges' +import { + getFileAvailability, + markAppWrittenFileAsLocal, +} from './shared/cloudFiles' +import { throwCloudContentUnavailable } from './shared/cloudGuards' +import { + getCachedDirectoryEntries, + removeDirectoryEntryFromCache, + upsertDirectoryEntryInCache, +} from './shared/directoryEntries' import { listMarkdownFiles as listMarkdownFilesShared } from './shared/path' +import { invalidateSearchIndex } from './shared/searchEngine' import { getFileTimestampFallbacks, normalizeTimestamp, @@ -64,39 +80,142 @@ export function listMarkdownFiles(rootPath: string): string[] { ) } +// 'unreadable' означает, что файл существует, но его содержимое сейчас +// недоступно (облачный плейсхолдер или сбой чтения). Каллеры обязаны +// пропустить такой файл до фоновой докачки: null здесь означал бы «id нет» +// и привёл бы к чеканке нового id, расходящегося с frontmatter-id файла. export function readFrontmatterIdFromSnippetFile( snippetPath: string, -): number | null { - if (!fs.pathExistsSync(snippetPath)) { +): number | null | 'unreadable' { + const availability = getFileAvailability(snippetPath) + + if (!availability.exists) { return null } - const source = fs.readFileSync(snippetPath, 'utf8') - const { frontmatter } = splitFrontmatter(source) - const id = normalizeNumber(frontmatter.id) + if (availability.isCloudPlaceholder) { + return 'unreadable' + } + + try { + const source = fs.readFileSync(snippetPath, 'utf8') + const { frontmatter } = splitFrontmatter(source) + const id = normalizeNumber(frontmatter.id) - return id > 0 ? id : null + return id > 0 ? id : null + } + catch { + return 'unreadable' + } } export function readSnippetFromFile( paths: Paths, entry: MarkdownSnippetIndexItem, pathToFolderIdMap: ReadonlyMap, + knownAvailability?: FileAvailability, ): MarkdownSnippet | null { + return ( + readSnippetFromFileWithMetadata( + paths, + entry, + pathToFolderIdMap, + knownAvailability, + )?.snippet ?? null + ) +} + +export function buildPlaceholderSnippet( + entry: MarkdownSnippetIndexItem, + pathToFolderIdMap: ReadonlyMap, + timestampFallbacks: { createdAt: number, updatedAt: number }, +): MarkdownSnippet { + const normalizedFileDirectory = normalizeDirectoryPath( + path.posix.dirname(entry.filePath), + ) + const isTrashed = isTrashSnippetDirectory(normalizedFileDirectory) + const folderId + = isTrashed || isInboxSnippetDirectory(normalizedFileDirectory) + ? null + : (pathToFolderIdMap.get(normalizedFileDirectory) ?? null) + + return { + contents: [], + createdAt: timestampFallbacks.createdAt, + description: null, + filePath: entry.filePath, + folderId, + id: entry.id, + isDeleted: isTrashed ? 1 : 0, + isFavorites: 0, + name: path.posix.basename(entry.filePath, '.md'), + pendingCloudDownload: true, + tags: [], + updatedAt: timestampFallbacks.updatedAt, + } +} + +export function readSnippetFromFileWithMetadata( + paths: Paths, + entry: MarkdownSnippetIndexItem, + pathToFolderIdMap: ReadonlyMap, + knownAvailability?: FileAvailability, +): { + legacyRecovery: 'ambiguous' | 'none' | 'recovered' + snippet: MarkdownSnippet +} | null { const snippetPath = path.join(paths.vaultPath, entry.filePath) + // Горячий путь скана уже статил файл: повторный stat не нужен. + const availability = knownAvailability ?? getFileAvailability(snippetPath) - if (!fs.pathExistsSync(snippetPath)) { + if (!availability.exists) { return null } - const source = fs.readFileSync(snippetPath, 'utf8') - const { body, frontmatter, hasFrontmatter } = splitFrontmatter(source) const now = Date.now() - const timestampFallbacks = getFileTimestampFallbacks(snippetPath, now) - const fragments = parseBodyFragments(body) + const timestampFallbacks = getFileTimestampFallbacks( + snippetPath, + now, + availability.stats, + ) + + let source: string | null = null + + if (!availability.isCloudPlaceholder) { + try { + source = fs.readFileSync(snippetPath, 'utf8') + } + catch (error) { + // Сорвавшееся чтение (обрыв облачного провайдера, EIO и т.п.) не + // валит весь скан: запись обрабатывается как недокачанная. + log('storage:markdown:read-snippet', error) + } + } + + // Плейсхолдер (или файл со сбоем чтения) не читается синхронно: сниппет + // сразу показывается в списке по данным индекса и имени файла, + // содержимое докачивается в фоне. + if (source === null) { + enqueueCloudDownload(snippetPath) + + return { + legacyRecovery: 'none', + snippet: buildPlaceholderSnippet( + entry, + pathToFolderIdMap, + timestampFallbacks, + ), + } + } + + const { body, frontmatter, hasFrontmatter } = splitFrontmatter(source) const metaContents = Array.isArray(frontmatter.contents) ? frontmatter.contents : [] + const { fragments, legacyRecovery } = parseBodyFragmentsWithMetadata( + body, + metaContents, + ) const contents = fragments.length ? fragments.map((fragment, index) => { @@ -169,40 +288,322 @@ export function readSnippetFromFile( } if (!hasFrontmatter) { - writeSnippetToFile(paths, snippet) + writeSnippetToFile(paths, snippet, { skipIfUnavailable: true }) } - return snippet + return { legacyRecovery, snippet } +} + +// Метаданные индекса собираются только по реально прочитанному файлу, а +// stat-сигнатура — по stat до чтения. Записи приложения не обновляют meta: +// изменённый mtime просто заставит перечитать файл на следующем старте. +export function buildSnippetIndexMetadata( + snippet: MarkdownSnippet, + stats: { mtimeMs: number, size: number }, +): MarkdownSnippetIndexMetadata { + return { + contents: snippet.contents.map(({ id, label, language }) => ({ + id, + label, + language, + })), + createdAt: snippet.createdAt, + description: snippet.description, + isDeleted: snippet.isDeleted, + isFavorites: snippet.isFavorites, + mtimeMs: stats.mtimeMs, + name: snippet.name, + size: stats.size, + tags: [...snippet.tags], + updatedAt: snippet.updatedAt, + } +} + +// state.json синхронизируется между устройствами и правится извне, поэтому +// метаданные индекса перед использованием проверяются по форме: битая запись +// не роняет скан, файл просто перечитывается. +function isValidSnippetIndexMetadata( + meta: MarkdownSnippetIndexMetadata | undefined, +): meta is MarkdownSnippetIndexMetadata { + return ( + !!meta + && typeof meta === 'object' + && Array.isArray(meta.contents) + && meta.contents.every( + content => + content + && typeof content === 'object' + && typeof content.id === 'number' + && typeof content.label === 'string' + && typeof content.language === 'string', + ) + && Array.isArray(meta.tags) + && typeof meta.name === 'string' + && typeof meta.mtimeMs === 'number' + && Number.isFinite(meta.mtimeMs) + && typeof meta.size === 'number' + && Number.isFinite(meta.size) + && typeof meta.createdAt === 'number' + && typeof meta.updatedAt === 'number' + ) +} + +export function hasFreshSnippetIndexMetadata( + entry: MarkdownSnippetIndexItem, + stats: { mtimeMs: number, size: number } | null, +): boolean { + return ( + !!stats + && isValidSnippetIndexMetadata(entry.meta) + && entry.meta.mtimeMs === stats.mtimeMs + && entry.meta.size === stats.size + ) +} + +// Запись списка из метаданных индекса, без чтения файла: тела фрагментов +// остаются value: null и дочитываются лениво (ensureSnippetContentLoaded). +export function buildSnippetFromIndexMetadata( + entry: MarkdownSnippetIndexItem, + meta: MarkdownSnippetIndexMetadata, + pathToFolderIdMap: ReadonlyMap, + options?: { pendingCloudDownload?: boolean }, +): MarkdownSnippet { + const normalizedFileDirectory = normalizeDirectoryPath( + path.posix.dirname(entry.filePath), + ) + const isTrashed = isTrashSnippetDirectory(normalizedFileDirectory) + const folderId + = isTrashed || isInboxSnippetDirectory(normalizedFileDirectory) + ? null + : (pathToFolderIdMap.get(normalizedFileDirectory) ?? null) + + return { + contents: meta.contents.map(content => ({ + id: content.id, + label: content.label, + language: content.language, + value: null, + })), + createdAt: meta.createdAt, + description: meta.description ?? null, + filePath: entry.filePath, + folderId, + id: entry.id, + isDeleted: isTrashed ? 1 : normalizeNumber(meta.isDeleted), + isFavorites: normalizeNumber(meta.isFavorites), + name: meta.name, + ...(options?.pendingCloudDownload ? { pendingCloudDownload: true } : {}), + tags: meta.tags.filter(tagId => typeof tagId === 'number' && tagId > 0), + updatedAt: meta.updatedAt, + } +} + +// Дочитывает тела фрагментов записи, построенной из индекса. Заполняются +// только value === null: метаданные runtime-объекта авторитетны и могут +// содержать ещё не сохранённые правки. Возвращает false, если содержимое +// сейчас недоступно (плейсхолдер, сбой чтения). +export function ensureSnippetContentLoaded( + paths: Paths, + snippet: MarkdownSnippet, +): boolean { + if (snippet.pendingCloudDownload) { + return false + } + + if (!snippet.contents.some(content => content.value === null)) { + return true + } + + const snippetPath = path.join(paths.vaultPath, snippet.filePath) + const availability = getFileAvailability(snippetPath) + + if (!availability.exists) { + return false + } + + if (availability.isCloudPlaceholder) { + enqueueCloudDownload(snippetPath) + return false + } + + let source: string + try { + source = fs.readFileSync(snippetPath, 'utf8') + } + catch (error) { + log('storage:markdown:load-snippet-content', error) + enqueueCloudDownload(snippetPath) + return false + } + + const { body, frontmatter } = splitFrontmatter(source) + const metaContents = Array.isArray(frontmatter.contents) + ? frontmatter.contents + : [] + const { fragments } = parseBodyFragmentsWithMetadata(body, metaContents) + + snippet.contents.forEach((content, index) => { + if (content.value === null) { + content.value = fragments[index]?.value ?? '' + } + }) + + // Тело догружено после построения поискового индекса: индекс мог быть + // собран без тел, и body-запросы не находили запись. Любая гидрация + // (открытие записи, preview, поиск) помечает индекс dirty. + const cache = runtimeRef.cache + if (cache?.snippetById.get(snippet.id) === snippet) { + invalidateSearchIndex(cache.searchIndex) + } + + return true } export function loadSnippets( paths: Paths, state: MarkdownState, + options?: { rewriteRecoveredLegacyFences?: boolean }, ): MarkdownSnippet[] { const pathToFolderIdMap = buildPathToFolderIdMap(state) return state.snippets - .map(item => readSnippetFromFile(paths, item, pathToFolderIdMap)) + .map((item) => { + const snippetPath = path.join(paths.vaultPath, item.filePath) + const availability = getFileAvailability(snippetPath) + + if (!availability.exists) { + return null + } + + // Свежая stat-сигнатура: запись строится из индекса без чтения файла, + // тело дочитывается лениво по первому обращению. + if ( + !availability.isCloudPlaceholder + && hasFreshSnippetIndexMetadata(item, availability.stats) + ) { + return buildSnippetFromIndexMetadata( + item, + item.meta!, + pathToFolderIdMap, + ) + } + + // Плейсхолдер с известными метаданными: полноценная запись списка без + // чтения (и без сетевой материализации), контент докачивается в фоне. + if ( + availability.isCloudPlaceholder + && isValidSnippetIndexMetadata(item.meta) + ) { + enqueueCloudDownload(snippetPath) + return buildSnippetFromIndexMetadata( + item, + item.meta, + pathToFolderIdMap, + { + pendingCloudDownload: true, + }, + ) + } + + const result = readSnippetFromFileWithMetadata( + paths, + item, + pathToFolderIdMap, + availability, + ) + + if ( + options?.rewriteRecoveredLegacyFences + && result?.legacyRecovery === 'recovered' + ) { + writeSnippetToFile(paths, result.snippet, { skipIfUnavailable: true }) + } + + if ( + result + && !result.snippet.pendingCloudDownload + && availability.stats + ) { + item.meta = buildSnippetIndexMetadata( + result.snippet, + availability.stats, + ) + } + + return result?.snippet ?? null + }) .filter((snippet): snippet is MarkdownSnippet => !!snippet) } +const trustedMovedLocalWrite = Symbol('trusted-moved-local-write') + export function writeSnippetToFile( paths: Paths, snippet: MarkdownSnippet, + options?: { + skipIfUnavailable?: boolean + [trustedMovedLocalWrite]?: true + }, ): void { const snippetPath = path.join(paths.vaultPath, snippet.filePath) + const canWriteMovedLocalFile = options?.[trustedMovedLocalWrite] === true + + // Запись в плейсхолдер уничтожила бы ещё не скачанное облачное + // содержимое, поэтому она запрещена: файл сначала докачивается в фоне. + // По умолчанию сбой поднимается наверх: тихий пропуск означал бы «принятую» + // правку, которую докачка затем молча перезапишет облачным содержимым. + // Пропуск допустим только там, где запись — необязательный write-back + // (scan, move, bulk-очистка тегов), а не сохранение пользовательской правки. + if (snippet.pendingCloudDownload) { + enqueueCloudDownload(snippetPath) + if (options?.skipIfUnavailable) { + return + } + throwCloudContentUnavailable() + } + + const availability = canWriteMovedLocalFile + ? null + : getFileAvailability(snippetPath) + + if (availability?.isCloudPlaceholder) { + enqueueCloudDownload(snippetPath) + if (options?.skipIfUnavailable) { + return + } + throwCloudContentUnavailable() + } + + if (canWriteMovedLocalFile) { + markAppWrittenFileAsLocal(snippetPath) + } + + // Ленивая запись (тела ещё не дочитаны из индекса): недостающие value + // дочитываются с диска перед сериализацией, иначе запись затёрла бы тела + // пустыми строками. Тихий пропуск записи потерял бы правку метаданных + // при следующем скане, поэтому сбой поднимается наверх. В scan-путях + // (write-back после чтения) сниппет уже прочитан и ветка недостижима. + if (!ensureSnippetContentLoaded(paths, snippet)) { + throwCloudContentUnavailable() + } + const nextContent = serializeSnippet(snippet) fs.ensureDirSync(path.dirname(snippetPath)) - if (fs.pathExistsSync(snippetPath)) { + if (canWriteMovedLocalFile || availability?.exists) { const currentContent = fs.readFileSync(snippetPath, 'utf8') if (currentContent === nextContent) { + if (canWriteMovedLocalFile) { + markAppWrittenFileAsLocal(snippetPath) + } return } } fs.writeFileSync(snippetPath, nextContent, 'utf8') + rememberAppFileChange(snippetPath) + markAppWrittenFileAsLocal(snippetPath) } function upsertSnippetIndex( @@ -248,66 +649,6 @@ export function buildSnippetTargetPath( return directory ? path.posix.join(directory, fileName) : fileName } -function getCachedDirectoryEntries( - directoryPath: string, - directoryEntriesCache?: DirectoryEntriesCache, -): string[] { - if (!directoryEntriesCache) { - return fs.readdirSync(directoryPath) - } - - const cachedEntries = directoryEntriesCache.get(directoryPath) - if (cachedEntries) { - return cachedEntries - } - - const entries = fs.readdirSync(directoryPath) - directoryEntriesCache.set(directoryPath, [...entries]) - return entries -} - -function removeDirectoryEntryFromCache( - directoryPath: string, - fileName: string, - directoryEntriesCache?: DirectoryEntriesCache, -): void { - if (!directoryEntriesCache) { - return - } - - const entries = directoryEntriesCache.get(directoryPath) - if (!entries) { - return - } - - const normalizedFileName = fileName.toLowerCase() - const nextEntries = entries.filter( - entry => entry.toLowerCase() !== normalizedFileName, - ) - - directoryEntriesCache.set(directoryPath, nextEntries) -} - -function upsertDirectoryEntryInCache( - directoryPath: string, - fileName: string, - directoryEntriesCache?: DirectoryEntriesCache, -): void { - if (!directoryEntriesCache) { - return - } - - const entries - = directoryEntriesCache.get(directoryPath) || fs.readdirSync(directoryPath) - const normalizedFileName = fileName.toLowerCase() - const nextEntries = entries.filter( - entry => entry.toLowerCase() !== normalizedFileName, - ) - - nextEntries.push(fileName) - directoryEntriesCache.set(directoryPath, nextEntries) -} - function assertSnippetPathAvailable( paths: Paths, targetRelativePath: string, @@ -434,6 +775,7 @@ export function persistSnippet( ? path.join(paths.vaultPath, sourcePath) : null const targetAbsolutePath = path.join(paths.vaultPath, targetPath) + let moved = false if ( sourceAbsolutePath @@ -443,6 +785,9 @@ export function persistSnippet( ) { fs.ensureDirSync(path.dirname(targetAbsolutePath)) fs.moveSync(sourceAbsolutePath, targetAbsolutePath, { overwrite: false }) + moved = true + rememberAppFileChange(sourceAbsolutePath) + rememberAppFileChange(targetAbsolutePath) removeDirectoryEntryFromCache( path.dirname(sourceAbsolutePath), @@ -452,7 +797,13 @@ export function persistSnippet( } snippet.filePath = targetPath - writeSnippetToFile(paths, snippet) + writeSnippetToFile(paths, snippet, { + skipIfUnavailable: options?.skipWriteIfUnavailable, + ...(moved && options?.sourceFileVerifiedLocal + ? { [trustedMovedLocalWrite]: true as const } + : {}), + }) + upsertDirectoryEntryInCache( path.dirname(targetAbsolutePath), path.basename(targetAbsolutePath), @@ -499,6 +850,7 @@ export function createSnippetRecord( isDeleted: snippet.isDeleted, isFavorites: snippet.isFavorites, name: snippet.name, + pendingCloudDownload: snippet.pendingCloudDownload === true, tags, updatedAt: snippet.updatedAt, } @@ -532,6 +884,10 @@ export function findSnippetById( return snippet } +/** + * Global content lookup is only safe for non-mutating fallback flows. + * Mutation paths with a known owner must scope lookup by snippet id first. + */ export function findSnippetByContentId( snippets: MarkdownSnippet[], contentId: number, diff --git a/src/main/storage/providers/markdown/runtime/spaceState.ts b/src/main/storage/providers/markdown/runtime/spaceState.ts index 269f223ea..1979abc2b 100644 --- a/src/main/storage/providers/markdown/runtime/spaceState.ts +++ b/src/main/storage/providers/markdown/runtime/spaceState.ts @@ -1,6 +1,10 @@ import fs from 'fs-extra' import yaml from 'js-yaml' import { pendingStateWriteByPath, stateContentCacheByPath } from './cache' +import { + isCloudFileNotDownloadedError, + readVaultTextFileSync, +} from './shared/guardedRead' import { scheduleStateFlush } from './shared/stateWriter' export function readSpaceState(statePath: string): T | null { @@ -31,12 +35,19 @@ export function readSpaceState(statePath: string): T | null { } try { - const content = fs.readFileSync(statePath, 'utf8') + // Guarded-чтение: недокачанный state-файл прерывает чтение ошибкой, + // потому что null здесь означал бы «state нет» и привёл бы к записи + // дефолтного state поверх ещё не скачанной облачной версии. + const content = readVaultTextFileSync(statePath) stateContentCacheByPath.set(statePath, content) const parsed = yaml.load(content) return parsed && typeof parsed === 'object' ? (parsed as T) : null } - catch { + catch (error) { + if (isCloudFileNotDownloadedError(error)) { + throw error + } + return null } } diff --git a/src/main/storage/providers/markdown/runtime/state.ts b/src/main/storage/providers/markdown/runtime/state.ts index 281512168..14d799b00 100644 --- a/src/main/storage/providers/markdown/runtime/state.ts +++ b/src/main/storage/providers/markdown/runtime/state.ts @@ -1,11 +1,17 @@ import type { MarkdownState, MarkdownStateFile, Paths } from './types' import { invalidateRuntimeSearchIndex } from './search' import { createStateAdapter } from './shared/stateAdapter' -import { syncFolderUiWithFolders } from './shared/stateUtils' +import { + syncFolderIdByPathWithFolders, + syncFolderUiWithFolders, +} from './shared/stateUtils' import { flushPendingStateWrites } from './shared/stateWriter' export { flushPendingStateWrites, syncFolderUiWithFolders } +// Версия 3: записи snippets несут денормализованные метаданные списка и +// stat-сигнатуру (`meta`). Записи без meta (v2) дозаполняются организно: +// файл читается один раз при первом скане и метаданные попадают в индекс. export function createDefaultState(): MarkdownState { return { counters: { @@ -18,13 +24,13 @@ export function createDefaultState(): MarkdownState { folders: [], snippets: [], tags: [], - version: 2, + version: 3, } } const adapter = createStateAdapter({ createDefaultState, - minVersion: 2, + minVersion: 3, getDirs: paths => [ paths.vaultPath, paths.metaDirPath, @@ -33,8 +39,18 @@ const adapter = createStateAdapter({ ], toPersistedState: state => ({ counters: state.counters, + // Fallback path → folder id для холодного старта с недокачанными + // .meta.yaml (см. syncFolderIdByPathWithFolders). + ...(state.folderIdByPath ? { folderIdByPath: state.folderIdByPath } : {}), folderUi: state.folderUi, - snippets: state.snippets, + // Записи индекса нормализуются до известной схемы: state.json + // синхронизируется между устройствами и не должен накапливать + // посторонние поля. + snippets: state.snippets.map(({ filePath, id, meta }) => ({ + filePath, + id, + ...(meta ? { meta } : {}), + })), tags: state.tags, version: state.version, }), @@ -43,6 +59,9 @@ const adapter = createStateAdapter({ return { counters: { ...defaults.counters, ...raw.counters }, + ...(raw.folderIdByPath && typeof raw.folderIdByPath === 'object' + ? { folderIdByPath: raw.folderIdByPath } + : {}), folderUi: (raw.folderUi ?? {}) as MarkdownState['folderUi'], folders: legacyFolders, snippets: Array.isArray(raw.snippets) ? raw.snippets : [], @@ -52,6 +71,7 @@ const adapter = createStateAdapter({ }, onBeforeSave: (state) => { syncFolderUiWithFolders(state) + syncFolderIdByPathWithFolders(state) invalidateRuntimeSearchIndex(state) }, }) diff --git a/src/main/storage/providers/markdown/runtime/sync.ts b/src/main/storage/providers/markdown/runtime/sync.ts index 5c6dd48fb..ed63d845d 100644 --- a/src/main/storage/providers/markdown/runtime/sync.ts +++ b/src/main/storage/providers/markdown/runtime/sync.ts @@ -8,9 +8,14 @@ import type { } from './types' import path from 'node:path' import fs from 'fs-extra' +import { enqueueCloudDownload } from '../cloudDownloads' import { runtimeRef } from './cache' import { INBOX_DIR_NAME, META_DIR_NAME, TRASH_DIR_NAME } from './constants' -import { readFolderMetadata, writeFolderMetadataFile } from './parser' +import { + isFolderMetadataInSync, + readFolderMetadata, + writeFolderMetadataFile, +} from './parser' import { buildFolderPathMap, buildPathToFolderIdMap, @@ -19,12 +24,16 @@ import { toPosixPath, } from './paths' import { buildSearchIndex, getSnippetSearchText } from './search' +import { getFileAvailability, primeDatalessChecks } from './shared/cloudFiles' import { syncFolderMetadataFilesByPathMap, syncFoldersStateFromDiskAtRoot, } from './shared/folderSync' +import { isCloudFileNotDownloadedError } from './shared/guardedRead' import { syncFolderUiWithFolders } from './shared/stateUtils' +import { createVaultReconciler } from './shared/vaultReconcile' import { + buildSnippetIndexMetadata, getStateSnippetIndexByFilePath, isInboxSnippetDirectory, isTrashSnippetDirectory, @@ -34,6 +43,7 @@ import { readSnippetFromFile, } from './snippets' import { + createDefaultState, flushPendingStateWrite, flushPendingStateWrites, loadState, @@ -46,8 +56,14 @@ function isTechnicalRootFolder(name: string): boolean { ) } -function syncFoldersWithDisk(paths: Paths, state: MarkdownState): void { - syncFoldersStateFromDiskAtRoot({ +function syncFoldersWithDisk( + paths: Paths, + state: MarkdownState, +): Map { + const diskFolders = syncFoldersStateFromDiskAtRoot< + FolderRecord, + MarkdownFolderMetadataFile + >({ buildFolder: ({ base, metadata, previousFolder }) => { const defaultLanguage = typeof metadata.defaultLanguage === 'string' @@ -74,11 +90,14 @@ function syncFoldersWithDisk(paths: Paths, state: MarkdownState): void { state, }) syncFolderUiWithFolders(state) + + return new Map(diskFolders.map(entry => [entry.path, entry.metadata])) } export function syncFolderMetadataFiles( paths: Paths, state: MarkdownState, + scannedMetadataByPath?: ReadonlyMap, ): void { const folderPathMap = buildFolderPathMap(state) syncFolderMetadataFilesByPathMap( @@ -86,9 +105,19 @@ export function syncFolderMetadataFiles( folderPathMap, (folderPath, folder) => { const syncedFolder = findFolderById(state, folder.id) - if (syncedFolder) { - writeFolderMetadataFile(paths, folderPath, syncedFolder) + if (!syncedFolder) { + return } + + const scannedMetadata = scannedMetadataByPath?.get(folderPath) + if ( + scannedMetadata + && isFolderMetadataInSync(scannedMetadata, syncedFolder) + ) { + return + } + + writeFolderMetadataFile(paths, folderPath, syncedFolder) }, ) } @@ -124,28 +153,52 @@ export function syncCounters( state.counters.contentId = Math.max(state.counters.contentId, maxContentId) } -export function syncStateWithDisk(paths: Paths): MarkdownState { +function syncStateAndSnippetsWithDisk( + paths: Paths, + options?: { rewriteRecoveredLegacyFences?: boolean }, +): { snippets: MarkdownSnippet[], state: MarkdownState } { flushPendingStateWrite(paths) const state = loadState(paths) - syncFoldersWithDisk(paths, state) + const scannedFolderMetadataByPath = syncFoldersWithDisk(paths, state) const relativeSnippetFiles = listMarkdownFiles(paths.vaultPath) + + // Один batch-вызов точной проверки dataless на весь список вместо + // отдельного системного вызова на каждый подозрительный файл. + primeDatalessChecks( + relativeSnippetFiles.map(filePath => + path.join(paths.vaultPath, filePath), + ), + ) + const fileSet = new Set(relativeSnippetFiles) const existingIdSet = new Set(state.snippets.map(item => item.id)) state.snippets = state.snippets.filter(item => fileSet.has(item.filePath)) + const knownSnippetFilePaths = new Set( + state.snippets.map(item => item.filePath), + ) + relativeSnippetFiles.forEach((filePath) => { - const knownSnippet = state.snippets.find( - item => item.filePath === filePath, - ) - if (knownSnippet) { + if (knownSnippetFilePaths.has(filePath)) { return } const snippetAbsolutePath = path.join(paths.vaultPath, filePath) - let snippetId = readFrontmatterIdFromSnippetFile(snippetAbsolutePath) + const frontmatterId = readFrontmatterIdFromSnippetFile(snippetAbsolutePath) + + // Неизвестный файл, содержимое которого сейчас недоступно (облачный + // плейсхолдер или сбой чтения): его frontmatter-id неизвестен, а + // чеканка нового id дала бы расходящиеся id после докачки. Файл + // появится в индексе после фоновой докачки через инкрементальный sync. + if (frontmatterId === 'unreadable') { + enqueueCloudDownload(snippetAbsolutePath) + return + } + + let snippetId = frontmatterId if (!snippetId || existingIdSet.has(snippetId)) { snippetId = state.counters.snippetId + 1 @@ -156,12 +209,16 @@ export function syncStateWithDisk(paths: Paths): MarkdownState { state.snippets.push({ filePath, id: snippetId }) }) - const snippets = loadSnippets(paths, state) + const snippets = loadSnippets(paths, state, options) syncCounters(state, snippets) - syncFolderMetadataFiles(paths, state) + syncFolderMetadataFiles(paths, state, scannedFolderMetadataByPath) saveState(paths, state, { immediate: true }) - return state + return { snippets, state } +} + +export function syncStateWithDisk(paths: Paths): MarkdownState { + return syncStateAndSnippetsWithDisk(paths).state } export function setRuntimeCache( @@ -205,18 +262,132 @@ export function setRuntimeCache( return runtimeRef.cache } +const vaultReconciler = createVaultReconciler('markdown') + export function resetRuntimeCache(): void { flushPendingStateWrites() + // Смена vault: ретраи сверки брошенного корня останавливаются, иначе они + // продолжили бы попытки по неактивному пути и слали storage-synced. + const previousVaultPath = runtimeRef.cache?.paths.vaultPath + if (previousVaultPath) { + vaultReconciler.abandon(previousVaultPath) + } runtimeRef.cache = null } -export function syncRuntimeWithDisk(paths: Paths): MarkdownRuntimeCache { - const state = syncStateWithDisk(paths) - const snippets = loadSnippets(paths, state) +// Полные обходы диска (например, Vault Doctor) допустимы только после +// фоновой сверки: до неё листинги каталогов могут блокироваться сетью. +export function isCodeVaultDiskReady(paths: Paths): boolean { + return vaultReconciler.isReconciled(paths.vaultPath) +} + +// Пустой временный кэш на период фоновой сверки: обход диска опасен +// синхронно (листинги dataless-каталогов материализуются сетью), поэтому +// первый доступ отдаёт пустой список, а настоящий — согласованный с диском +// и с getById — приходит после реконсиляции. Список из state-индекса тут +// не строится намеренно: он бы содержал записи, чьи файлы ещё не подтянуты +// из облака, и клик по такой записи давал бы 404. Сам state при этом +// читается с диска: мутации в этот период работают с настоящими счётчиками +// и тегами, а не чеканят id заново поверх существующего индекса. +function buildProvisionalRuntimeCache(paths: Paths): MarkdownRuntimeCache { + if ( + runtimeRef.cache + && runtimeRef.cache.paths.vaultPath === paths.vaultPath + ) { + return runtimeRef.cache + } + + // state.json сам может быть облачным плейсхолдером: тогда loadState + // бросает, а кэш строится на неперсистируемом дефолтном state (флаг + // provisional блокирует запись и мутации до докачки). + let state: MarkdownState + try { + state = loadState(paths) + } + catch (error) { + if (!isCloudFileNotDownloadedError(error)) { + throw error + } + + state = createDefaultState() + state.provisional = true + } + + return setRuntimeCache(paths, state, []) +} + +// Настоящая сверка с диском: читает state и файлы. Может бросить, если +// state.json сам недокачан из облака (reconciler ретраит по этой ошибке). +function performFullRuntimeSync(paths: Paths): MarkdownRuntimeCache { + const { snippets, state } = syncStateAndSnippetsWithDisk(paths, { + rewriteRecoveredLegacyFences: true, + }) return setRuntimeCache(paths, state, snippets) } +export function syncRuntimeWithDisk(paths: Paths): MarkdownRuntimeCache { + // Первый доступ к vault: обход диска опасен синхронно (листинги + // dataless-каталогов материализуются сетью), поэтому мгновенно отдаётся + // provisional-кэш, а настоящая сверка выполняется в фоне. Настоящая + // сверка вызывается напрямую (не через syncRuntimeWithDisk), иначе до + // пометки reconciled она снова ушла бы в provisional-ветку. + if (!vaultReconciler.isReconciled(paths.vaultPath)) { + const provisionalCache = buildProvisionalRuntimeCache(paths) + + vaultReconciler.begin(paths.vaultPath, () => { + if ( + runtimeRef.cache + && runtimeRef.cache.paths.vaultPath !== paths.vaultPath + ) { + return + } + + performFullRuntimeSync(paths) + }) + + return provisionalCache + } + + return performFullRuntimeSync(paths) +} + +// Перепроверка недокачанных записей независимо от fs-событий: облачный +// провайдер (особенно iCloud) материализует файл, НЕ меняя mtime/size, +// поэтому chokidar не присылает `change` и флаг pendingCloudDownload иначе +// висел бы вечно. Возвращает, сколько записей всё ещё недокачано. +export function refreshPendingSnippetFiles(paths: Paths): { + changed: boolean + remaining: number +} { + const cache = runtimeRef.cache + if (!cache || cache.paths.vaultPath !== paths.vaultPath) { + return { changed: false, remaining: 0 } + } + + const pendingFilePaths = cache.snippets + .filter(snippet => snippet.pendingCloudDownload) + .map(snippet => snippet.filePath) + + let changed = false + for (const filePath of pendingFilePaths) { + const absolutePath = path.join(paths.vaultPath, filePath) + if (getFileAvailability(absolutePath).isCloudPlaceholder) { + continue + } + + if (syncSnippetFileWithDisk(paths, filePath)) { + changed = true + } + } + + const remaining + = runtimeRef.cache?.snippets.filter(snippet => snippet.pendingCloudDownload) + .length ?? 0 + + return { changed, remaining } +} + export function getRuntimeCache(paths: Paths): MarkdownRuntimeCache { if ( !runtimeRef.cache @@ -228,17 +399,61 @@ export function getRuntimeCache(paths: Paths): MarkdownRuntimeCache { return runtimeRef.cache } +function removeSnippetFromRuntimeMaps( + cache: MarkdownRuntimeCache, + snippet: MarkdownSnippet, +): void { + cache.snippetById.delete(snippet.id) + + snippet.contents.forEach((content) => { + const owner = cache.contentOwnerByContentId.get(content.id) + if (owner && owner.snippet === snippet) { + cache.contentOwnerByContentId.delete(content.id) + } + }) +} + +function upsertSnippetInRuntimeMaps( + cache: MarkdownRuntimeCache, + previousSnippet: MarkdownSnippet | null, + snippet: MarkdownSnippet, +): void { + if (previousSnippet) { + removeSnippetFromRuntimeMaps(cache, previousSnippet) + } + + cache.snippetById.set(snippet.id, snippet) + snippet.contents.forEach((content, contentIndex) => { + cache.contentOwnerByContentId.set(content.id, { + contentIndex, + snippet, + }) + }) +} + +function commitRuntimeCache(cache: MarkdownRuntimeCache): MarkdownRuntimeCache { + // A new object identity signals watcher consumers that data changed, + // while built maps and the lazily rebuilt search index are reused. + runtimeRef.cache = { ...cache } + return runtimeRef.cache +} + export function syncSnippetFileWithDisk( paths: Paths, changedFilePath: string, ): MarkdownRuntimeCache | null { - if ( - !runtimeRef.cache - || runtimeRef.cache.paths.vaultPath !== paths.vaultPath - ) { + const cache = runtimeRef.cache + if (!cache || cache.paths.vaultPath !== paths.vaultPath) { return null } + // Provisional state (state.json ещё не докачан из облака) не может + // регистрировать файлы: id выдавались бы с дефолтных счётчиков. Событие + // не теряется — файл подберёт полная сверка после докачки. + if (cache.state.provisional) { + return cache + } + const normalizedFilePath = toPosixPath(changedFilePath).trim() if ( !normalizedFilePath @@ -247,8 +462,8 @@ export function syncSnippetFileWithDisk( return null } - const state = runtimeRef.cache.state - const snippets = runtimeRef.cache.snippets + const state = cache.state + const snippets = cache.snippets const snippetAbsolutePath = path.join(paths.vaultPath, normalizedFilePath) const normalizedFileDirectory = normalizeDirectoryPath( path.posix.dirname(normalizedFilePath), @@ -272,7 +487,7 @@ export function syncSnippetFileWithDisk( if (!snippetExistsOnDisk) { if (snippetIndexInState === -1) { - return runtimeRef.cache + return cache } const removedSnippetId = state.snippets[snippetIndexInState].id @@ -282,71 +497,107 @@ export function syncSnippetFileWithDisk( snippet => snippet.id === removedSnippetId, ) if (snippetIndexInRuntime !== -1) { - snippets.splice(snippetIndexInRuntime, 1) + const [removedSnippet] = snippets.splice(snippetIndexInRuntime, 1) + removeSnippetFromRuntimeMaps(cache, removedSnippet) } saveState(paths, state) - return setRuntimeCache(paths, state, snippets) + return commitRuntimeCache(cache) } let snippetIndexItem = snippetIndexInState !== -1 ? state.snippets[snippetIndexInState] : null if (!snippetIndexItem) { - const existingSnippetIds = new Set( - state.snippets.map(item => item.id), - ) - let snippetId = readFrontmatterIdFromSnippetFile(snippetAbsolutePath) + const frontmatterId = readFrontmatterIdFromSnippetFile(snippetAbsolutePath) + + // Неизвестный файл, содержимое которого сейчас недоступно (облачный + // плейсхолдер или сбой чтения): регистрировать его нельзя, иначе id + // был бы отчеканен вслепую и разошёлся бы с frontmatter-id после + // докачки. Файл появится в индексе после фоновой докачки. + if (frontmatterId === 'unreadable') { + enqueueCloudDownload(snippetAbsolutePath) + return cache + } - if (!snippetId || existingSnippetIds.has(snippetId)) { - snippetId = state.counters.snippetId + 1 - state.counters.snippetId = snippetId + let snippetId = frontmatterId + + // Внешнее перемещение (mv A.md → B.md) может прислать add нового пути + // раньше unlink старого: если frontmatter-id принадлежит записи, файла + // которой уже нет на диске, это тот же сниппет — перенацеливаем запись, + // сохраняя id, вместо аллокации нового. + if (snippetId) { + const ownerEntry = state.snippets.find(item => item.id === snippetId) + + if ( + ownerEntry + && !fs.pathExistsSync(path.join(paths.vaultPath, ownerEntry.filePath)) + ) { + ownerEntry.filePath = normalizedFilePath + snippetIndexItem = ownerEntry + } } - snippetIndexItem = { - filePath: normalizedFilePath, - id: snippetId, + if (!snippetIndexItem) { + const existingSnippetIds = new Set( + state.snippets.map(item => item.id), + ) + + if (!snippetId || existingSnippetIds.has(snippetId)) { + snippetId = state.counters.snippetId + 1 + state.counters.snippetId = snippetId + } + + snippetIndexItem = { + filePath: normalizedFilePath, + id: snippetId, + } + state.snippets.push(snippetIndexItem) } - state.snippets.push(snippetIndexItem) } else { snippetIndexItem.filePath = normalizedFilePath } + const changedFileAvailability = getFileAvailability(snippetAbsolutePath) const syncedSnippet = readSnippetFromFile( paths, snippetIndexItem, pathToFolderIdMap, + changedFileAvailability, ) if (!syncedSnippet) { return null } + // Индекс метаданных обновляется по реально прочитанному файлу, чтобы + // следующий холодный старт собрал запись без чтения. + if (!syncedSnippet.pendingCloudDownload && changedFileAvailability.stats) { + snippetIndexItem.meta = buildSnippetIndexMetadata( + syncedSnippet, + changedFileAvailability.stats, + ) + } + const snippetIndexInRuntime = snippets.findIndex( snippet => snippet.id === syncedSnippet.id, ) + let previousSnippet: MarkdownSnippet | null = null if (snippetIndexInRuntime === -1) { snippets.push(syncedSnippet) } else { + previousSnippet = snippets[snippetIndexInRuntime] snippets[snippetIndexInRuntime] = syncedSnippet } - const maxSnippetContentId = syncedSnippet.contents.reduce( - (maxId, content) => Math.max(maxId, content.id), - 0, - ) - state.counters.snippetId = Math.max( - state.counters.snippetId, - syncedSnippet.id, - ) - state.counters.contentId = Math.max( - state.counters.contentId, - maxSnippetContentId, - ) + upsertSnippetInRuntimeMaps(cache, previousSnippet, syncedSnippet) + syncCounters(state, snippets) + // saveState marks the runtime search index dirty, so it is rebuilt + // lazily on the next search instead of eagerly on every file change. saveState(paths, state) - return setRuntimeCache(paths, state, snippets) + return commitRuntimeCache(cache) } diff --git a/src/main/storage/providers/markdown/runtime/types.ts b/src/main/storage/providers/markdown/runtime/types.ts index c0b94ceb2..3f9c3845a 100644 --- a/src/main/storage/providers/markdown/runtime/types.ts +++ b/src/main/storage/providers/markdown/runtime/types.ts @@ -6,9 +6,33 @@ export interface MarkdownTagState extends TagRecord { updatedAt: number } +export interface MarkdownSnippetIndexContentMetadata { + id: number + label: string + language: string +} + +// Денормализованные метаданные списка в state.json (слой 4 плана +// icloud-lazy-vault-load): позволяют строить список и placeholder-записи без +// чтения файлов. mtimeMs/size — freshness-сигнатура последнего чтения: пока +// stat совпадает, файл не перечитывается. +export interface MarkdownSnippetIndexMetadata { + contents: MarkdownSnippetIndexContentMetadata[] + createdAt: number + description: string | null + isDeleted: number + isFavorites: number + mtimeMs: number + name: string + size: number + tags: number[] + updatedAt: number +} + export interface MarkdownSnippetIndexItem { filePath: string id: number + meta?: MarkdownSnippetIndexMetadata } export interface MarkdownFolderMetadataFile { @@ -20,6 +44,8 @@ export interface MarkdownFolderMetadataFile { masscode_id?: number name?: string orderIndex?: number + // Файл метаданных недокачан из облака: содержимое (и id) неизвестно. + unavailable?: boolean updatedAt?: number } @@ -39,6 +65,7 @@ export interface MarkdownStateFile { snippetId?: number tagId?: number } + folderIdByPath?: Record folderUi?: Record folders?: FolderRecord[] snippets?: MarkdownSnippetIndexItem[] @@ -53,8 +80,14 @@ export interface MarkdownState { snippetId: number tagId: number } + // Персистируемый fallback path → folder id: без него недокачанный + // .meta.yaml чеканил бы папке новый id на каждом холодном старте. + folderIdByPath?: Record folderUi: Record folders: FolderRecord[] + // Дефолтный state на период, пока state.json не докачан из облака: + // такой state нельзя ни персистить, ни использовать для выдачи id. + provisional?: boolean snippets: MarkdownSnippetIndexItem[] tags: MarkdownTagState[] version: number @@ -100,6 +133,11 @@ export interface MarkdownSnippet { isDeleted: number isFavorites: number name: string + /** + * Файл сниппета — облачный плейсхолдер: содержимое ещё не скачано + * провайдером, запись показывается в списке и докачивается в фоне. + */ + pendingCloudDownload?: boolean tags: number[] updatedAt: number } @@ -162,10 +200,18 @@ export type MarkdownErrorCode = | 'NAME_CONFLICT' | 'RESERVED_NAME' | 'SNIPPET_NOT_FOUND' + | 'VAULT_HYDRATING' export type DirectoryEntriesCache = Map export interface PersistSnippetOptions { allowRenameOnConflict?: boolean directoryEntriesCache?: Map + // Каллер проверил source до мутации и move: после переноса runtime может + // безопасно выполнить одну запись resident/zero-block файла по новому пути. + sourceFileVerifiedLocal?: boolean + // Move-пути (перенос в trash при удалении папки): файл-плейсхолдер уже + // перемещён, а перезапись frontmatter не обязательна и не должна валить + // всю операцию. + skipWriteIfUnavailable?: boolean } diff --git a/src/main/storage/providers/markdown/runtime/validation.ts b/src/main/storage/providers/markdown/runtime/validation.ts index 3fbfe693c..2cd89cf8b 100644 --- a/src/main/storage/providers/markdown/runtime/validation.ts +++ b/src/main/storage/providers/markdown/runtime/validation.ts @@ -21,6 +21,20 @@ export function getMarkdownStorageErrorMessage(error: unknown): string { return normalizeErrorMessage(error) } +// Пока state-файл пространства не докачан из облака, создание записей +// работало бы на дефолтных счётчиках и чеканило id поверх существующего +// индекса. Такие мутации отклоняются до окончания докачки. +export function assertVaultNotHydrating(state: { + provisional?: boolean +}): void { + if (state.provisional) { + throwStorageError( + 'VAULT_HYDRATING', + 'Vault state is not downloaded from cloud storage yet', + ) + } +} + function normalizeName(name: string): string { return name.trim() } diff --git a/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts b/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts index c6eb4d739..41a4df333 100644 --- a/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts +++ b/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts @@ -3,6 +3,7 @@ import path from 'node:path' import fs from 'fs-extra' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getRuntimeCache, writeSnippetToFile } from '../../runtime' import { getPaths } from '../../runtime/paths' import { ensureStateFile } from '../../runtime/state' import { resetRuntimeCache } from '../../runtime/sync' @@ -115,6 +116,128 @@ describe('code snippets storage validations', () => { expect(result.id).toBeGreaterThan(0) }) + // Два ресинка: первый скан дозаполняет индекс метаданных, второй строит + // ленивые записи из индекса без чтения тел. + function resyncTwiceForLazySnippets() { + resetRuntimeCache() + getRuntimeCache(getPaths(tempVaultPath)) + resetRuntimeCache() + return getRuntimeCache(getPaths(tempVaultPath)) + } + + it('materializes lazy fragment bodies on getSnippetById', () => { + const storage = createSnippetsStorage() + const { id } = storage.createSnippet({ name: 'Lazy Read' }) + storage.createSnippetContent(id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'lazy body', + }) + + const cache = resyncTwiceForLazySnippets() + const lazySnippet = cache.snippets.find(snippet => snippet.id === id) + expect(lazySnippet?.contents[0]?.value).toBeNull() + + const record = storage.getSnippetById(id) + expect(record?.contents[0]?.value).toBe('lazy body') + }) + + it('finds lazy snippets by fragment body via search', () => { + const storage = createSnippetsStorage() + const { id } = storage.createSnippet({ name: 'Search Target' }) + storage.createSnippetContent(id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'needle-body-text', + }) + + resyncTwiceForLazySnippets() + + const results = storage.getSnippets({ search: 'needle-body-text' }) + expect(results.some(snippet => snippet.id === id)).toBe(true) + }) + + it('keeps fragment bodies intact when renaming a lazy snippet', () => { + const storage = createSnippetsStorage() + const { id } = storage.createSnippet({ name: 'Lazy Rename' }) + storage.createSnippetContent(id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'keep me', + }) + + const lazyCache = resyncTwiceForLazySnippets() + const lazySnippet = lazyCache.snippets.find(snippet => snippet.id === id) + expect(lazySnippet?.contents[0]?.value).toBeNull() + + // Переименование сериализует сниппет целиком: незагруженные тела + // должны дочитаться, а не затереться пустыми строками. + storage.updateSnippet(id, { name: 'Lazy Renamed' }) + + const record = storage.getSnippetById(id) + expect(record?.contents[0]?.value).toBe('keep me') + + const codeRootPath = getPaths(tempVaultPath).vaultPath + const cache = getRuntimeCache(getPaths(tempVaultPath)) + const renamed = cache.snippets.find(snippet => snippet.id === id) + const rawSource = fs.readFileSync( + path.join(codeRootPath, renamed!.filePath), + 'utf8', + ) + expect(rawSource).toContain('keep me') + }) + + it('keeps sibling fragment bodies when deleting a fragment of a lazy snippet', () => { + const storage = createSnippetsStorage() + const { id } = storage.createSnippet({ name: 'Lazy Delete' }) + const first = storage.createSnippetContent(id, { + label: 'First', + language: 'plain_text', + value: 'first body', + }) + storage.createSnippetContent(id, { + label: 'Second', + language: 'plain_text', + value: 'second body', + }) + + const cache = resyncTwiceForLazySnippets() + const lazySnippet = cache.snippets.find(snippet => snippet.id === id) + expect(lazySnippet?.contents[0]?.value).toBeNull() + + // Удаление НЕ последнего фрагмента у ленивого сниппета: тела должны + // дочитаться до splice, иначе оставшийся фрагмент получит тело соседа. + storage.deleteSnippetContent(id, first.id) + + const record = storage.getSnippetById(id) + expect(record?.contents).toHaveLength(1) + expect(record?.contents[0]?.label).toBe('Second') + expect(record?.contents[0]?.value).toBe('second body') + + const codeRootPath = getPaths(tempVaultPath).vaultPath + const runtimeSnippet = getRuntimeCache( + getPaths(tempVaultPath), + ).snippets.find(snippet => snippet.id === id) + const rawSource = fs.readFileSync( + path.join(codeRootPath, runtimeSnippet!.filePath), + 'utf8', + ) + expect(rawSource).toContain('second body') + expect(rawSource).not.toContain('first body') + }) + + it('createSnippet during vault hydration throws VAULT_HYDRATING', () => { + const storage = createSnippetsStorage() + const cache = getRuntimeCache(getPaths(tempVaultPath)) + // state.json «ещё не докачан» из облака: создание работало бы на + // дефолтных счётчиках и чеканило id поверх существующего индекса. + cache.state.provisional = true + + expect(() => storage.createSnippet({ name: 'Blocked Snippet' })).toThrow( + 'VAULT_HYDRATING', + ) + }) + it('getSnippetById returns the stored snippet', () => { const storage = createSnippetsStorage() const { id } = storage.createSnippet({ name: 'Lookup Snippet' }) @@ -152,6 +275,37 @@ describe('code snippets storage validations', () => { ).toEqual([named.id]) }) + it('sorts snippets by name and updated date', () => { + vi.useFakeTimers() + + try { + const storage = createSnippetsStorage() + + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const bravo = storage.createSnippet({ name: 'Bravo' }) + + vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')) + const alpha = storage.createSnippet({ name: 'Alpha' }) + + vi.setSystemTime(new Date('2026-01-01T00:00:02.000Z')) + storage.updateSnippet(bravo.id, { description: 'Updated' }) + + expect( + storage + .getSnippets({ sort: 'name', order: 'ASC' }) + .map(snippet => snippet.id), + ).toEqual([alpha.id, bravo.id]) + expect( + storage + .getSnippets({ sort: 'updatedAt', order: 'DESC' }) + .map(snippet => snippet.id), + ).toEqual([bravo.id, alpha.id]) + } + finally { + vi.useRealTimers() + } + }) + it('createSnippet throws NAME_CONFLICT for duplicate name in same folder', () => { const storage = createSnippetsStorage() storage.createSnippet({ name: 'Duplicate' }) @@ -217,4 +371,81 @@ describe('code snippets storage validations', () => { expect(moved?.name.toLowerCase()).not.toBe('shared') expect(moved?.name.toLowerCase()).toContain('shared') }) + + it('updates content scoped to the requested snippet when content ids are duplicated', () => { + const storage = createSnippetsStorage() + const first = storage.createSnippet({ name: 'First' }) + const second = storage.createSnippet({ name: 'Second' }) + const firstContent = storage.createSnippetContent(first.id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'first value', + }) + + storage.createSnippetContent(second.id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'second value', + }) + + const paths = getPaths(tempVaultPath) + const cache = getRuntimeCache(paths) + const secondSnippet = cache.snippets.find( + snippet => snippet.id === second.id, + ) + + expect(secondSnippet).toBeDefined() + secondSnippet!.contents[0]!.id = firstContent.id + writeSnippetToFile(paths, secondSnippet!) + + const result = storage.updateSnippetContent(second.id, firstContent.id, { + value: 'second updated', + }) + + expect(result).toEqual({ + invalidInput: false, + notFound: false, + parentNotFound: false, + }) + expect(storage.getSnippetById(first.id)?.contents[0]?.value).toBe( + 'first value', + ) + expect(storage.getSnippetById(second.id)?.contents[0]).toMatchObject({ + id: firstContent.id, + value: 'second updated', + }) + }) + + it('deletes content scoped to the requested snippet when content ids are duplicated', () => { + const storage = createSnippetsStorage() + const first = storage.createSnippet({ name: 'First' }) + const second = storage.createSnippet({ name: 'Second' }) + const firstContent = storage.createSnippetContent(first.id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'first value', + }) + + storage.createSnippetContent(second.id, { + label: 'Fragment 1', + language: 'plain_text', + value: 'second value', + }) + + const paths = getPaths(tempVaultPath) + const cache = getRuntimeCache(paths) + const secondSnippet = cache.snippets.find( + snippet => snippet.id === second.id, + ) + + expect(secondSnippet).toBeDefined() + secondSnippet!.contents[0]!.id = firstContent.id + writeSnippetToFile(paths, secondSnippet!) + + expect(storage.deleteSnippetContent(second.id, firstContent.id)).toEqual({ + deleted: true, + }) + expect(storage.getSnippetById(first.id)?.contents).toHaveLength(1) + expect(storage.getSnippetById(second.id)?.contents).toHaveLength(0) + }) }) diff --git a/src/main/storage/providers/markdown/storages/folders.ts b/src/main/storage/providers/markdown/storages/folders.ts index ad3e97bc1..8081cee27 100644 --- a/src/main/storage/providers/markdown/storages/folders.ts +++ b/src/main/storage/providers/markdown/storages/folders.ts @@ -1,6 +1,7 @@ import type { FoldersStorage, FolderUpdateResult } from '../../../contracts' import path from 'node:path' import fs from 'fs-extra' +import { scheduleDockBadgeRefresh } from '../../../../dockBadge' import { assertDirectoryNameAvailable, assertNotReservedRootFolderName, @@ -14,6 +15,7 @@ import { getPaths, getRuntimeCache, getVaultPath, + isCodeVaultDiskReady, normalizeDirectoryPath, normalizeFlag, persistSnippet, @@ -23,9 +25,11 @@ import { throwStorageError, validateEntryName, } from '../runtime' +import { getFileAvailability } from '../runtime/shared/cloudFiles' import { applyFolderParentAndOrder, assertFolderMoveTargetValid, + assertNoUnknownDomainFiles, createFolderInStateAndDisk, getFolderPathsByDepth, getFoldersSortedByCreatedAt, @@ -96,6 +100,20 @@ export function createFoldersStorage(): FoldersStorage { }, updateFolder: (id, input): FolderUpdateResult => { const paths = getPaths(getVaultPath()) + + // Rename/move каталога до завершения фоновой сверки работал бы по + // пустому provisional-списку записей: файлы переместились бы на диске, + // а index paths и связи остались бы старыми. + if ( + ('name' in input || 'parentId' in input) + && !isCodeVaultDiskReady(paths) + ) { + throwStorageError( + 'VAULT_HYDRATING', + 'Vault is still syncing, folder rename or move is not available yet', + ) + } + const { state, snippets } = getRuntimeCache(paths) const folder = findFolderById(state, id) @@ -242,6 +260,17 @@ export function createFoldersStorage(): FoldersStorage { }, deleteFolder: (id) => { const paths = getPaths(getVaultPath()) + + // До завершения фоновой сверки runtime-кэш provisional: список записей + // пуст, перенос содержимого папки в trash ничего бы не нашёл, а + // removeFolderPathsFromDisk физически уничтожил бы файлы на диске. + if (!isCodeVaultDiskReady(paths)) { + throwStorageError( + 'VAULT_HYDRATING', + 'Vault is still syncing, folder deletion is not available yet', + ) + } + const { state, snippets } = getRuntimeCache(paths) const folder = findFolderById(state, id) @@ -254,27 +283,58 @@ export function createFoldersStorage(): FoldersStorage { removedFolderIds.add(id) const directoryEntriesCache = new Map() + const removedFolderPaths = getFolderPathsByDepth( + oldFolderPathMap, + removedFolderIds, + ) + + // Доменный .md без записи в runtime (плейсхолдер с другого устройства, + // сбой чтения при скане) физически уничтожился бы вместе с каталогом: + // удаление отклоняется целиком. Обход стартует с корня удаляемой папки + // (вложенные каталоги покрываются рекурсией). Известные записи + // переносятся в trash штатно, включая pending (их trash-маркер — путь). + const topFolderPath = oldFolderPathMap.get(id) + const knownFilePaths = new Set( + snippets + .filter( + snippet => + snippet.folderId !== null + && removedFolderIds.has(snippet.folderId), + ) + .map(snippet => snippet.filePath), + ) + assertNoUnknownDomainFiles( + paths.vaultPath, + topFolderPath ? [topFolderPath] : [], + knownFilePaths, + ) + snippets.forEach((snippet) => { if ( snippet.folderId !== null && removedFolderIds.has(snippet.folderId) ) { const previousPath = snippet.filePath + const sourceAvailability = getFileAvailability( + path.join(paths.vaultPath, previousPath), + ) + const sourceFileVerifiedLocal + = !snippet.pendingCloudDownload + && sourceAvailability.exists + && !sourceAvailability.isCloudPlaceholder + snippet.folderId = null snippet.isDeleted = 1 snippet.updatedAt = Date.now() persistSnippet(paths, state, snippet, previousPath, { allowRenameOnConflict: true, directoryEntriesCache, + skipWriteIfUnavailable: true, + sourceFileVerifiedLocal, }) } }) - const removedFolderPaths = getFolderPathsByDepth( - oldFolderPathMap, - removedFolderIds, - ) - state.folders = state.folders.filter( folder => !removedFolderIds.has(folder.id), ) @@ -282,6 +342,7 @@ export function createFoldersStorage(): FoldersStorage { removeFolderPathsFromDisk(paths.vaultPath, removedFolderPaths) saveState(paths, state) + scheduleDockBadgeRefresh() return { deleted: true } }, diff --git a/src/main/storage/providers/markdown/storages/snippets.ts b/src/main/storage/providers/markdown/storages/snippets.ts index b4fd0493c..f6fd266d0 100644 --- a/src/main/storage/providers/markdown/storages/snippets.ts +++ b/src/main/storage/providers/markdown/storages/snippets.ts @@ -9,11 +9,14 @@ import type { SnippetUpdateResult, } from '../../../contracts' import path from 'node:path' +import { scheduleDockBadgeRefresh } from '../../../../dockBadge' +import { prioritizeCloudDownload } from '../cloudDownloads' import { assertUniqueSiblingEntryName, + assertVaultNotHydrating, createSnippetRecord, + ensureSnippetContentLoaded, findFolderById, - findSnippetByContentId, findSnippetById, getPaths, getRuntimeCache, @@ -29,10 +32,12 @@ import { writeSnippetToFile, } from '../runtime' import { - createNestedContent, - deleteNestedContent, - updateNestedContent, -} from '../runtime/shared/entityContent' + assertEntityFileWritable, + markEntityPendingIfEvicted, + markEntityPendingIfFileExists, + throwCloudContentUnavailable, +} from '../runtime/shared/cloudGuards' +import { createNestedContent } from '../runtime/shared/entityContent' import { filterAndSortByQuery } from '../runtime/shared/entityQuery' import { addTagToEntity, @@ -44,6 +49,13 @@ import { getEntityDeleteCounts, } from '../runtime/shared/entityStorage' +function findContentIndexById( + snippet: MarkdownSnippet, + contentId: number, +): number { + return snippet.contents.findIndex(content => content.id === contentId) +} + export function createSnippetsStorage(): SnippetsStorage { return { getSnippets: (query: SnippetsQueryInput) => { @@ -72,7 +84,17 @@ export function createSnippetsStorage(): SnippetsStorage { (snippet, query) => query.isDeleted ? snippet.isDeleted === 1 : snippet.isDeleted === 0, ], - getSortValue: snippet => snippet.createdAt, + getSortValue: (snippet, sort) => { + if (sort === 'name') { + return snippet.name.toLowerCase() + } + + if (sort === 'updatedAt') { + return snippet.updatedAt + } + + return snippet.createdAt + }, query, }).map(snippet => createSnippetRecord(snippet, state)) @@ -83,6 +105,33 @@ export function createSnippetsStorage(): SnippetsStorage { const { state, snippets } = getRuntimeCache(paths) const snippet = findSnippetById(snippets, id) + // Пользователь открыл ещё не докачанный сниппет: его файл поднимается + // в начало очереди фоновой докачки, ответ при этом не блокируется. + if (snippet?.pendingCloudDownload) { + prioritizeCloudDownload(path.join(paths.vaultPath, snippet.filePath)) + } + + // Запись из индекса без тел: контент дочитывается по первому запросу. + // Сбой дочитки (файл выгружен после скана, флаг ещё не обновился) + // помечает запись pending: успешный ответ с пустыми телами без флага + // открыл бы редактируемый пустой редактор, и набранный текст потерялся + // бы на 503 при сохранении. Для уже гидрированной записи eviction + // ловится свежим stat. Флаг снимет ресинк после докачки. + if (snippet) { + if (!ensureSnippetContentLoaded(paths, snippet)) { + markEntityPendingIfFileExists( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + } + else { + markEntityPendingIfEvicted( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + } + } + return snippet ? createSnippetRecord(snippet, state) : null }, getSnippetsCounts: (): SnippetsCount => { @@ -95,6 +144,7 @@ export function createSnippetsStorage(): SnippetsStorage { const paths = getPaths(getVaultPath()) const { state, snippets } = getRuntimeCache(paths) + assertVaultNotHydrating(state) const name = validateEntryName(input.name, 'snippet') const folderId = input.folderId ?? null assertUniqueSiblingEntryName(snippets, folderId, name, 'snippet') @@ -126,6 +176,7 @@ export function createSnippetsStorage(): SnippetsStorage { }) saveState(paths, state) + scheduleDockBadgeRefresh() return result }, @@ -133,6 +184,16 @@ export function createSnippetsStorage(): SnippetsStorage { const paths = getPaths(getVaultPath()) const { state, snippets } = getRuntimeCache(paths) const snippet = findSnippetById(snippets, snippetId) + + // Проверка до мутации: createNestedContent пушит фрагмент в runtime + // до записи файла. + if (snippet) { + assertEntityFileWritable( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + } + const result = createNestedContent({ createContent: contentId => ({ id: contentId, @@ -166,6 +227,13 @@ export function createSnippetsStorage(): SnippetsStorage { } } + // Проверка до мутации: иначе rename/move уже переместил бы файл и + // изменил runtime, а запись frontmatter отклонилась. + assertEntityFileWritable( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + const previousPath = snippet.filePath const previousFolderId = snippet.folderId const updateResult = applyEntityUpdateFields({ @@ -214,8 +282,15 @@ export function createSnippetsStorage(): SnippetsStorage { snippet.updatedAt = Date.now() persistSnippet(paths, state, snippet, previousPath, { allowRenameOnConflict: movedToTrash || movedBetweenDirectories, + // assertEntityFileWritable выше уже проверил source до mutation. + sourceFileVerifiedLocal: true, + // Перенос в trash не требует перезаписи frontmatter (isDeleted + // выводится из trash-каталога), поэтому недокачанный файл не должен + // блокировать удаление. + skipWriteIfUnavailable: movedToTrash, }) saveState(paths, state) + scheduleDockBadgeRefresh() return { invalidInput: false, @@ -229,45 +304,81 @@ export function createSnippetsStorage(): SnippetsStorage { ): SnippetContentUpdateResult => { const paths = getPaths(getVaultPath()) const { state, snippets } = getRuntimeCache(paths) - const ownedContent = findSnippetByContentId(snippets, contentId) - const result = updateNestedContent({ - applyPatch: (content, patch) => { - if ('label' in patch) { - content.label = patch.label || content.label - } - if ('value' in patch) { - content.value = patch.value ?? null - } + if (!('label' in input || 'value' in input || 'language' in input)) { + return { + invalidInput: true, + notFound: false, + parentNotFound: false, + } + } - if ('language' in patch) { - content.language = patch.language || content.language - } - }, - findTargetOwnerById: id => findSnippetById(snippets, id), - hasAnyField: patch => - 'label' in patch || 'value' in patch || 'language' in patch, - ownerId: snippetId, - ownedContent: ownedContent - ? { - contentIndex: ownedContent.contentIndex, - owner: ownedContent.snippet, - } - : undefined, - patch: input, - persistOwner: snippet => writeSnippetToFile(paths, snippet), - }) - if (!result.invalidInput && !result.notFound) { - saveState(paths, state) + const snippet = findSnippetById(snippets, snippetId) + if (!snippet) { + return { + invalidInput: false, + notFound: false, + parentNotFound: true, + } } - return result + const contentIndex = findContentIndexById(snippet, contentId) + if (contentIndex === -1) { + return { + invalidInput: false, + notFound: true, + parentNotFound: false, + } + } + + // Проверка и дочитка тел до мутации: иначе патч частично применился + // бы в памяти, а запись на диск отклонилась. + assertEntityFileWritable( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + if (!ensureSnippetContentLoaded(paths, snippet)) { + throwCloudContentUnavailable() + } + + const content = snippet.contents[contentIndex] + + if ('label' in input) { + content.label = input.label || content.label + } + + if ('value' in input) { + content.value = input.value ?? null + } + + if ('language' in input) { + content.language = input.language || content.language + } + + snippet.updatedAt = Date.now() + writeSnippetToFile(paths, snippet) + saveState(paths, state) + + return { + invalidInput: false, + notFound: false, + parentNotFound: false, + } }, addTagToSnippet: (snippetId, tagId): SnippetTagRelationResult => { const paths = getPaths(getVaultPath()) const { state, snippets } = getRuntimeCache(paths) const snippet = findSnippetById(snippets, snippetId) const tag = state.tags.find(item => item.id === tagId) + + // Проверка до мутации: addTagToEntity меняет runtime до записи файла. + if (snippet && tag) { + assertEntityFileWritable( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + } + const result = addTagToEntity({ entity: snippet, onUpdated: snippet => writeSnippetToFile(paths, snippet), @@ -292,6 +403,16 @@ export function createSnippetsStorage(): SnippetsStorage { const { state, snippets } = getRuntimeCache(paths) const snippet = findSnippetById(snippets, snippetId) const tag = state.tags.find(item => item.id === tagId) + + // Проверка до мутации: deleteTagFromEntity меняет runtime до записи + // файла. + if (snippet && tag) { + assertEntityFileWritable( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + } + const result = deleteTagFromEntity({ entity: snippet, missingRelationFound: true, @@ -325,6 +446,7 @@ export function createSnippetsStorage(): SnippetsStorage { } saveState(paths, state) + scheduleDockBadgeRefresh() return result }, @@ -342,29 +464,41 @@ export function createSnippetsStorage(): SnippetsStorage { } saveState(paths, state) + scheduleDockBadgeRefresh() return result }, - deleteSnippetContent: (contentId) => { + deleteSnippetContent: (snippetId, contentId) => { const paths = getPaths(getVaultPath()) const { state, snippets } = getRuntimeCache(paths) - const ownedContent = findSnippetByContentId(snippets, contentId) - const result = deleteNestedContent({ - ownedContent: ownedContent - ? { - contentIndex: ownedContent.contentIndex, - owner: ownedContent.snippet, - } - : undefined, - persistOwner: snippet => writeSnippetToFile(paths, snippet), - }) - if (!result.deleted) { - return result + const snippet = findSnippetById(snippets, snippetId) + + if (!snippet) { + return { deleted: false } + } + + const contentIndex = findContentIndexById(snippet, contentId) + if (contentIndex === -1) { + return { deleted: false } } + // Проверка и дочитка тел ДО удаления: доливка null-value в guard'e + // записи идёт по позициям против ещё полного файла, и после splice + // каждый следующий фрагмент получил бы тело соседа. + assertEntityFileWritable( + path.join(paths.vaultPath, snippet.filePath), + snippet, + ) + if (!ensureSnippetContentLoaded(paths, snippet)) { + throwCloudContentUnavailable() + } + + snippet.contents.splice(contentIndex, 1) + snippet.updatedAt = Date.now() + writeSnippetToFile(paths, snippet) saveState(paths, state) - return result + return { deleted: true } }, } } diff --git a/src/main/storage/providers/markdown/storages/tags.ts b/src/main/storage/providers/markdown/storages/tags.ts index 303600a8b..13aeb8119 100644 --- a/src/main/storage/providers/markdown/storages/tags.ts +++ b/src/main/storage/providers/markdown/storages/tags.ts @@ -45,7 +45,9 @@ export function createTagsStorage(): TagsStorage { snippets, id, (snippet: MarkdownSnippet) => { - writeSnippetToFile(paths, snippet) + // Bulk-очистка тега: недокачанный файл пропускается, устаревший id + // в frontmatter безвреден (state — источник истины по тегам). + writeSnippetToFile(paths, snippet, { skipIfUnavailable: true }) }, ) if (!result.deleted) { diff --git a/src/main/storage/providers/markdown/watcher.ts b/src/main/storage/providers/markdown/watcher.ts index 74bc148f0..11bfa038a 100644 --- a/src/main/storage/providers/markdown/watcher.ts +++ b/src/main/storage/providers/markdown/watcher.ts @@ -1,49 +1,93 @@ import type { ChokidarOptions, FSWatcher } from 'chokidar' -import { BrowserWindow } from 'electron' +import type { NotesPaths, NotesRuntimeCache } from './notes/runtime' +import path from 'node:path' import { importEsm, log } from '../../../utils' +import { + configureCloudDownloads, + getPendingCloudPaths, + resetCloudDownloads, +} from './cloudDownloads' +import { wasRecentAppDrawingChange } from './drawings' import { getHttpPaths, + type HttpPaths, peekHttpRuntimeCache, resetHttpRuntimeCache, syncHttpRuntimeWithDisk, } from './http' import { + getNotesAssetNameFromAbsolutePath, getNotesPaths, + parseNotesAssetName, peekNotesRuntimeCache, + refreshPendingNoteFiles, + resetNotesPathsCache, resetNotesRuntimeCache, + scheduleNotesAssetsMigration, + syncNoteFileWithDisk, syncNotesRuntimeWithDisk, } from './notes/runtime' +import { broadcastNotesAssetReady } from './notesAssetEvents' import { ensureStateFile, getPaths, getVaultPath, + type MarkdownRuntimeCache, type Paths, peekRuntimeCache, + refreshPendingSnippetFiles, + resetPathsCache, resetRuntimeCache, syncRuntimeWithDisk, syncSnippetFileWithDisk, } from './runtime' +import { wasRecentAppFileChange } from './runtime/shared/appChanges' +import { getFileAvailability } from './runtime/shared/cloudFiles' +import { isCloudFileNotDownloadedError } from './runtime/shared/guardedRead' +import { broadcastStorageSynced } from './runtime/shared/vaultReconcile' import { + getManagedNotesAssetName, getWatchPathSpaceId, isCodeWatchPath, + isDrawingsWatchPath, isHttpWatchPath, + isManagedNotesAssetsPath, isMathWatchPath, isNotesWatchPath, normalizeRelativeWatchPath, shouldIgnoreWatchPath, toCodeRelativePath, + toNotesRelativePath, } from './watcherPaths' +// Above this number of buffered file changes an incremental per-file sync +// is unlikely to beat one full re-read, so the watcher escalates instead. +const MAX_PENDING_SYNC_FILE_PATHS = 25 +// Верхняя граница дебаунса: даже под непрерывным потоком событий буфер +// изменений сбрасывается не реже, чем раз в эту паузу. Значение балансирует +// отзывчивость UI и стоимость sync-циклов во время шторма фоновых докачек. +const MAX_PENDING_SYNC_AGE_MS = 2_000 + +// Пауза между проходами self-heal: перепроверка недокачанных записей после +// того, как облако (особенно iCloud) материализовало файлы без fs-событий. +const CLOUD_REFRESH_INTERVAL_MS = 3_000 + let markdownWatcher: FSWatcher | null = null let markdownWatchTimer: NodeJS.Timeout | null = null +let pendingSyncSince: number | null = null +let cloudRefreshTimer: NodeJS.Timeout | null = null let watchedVaultPath: string | null = null -let pendingFilePath: string | null = null +const pendingCodeFilePaths = new Set() +const pendingNoteFilePaths = new Set() let hasPendingFullSync = false let hasPendingMathSync = false let hasPendingNotesSync = false let hasPendingHttpSync = false +let hasPendingDrawingsSync = false let watcherStartToken = 0 let chokidarWatchLoader: Promise | null = null +let preparedCloudVaultPath: string | null = null +let preparedWatcherStartToken: number | null = null type ChokidarWatch = ( path: string | readonly string[], @@ -79,12 +123,55 @@ async function getChokidarWatch(): Promise { return chokidarWatchLoader } +function syncChangedSnippetFiles( + paths: Paths, + changedFilePaths: string[], +): MarkdownRuntimeCache { + let nextCache: MarkdownRuntimeCache | null = null + + for (const changedFilePath of changedFilePaths) { + nextCache = syncSnippetFileWithDisk(paths, changedFilePath) + if (!nextCache) { + return syncRuntimeWithDisk(paths) + } + } + + return nextCache ?? syncRuntimeWithDisk(paths) +} + +function syncChangedNoteFiles( + notesPaths: NotesPaths, + changedFilePaths: string[], +): NotesRuntimeCache { + let nextCache: NotesRuntimeCache | null = null + + for (const changedFilePath of changedFilePaths) { + nextCache = syncNoteFileWithDisk(notesPaths, changedFilePath) + if (!nextCache) { + return syncNotesRuntimeWithDisk(notesPaths) + } + } + + return nextCache ?? syncNotesRuntimeWithDisk(notesPaths) +} + function scheduleStateSync( vaultRootPath: string, paths: Paths, changedPath: string | null, forceFullSync = false, ): void { + if (isManagedNotesAssetsPath(changedPath)) { + const managedAssetName = getManagedNotesAssetName(changedPath) + const parsedAsset = managedAssetName + ? parseNotesAssetName(managedAssetName) + : null + if (parsedAsset) { + broadcastNotesAssetReady(parsedAsset.fileName) + } + return + } + const changedSpaceId = getWatchPathSpaceId(changedPath) if (changedPath && !changedSpaceId) { return @@ -94,21 +181,50 @@ function scheduleStateSync( const changedCodePath = isCodeWatchPath(changedPath) const changedMathPath = isMathWatchPath(changedPath) const changedHttpPath = isHttpWatchPath(changedPath) + const changedDrawingsPath = isDrawingsWatchPath(changedPath) const changedCodeRelativePath = changedPath && changedCodePath ? toCodeRelativePath(changedPath) : null - if (changedNotesPath) { - hasPendingNotesSync = true + // Echoes of the app's own file writes are skipped entirely: the runtime + // caches were already updated by the storage layer before the write. + const isAppEcho + = changedPath !== null + && !forceFullSync + && (changedNotesPath || changedCodePath || changedHttpPath) + && wasRecentAppFileChange(path.join(vaultRootPath, changedPath)) + + if (changedNotesPath && !isAppEcho) { + const changedNoteRelativePath + = changedPath && !forceFullSync ? toNotesRelativePath(changedPath) : null + + if (changedNoteRelativePath) { + pendingNoteFilePaths.add(changedNoteRelativePath) + if (pendingNoteFilePaths.size > MAX_PENDING_SYNC_FILE_PATHS) { + hasPendingNotesSync = true + } + } + else { + hasPendingNotesSync = true + } } if (changedMathPath) { hasPendingMathSync = true } - if (changedHttpPath) { + if (changedHttpPath && !isAppEcho) { hasPendingHttpSync = true } + if ( + changedDrawingsPath + && !(changedPath && wasRecentAppDrawingChange(vaultRootPath, changedPath)) + ) { + // Echoes of the app's own drawing writes are skipped entirely: + // re-syncing them would only re-read data the renderer already has. + hasPendingDrawingsSync = true + } + if (changedNotesPath) { // Notes space has separate runtime cache sync path. } @@ -118,20 +234,24 @@ function scheduleStateSync( else if (changedHttpPath) { // HTTP space has separate runtime cache sync path. } + else if (changedDrawingsPath) { + // Drawings space has no main-process cache to sync; broadcast only. + } else if (forceFullSync || !changedPath) { hasPendingFullSync = true if (forceFullSync && !changedPath) { hasPendingNotesSync = true hasPendingHttpSync = true + hasPendingDrawingsSync = true } } else if (changedCodeRelativePath) { - if (pendingFilePath && pendingFilePath !== changedCodeRelativePath) { - hasPendingFullSync = true - } - else { - pendingFilePath = changedCodeRelativePath + if (!isAppEcho) { + pendingCodeFilePaths.add(changedCodeRelativePath) + if (pendingCodeFilePaths.size > MAX_PENDING_SYNC_FILE_PATHS) { + hasPendingFullSync = true + } } } else if (!changedNotesPath) { @@ -144,39 +264,59 @@ function scheduleStateSync( markdownWatchTimer = null } - markdownWatchTimer = setTimeout(() => { + if (pendingSyncSince === null) { + pendingSyncSince = Date.now() + } + + const runScheduledSync = (): void => { + markdownWatchTimer = null + pendingSyncSince = null + try { const previousCache = peekRuntimeCache() const previousNotesCache = peekNotesRuntimeCache() const previousHttpCache = peekHttpRuntimeCache() - const changedFilePath = hasPendingFullSync ? null : pendingFilePath + const changedCodeFilePaths = hasPendingFullSync + ? null + : [...pendingCodeFilePaths] + const changedNoteFilePaths = hasPendingNotesSync + ? null + : [...pendingNoteFilePaths] const shouldNotifyMath = hasPendingMathSync - const shouldSyncCode = hasPendingFullSync || changedFilePath !== null - const shouldSyncNotes = hasPendingNotesSync + const shouldNotifyDrawings = hasPendingDrawingsSync + const shouldSyncCode + = hasPendingFullSync + || (changedCodeFilePaths !== null && changedCodeFilePaths.length > 0) + const shouldSyncNotes + = hasPendingNotesSync + || (changedNoteFilePaths !== null && changedNoteFilePaths.length > 0) const shouldSyncHttp = hasPendingHttpSync hasPendingFullSync = false hasPendingMathSync = false hasPendingNotesSync = false hasPendingHttpSync = false - pendingFilePath = null + hasPendingDrawingsSync = false + pendingCodeFilePaths.clear() + pendingNoteFilePaths.clear() let nextCache = previousCache if (shouldSyncCode) { - if (changedFilePath) { - const syncedSnippetCache = syncSnippetFileWithDisk( - paths, - changedFilePath, - ) - nextCache = syncedSnippetCache || syncRuntimeWithDisk(paths) - } - else { - nextCache = syncRuntimeWithDisk(paths) - } + nextCache + = changedCodeFilePaths && changedCodeFilePaths.length > 0 + ? syncChangedSnippetFiles(paths, changedCodeFilePaths) + : syncRuntimeWithDisk(paths) } - const nextNotesCache = shouldSyncNotes - ? syncNotesRuntimeWithDisk(getNotesPaths(vaultRootPath)) - : previousNotesCache + + let nextNotesCache = previousNotesCache + if (shouldSyncNotes) { + const notesPaths = getNotesPaths(vaultRootPath) + nextNotesCache + = changedNoteFilePaths && changedNoteFilePaths.length > 0 + ? syncChangedNoteFiles(notesPaths, changedNoteFilePaths) + : syncNotesRuntimeWithDisk(notesPaths) + } + const nextHttpCache = shouldSyncHttp ? syncHttpRuntimeWithDisk(getHttpPaths(vaultRootPath)) : previousHttpCache @@ -187,6 +327,7 @@ function scheduleStateSync( = shouldSyncNotes && (!previousNotesCache || nextNotesCache !== previousNotesCache) const hasMathChanges = shouldNotifyMath + const hasDrawingsChanges = shouldNotifyDrawings const hasHttpChanges = shouldSyncHttp && (!previousHttpCache || nextHttpCache !== previousHttpCache) @@ -196,44 +337,140 @@ function scheduleStateSync( || hasNotesChanges || hasMathChanges || hasHttpChanges + || hasDrawingsChanges ) { - BrowserWindow.getAllWindows().forEach((window) => { - window.webContents.send('system:storage-synced') - }) + broadcastStorageSynced() } } catch (error) { log('storage:markdown:watcher-sync', error) } - }, 250) + } + + // Дебаунс с верхней границей: при непрерывном потоке событий (например, + // массовая фоновая докачка из облака даёт завершения чаще, чем раз в + // 250 мс) чистый дебаунс откладывал бы синк и обновление UI бесконечно. + if (Date.now() - pendingSyncSince >= MAX_PENDING_SYNC_AGE_MS) { + runScheduledSync() + return + } + + markdownWatchTimer = setTimeout(runScheduledSync, 250) +} + +// Самоисцеляющийся цикл: пока есть недокачанные файлы, раз в интервал +// перечитывает те записи, чьи файлы уже стали доступны (облако могло +// материализовать их без fs-события). Останавливается, когда pending нет. +function scheduleCloudRefresh( + vaultRootPath: string, + paths: Paths, + notesPaths: NotesPaths, + httpPaths: HttpPaths, +): void { + if (cloudRefreshTimer) { + return + } + + cloudRefreshTimer = setTimeout(() => { + cloudRefreshTimer = null + + let changed = false + let remaining = 0 + + try { + const codeResult = refreshPendingSnippetFiles(paths) + const notesResult = refreshPendingNoteFiles(notesPaths) + changed = codeResult.changed || notesResult.changed + remaining = codeResult.remaining + notesResult.remaining + + // HTTP не хранит pending-флаг на записях (пропущенный файл просто + // отсутствует в state): если среди недокачанных путей есть ставший + // доступным http-файл, пересобираем http-пространство целиком. + // path.sep, а не '/': на Windows абсолютные пути используют '\'. + const pendingHttpPaths = getPendingCloudPaths().filter(candidatePath => + candidatePath.startsWith(`${httpPaths.httpRoot}${path.sep}`), + ) + let hasReadyHttpFile = false + let remainingHttp = 0 + for (const candidatePath of pendingHttpPaths) { + if (getFileAvailability(candidatePath).isCloudPlaceholder) { + remainingHttp += 1 + } + else { + hasReadyHttpFile = true + } + } + + if (hasReadyHttpFile) { + syncHttpRuntimeWithDisk(httpPaths) + changed = true + } + remaining += remainingHttp + } + catch (error) { + log('storage:markdown:cloud-refresh', error) + } + + if (changed) { + broadcastStorageSynced() + } + + if (remaining > 0) { + scheduleCloudRefresh(vaultRootPath, paths, notesPaths, httpPaths) + } + }, CLOUD_REFRESH_INTERVAL_MS) } export function stopMarkdownWatcher(): void { watcherStartToken += 1 + preparedCloudVaultPath = null + preparedWatcherStartToken = null if (markdownWatchTimer) { clearTimeout(markdownWatchTimer) markdownWatchTimer = null } + if (cloudRefreshTimer) { + clearTimeout(cloudRefreshTimer) + cloudRefreshTimer = null + } + + pendingSyncSince = null + if (markdownWatcher) { void markdownWatcher.close() markdownWatcher = null } watchedVaultPath = null - pendingFilePath = null + pendingCodeFilePaths.clear() + pendingNoteFilePaths.clear() hasPendingFullSync = false hasPendingMathSync = false hasPendingNotesSync = false hasPendingHttpSync = false + hasPendingDrawingsSync = false + resetCloudDownloads() resetRuntimeCache() resetNotesRuntimeCache() resetHttpRuntimeCache() + resetPathsCache() + resetNotesPathsCache() } -export function startMarkdownWatcher(): void { - const vaultRootPath = getVaultPath() +function initializeMarkdownWatcher(vaultRootPath: string): void { + const canReusePreparedCloudQueue + = preparedCloudVaultPath === vaultRootPath + && preparedWatcherStartToken === watcherStartToken + && markdownWatcher === null + && watchedVaultPath === null + + // Право сохранить раннюю очередь одноразовое: повторный start, даже пока + // первый ещё ждёт chokidar или cloud bootstrap, снова проходит stop/reset. + preparedCloudVaultPath = null + preparedWatcherStartToken = null + const paths = getPaths(vaultRootPath) const runtimeCache = peekRuntimeCache() const notesPaths = getNotesPaths(vaultRootPath) @@ -264,11 +501,70 @@ export function startMarkdownWatcher(): void { return } - stopMarkdownWatcher() + if (!canReusePreparedCloudQueue) { + stopMarkdownWatcher() + } + + // Обработчик регистрируется до первых сканов: они уже могут ставить + // облачные плейсхолдеры в очередь докачки. Докачанный файл проходит через + // общий инкрементальный sync-конвейер watcher, как внешнее изменение. + // onQueueActivity взводит self-heal: он перепроверяет недокачанные записи + // напрямую (stat), не полагаясь на fs-события, которых материализация + // iCloud не порождает (mtime/size не меняются при докачке контента). + configureCloudDownloads({ + onDownloaded: (absolutePath) => { + const assetName = getNotesAssetNameFromAbsolutePath( + notesPaths, + absolutePath, + ) + if (assetName) { + broadcastNotesAssetReady(assetName) + const notesCache = peekNotesRuntimeCache() + if (notesCache?.paths.notesRoot === notesPaths.notesRoot) { + scheduleNotesAssetsMigration(notesCache) + } + return + } + + const relativeWatchPath = normalizeRelativeWatchPath( + vaultRootPath, + absolutePath, + ) + + if (!relativeWatchPath) { + return + } + + scheduleStateSync( + vaultRootPath, + paths, + relativeWatchPath, + isNotesWatchPath(relativeWatchPath), + ) + }, + onQueueActivity: () => { + scheduleCloudRefresh(vaultRootPath, paths, notesPaths, httpPaths) + }, + }) + ensureStateFile(paths) - syncRuntimeWithDisk(paths) - syncNotesRuntimeWithDisk(notesPaths) - syncHttpRuntimeWithDisk(httpPaths) + + // Первичный скан каждого пространства падает независимо: недокачанный + // служебный файл (state, метаданные) прерывает только свой скан, а + // watcher всё равно запускается. Кэш заполнится после фоновой докачки + // или первого успешного обращения через API. + const syncSafely = (label: string, sync: () => unknown): void => { + try { + sync() + } + catch (error) { + log(`storage:markdown:initial-sync:${label}`, error) + } + } + + syncSafely('code', () => syncRuntimeWithDisk(paths)) + syncSafely('notes', () => syncNotesRuntimeWithDisk(notesPaths)) + syncSafely('http', () => syncHttpRuntimeWithDisk(httpPaths)) const startToken = ++watcherStartToken @@ -305,11 +601,14 @@ export function startMarkdownWatcher(): void { ) }) .on('unlink', (changedPath: string) => { - scheduleStateSync( + const relativePath = normalizeRelativeWatchPath( vaultRootPath, - paths, - normalizeRelativeWatchPath(vaultRootPath, changedPath), + changedPath, ) + if (isManagedNotesAssetsPath(relativePath)) { + return + } + scheduleStateSync(vaultRootPath, paths, relativePath) }) .on('addDir', (changedPath: string) => { scheduleStateSync( @@ -320,12 +619,14 @@ export function startMarkdownWatcher(): void { ) }) .on('unlinkDir', (changedPath: string) => { - scheduleStateSync( + const relativePath = normalizeRelativeWatchPath( vaultRootPath, - paths, - normalizeRelativeWatchPath(vaultRootPath, changedPath), - true, + changedPath, ) + if (isManagedNotesAssetsPath(relativePath)) { + return + } + scheduleStateSync(vaultRootPath, paths, relativePath, true) }) .on('error', (error: unknown) => { log('storage:markdown:watcher-error', error) @@ -347,3 +648,99 @@ export function startMarkdownWatcher(): void { log('storage:markdown:watcher-start', error) }) } + +function configureCloudBootstrapRecovery(vaultRootPath: string): void { + const expectedStartToken = watcherStartToken + let isRetryScheduled = false + + const scheduleRetry = (): void => { + if (isRetryScheduled) { + return + } + + isRetryScheduled = true + setImmediate(() => { + isRetryScheduled = false + + if ( + watcherStartToken !== expectedStartToken + || getVaultPath() !== vaultRootPath + ) { + return + } + + try { + if (tryStartMarkdownWatcher(vaultRootPath)) { + broadcastStorageSynced() + } + } + catch (error) { + log('storage:markdown:watcher-hydration-retry', error) + } + }) + } + + configureCloudDownloads({ + onDownloaded: scheduleRetry, + onQueueActivity: () => {}, + }) + + // Файл мог материализоваться между первым stat и постановкой callback. + // Если очередь уже пуста, один отложенный retry закрывает это окно без + // polling-loop во время обычной активной загрузки. + if (getPendingCloudPaths().length === 0) { + scheduleRetry() + } +} + +function tryStartMarkdownWatcher(vaultRootPath: string): boolean { + try { + initializeMarkdownWatcher(vaultRootPath) + return true + } + catch (error) { + if (!isCloudFileNotDownloadedError(error)) { + throw error + } + + // Разрешение legacy-layout может найти offloaded state до регистрации + // обычных watcher callbacks. Сохраняем загрузку и повторяем startup, + // когда облачный провайдер материализует файл. + configureCloudBootstrapRecovery(vaultRootPath) + return false + } +} + +export function startMarkdownWatcher(): void { + tryStartMarkdownWatcher(getVaultPath()) +} + +export function prepareMarkdownWatcher(): void { + if ( + markdownWatcher + || watchedVaultPath + || preparedCloudVaultPath + || watcherStartToken !== 0 + ) { + return + } + + const vaultRootPath = getVaultPath() + const notesPaths = getNotesPaths(vaultRootPath) + + configureCloudDownloads({ + onDownloaded: (absolutePath) => { + const assetName = getNotesAssetNameFromAbsolutePath( + notesPaths, + absolutePath, + ) + if (assetName) { + broadcastNotesAssetReady(assetName) + } + }, + onQueueActivity: () => {}, + }) + + preparedCloudVaultPath = vaultRootPath + preparedWatcherStartToken = watcherStartToken +} diff --git a/src/main/storage/providers/markdown/watcherPaths.ts b/src/main/storage/providers/markdown/watcherPaths.ts index d16bc7917..9d28f3d60 100644 --- a/src/main/storage/providers/markdown/watcherPaths.ts +++ b/src/main/storage/providers/markdown/watcherPaths.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { CODE_SPACE_ID, + DRAWINGS_SPACE_ID, HTTP_SPACE_ID, INBOX_DIR_NAME, MATH_SPACE_ID, @@ -13,6 +14,8 @@ import { toPosixPath } from './runtime/shared/path' export const NOTES_SPACE_WATCH_PREFIX = NOTES_SPACE_ID.toLowerCase() export const CODE_SPACE_WATCH_PREFIX = CODE_SPACE_ID.toLowerCase() +const MANAGED_NOTES_ASSETS_WATCH_ROOT + = `${NOTES_SPACE_WATCH_PREFIX}/${META_DIR_NAME}/assets`.toLowerCase() export function normalizeRelativeWatchPath( watchRootPath: string, @@ -92,6 +95,31 @@ export function isNotesWatchPath(relativePath: string | null): boolean { return getWatchPathSpaceId(relativePath) === NOTES_SPACE_ID } +export function isManagedNotesAssetsPath(relativePath: string | null): boolean { + if (!relativePath) { + return false + } + + const normalizedRelativePath = relativePath.toLowerCase() + return ( + normalizedRelativePath === MANAGED_NOTES_ASSETS_WATCH_ROOT + || normalizedRelativePath.startsWith(`${MANAGED_NOTES_ASSETS_WATCH_ROOT}/`) + ) +} + +export function getManagedNotesAssetName( + relativePath: string | null, +): string | null { + if (!isManagedNotesAssetsPath(relativePath) || !relativePath) { + return null + } + + const fileName = relativePath.slice( + MANAGED_NOTES_ASSETS_WATCH_ROOT.length + 1, + ) + return fileName && !fileName.includes('/') ? fileName : null +} + export function isCodeWatchPath(relativePath: string | null): boolean { return getWatchPathSpaceId(relativePath) === CODE_SPACE_ID } @@ -104,6 +132,10 @@ export function isHttpWatchPath(relativePath: string | null): boolean { return getWatchPathSpaceId(relativePath) === HTTP_SPACE_ID } +export function isDrawingsWatchPath(relativePath: string | null): boolean { + return getWatchPathSpaceId(relativePath) === DRAWINGS_SPACE_ID +} + export function toCodeRelativePath(relativePath: string): string | null { const normalizedRelativePath = relativePath.toLowerCase() @@ -118,3 +150,18 @@ export function toCodeRelativePath(relativePath: string): string | null { return relativePath.slice(codePrefix.length) } + +export function toNotesRelativePath(relativePath: string): string | null { + const normalizedRelativePath = relativePath.toLowerCase() + + if (normalizedRelativePath === NOTES_SPACE_WATCH_PREFIX) { + return null + } + + const notesPrefix = `${NOTES_SPACE_WATCH_PREFIX}/` + if (!normalizedRelativePath.startsWith(notesPrefix)) { + return null + } + + return relativePath.slice(notesPrefix.length) +} diff --git a/src/main/store/__tests__/preferences.test.ts b/src/main/store/__tests__/preferences.test.ts index a1afd5814..d617e38d2 100644 --- a/src/main/store/__tests__/preferences.test.ts +++ b/src/main/store/__tests__/preferences.test.ts @@ -111,6 +111,68 @@ afterEach(() => { }) describe('preferences store sanitization', () => { + it('defaults table wrapping to false', async () => { + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('editor.notes.wrapTables' as any)).toBe(false) + }) + + it('keeps valid table wrapping and rejects invalid values', async () => { + persistedStateByName.preferences = { + editor: { notes: { wrapTables: true } }, + } + + const { default: preferences } = await import('../module/preferences') + expect(preferences.get('editor.notes.wrapTables' as any)).toBe(true) + + vi.resetModules() + persistedStateByName.preferences = { + editor: { notes: { wrapTables: 'yes' } }, + } + + const { default: invalidPreferences } = await import( + '../module/preferences' + ) + expect(invalidPreferences.get('editor.notes.wrapTables' as any)).toBe( + false, + ) + }) + + it('defaults missing dock badge source to none', async () => { + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('appearance.dockBadgeSource' as any)).toBe('none') + }) + + it('keeps a valid dock badge source and prunes stale appearance values', async () => { + persistedStateByName.preferences = { + appearance: { + theme: 'dark', + dockBadgeSource: 'notesInbox', + stale: true, + }, + } + + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('appearance.dockBadgeSource' as any)).toBe( + 'notesInbox', + ) + expect(preferences.get('appearance.stale' as any)).toBeUndefined() + }) + + it('defaults an invalid dock badge source to none', async () => { + persistedStateByName.preferences = { + appearance: { + dockBadgeSource: 'invalid', + }, + } + + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('appearance.dockBadgeSource' as any)).toBe('none') + }) + it('migrates legacy keys into grouped preferences schema and prunes stale values', async () => { persistedStateByName.preferences = { storagePath: '/custom-storage', @@ -140,6 +202,9 @@ describe('preferences store sanitization', () => { expect(preferences.get('storage.rootPath' as any)).toBe('/custom-storage') expect(preferences.get('api.port' as any)).toBe(9876) + expect(preferences.get('api.integrations.enabled' as any)).toBe(false) + expect(preferences.get('api.integrations.tokenHash' as any)).toBeNull() + expect(preferences.get('api.integrations.tokenPreview' as any)).toBeNull() expect(preferences.get('localization.locale' as any)).toBe('ru_RU') expect(preferences.get('appearance.theme' as any)).toBe('dark') expect(preferences.get('editor.code.fontSize' as any)).toBe(18) @@ -167,12 +232,37 @@ describe('preferences store sanitization', () => { ).toBeUndefined() }) + it('keeps persisted sqliteMigrated flag and defaults it to false', async () => { + persistedStateByName.preferences = { + storage: { + sqliteMigrated: true, + }, + } + + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('storage.sqliteMigrated' as any)).toBe(true) + }) + + it('defaults sqliteMigrated to false for invalid or missing value', async () => { + persistedStateByName.preferences = { + storage: { + sqliteMigrated: 'bad', + }, + } + + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('storage.sqliteMigrated' as any)).toBe(false) + }) + it('keeps persisted http settings and prunes stale values', async () => { persistedStateByName.preferences = { http: { wrapLines: false, defaultPreviewFormat: 'curl', autoSwitchToResponse: false, + skipCertificateVerification: true, garbage: 'bad', }, } @@ -182,6 +272,9 @@ describe('preferences store sanitization', () => { expect(preferences.get('http.wrapLines' as any)).toBe(false) expect(preferences.get('http.defaultPreviewFormat' as any)).toBe('curl') expect(preferences.get('http.autoSwitchToResponse' as any)).toBe(false) + expect(preferences.get('http.skipCertificateVerification' as any)).toBe( + true, + ) expect(preferences.get('http.garbage' as any)).toBeUndefined() }) @@ -191,6 +284,7 @@ describe('preferences store sanitization', () => { wrapLines: 'bad', defaultPreviewFormat: 'bad', autoSwitchToResponse: 'bad', + skipCertificateVerification: 'bad', }, } @@ -199,6 +293,31 @@ describe('preferences store sanitization', () => { expect(preferences.get('http.wrapLines' as any)).toBe(true) expect(preferences.get('http.defaultPreviewFormat' as any)).toBe('http') expect(preferences.get('http.autoSwitchToResponse' as any)).toBe(true) + expect(preferences.get('http.skipCertificateVerification' as any)).toBe( + false, + ) + }) + + it('keeps sanitized API integration settings', async () => { + persistedStateByName.preferences = { + api: { + integrations: { + enabled: true, + tokenHash: 'hash', + tokenPreview: 'mc_...abcd', + stale: true, + }, + }, + } + + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('api.integrations.enabled' as any)).toBe(true) + expect(preferences.get('api.integrations.tokenHash' as any)).toBe('hash') + expect(preferences.get('api.integrations.tokenPreview' as any)).toBe( + 'mc_...abcd', + ) + expect(preferences.get('api.integrations.stale' as any)).toBeUndefined() }) }) @@ -212,6 +331,13 @@ describe('app store sanitization', () => { codeLayoutMode: 'all-panels', legacyStateFlag: true, }, + code: { + contentSort: { + sort: 'updatedAt', + order: 'ASC', + garbage: 'bad', + }, + }, compactListMode: true, sizes: { tagsListHeight: 30, @@ -230,6 +356,13 @@ describe('app store sanitization', () => { legacyNotesStateFlag: true, }, notesEditorMode: 'preview', + notes: { + contentSort: { + sort: 'name', + order: 'DESC', + garbage: 'bad', + }, + }, lastNotifiedUpdateVersion: '4.7.1', legacyRootFlag: true, } @@ -245,6 +378,11 @@ describe('app store sanitization', () => { folderId: 5, }) expect(app.get('code.layout.mode' as any)).toBe('all-panels') + expect(app.get('code.contentSort' as any)).toEqual({ + sort: 'updatedAt', + order: 'ASC', + }) + expect(app.get('code.contentSort.garbage' as any)).toBeUndefined() expect(app.get('ui.compactListMode' as any)).toBe(true) expect(app.get('code.layout.tagsListHeight' as any)).toBe(200) expect(app.get('code.layout.threePanel' as any)).toEqual([15, 20, 65]) @@ -254,6 +392,11 @@ describe('app store sanitization', () => { folderId: 7, }) expect(app.get('notes.editorMode' as any)).toBe('preview') + expect(app.get('notes.contentSort' as any)).toEqual({ + sort: 'name', + order: 'DESC', + }) + expect(app.get('notes.contentSort.garbage' as any)).toBeUndefined() expect(app.get('notes.layout.mode' as any)).toBe('list-editor') expect(app.get('notes.layout.threePanel' as any)).toEqual([10, 25, 65]) expect(app.get('notes.layout.twoPanel' as any)).toBeUndefined() @@ -400,6 +543,11 @@ describe('app store sanitization', () => { it('keeps persisted http layout values', async () => { persistedStateByName.app = { http: { + contentSort: { + sort: 'updatedAt', + order: 'ASC', + garbage: 'bad', + }, layout: { mode: 'list-editor', environmentsListHeight: 180, @@ -414,6 +562,11 @@ describe('app store sanitization', () => { const { default: app } = await import('../module/app') expect(app.get('http.layout.mode' as any)).toBe('list-editor') + expect(app.get('http.contentSort' as any)).toEqual({ + sort: 'updatedAt', + order: 'ASC', + }) + expect(app.get('http.contentSort.garbage' as any)).toBeUndefined() expect(app.get('http.layout.environmentsListHeight' as any)).toBe(180) expect(app.get('http.layout.threePanel' as any)).toEqual([20, 30]) expect(app.get('http.layout.twoPanel' as any)).toBe(35) @@ -421,6 +574,38 @@ describe('app store sanitization', () => { expect(app.get('http.layout.garbage' as any)).toBeUndefined() }) + it('keeps persisted local-space sort values', async () => { + persistedStateByName.app = { + math: { + contentSort: { + sort: 'updatedAt', + order: 'ASC', + garbage: 'bad', + }, + }, + drawings: { + contentSort: { + sort: 'name', + order: 'DESC', + garbage: 'bad', + }, + }, + } + + const { default: app } = await import('../module/app') + + expect(app.get('math.contentSort' as any)).toEqual({ + sort: 'updatedAt', + order: 'ASC', + }) + expect(app.get('math.contentSort.garbage' as any)).toBeUndefined() + expect(app.get('drawings.contentSort' as any)).toEqual({ + sort: 'name', + order: 'DESC', + }) + expect(app.get('drawings.contentSort.garbage' as any)).toBeUndefined() + }) + it('keeps persisted http donation counters', async () => { persistedStateByName.app = { donations: { diff --git a/src/main/store/constants.ts b/src/main/store/constants.ts index b9c16cc99..7eb4dc88e 100644 --- a/src/main/store/constants.ts +++ b/src/main/store/constants.ts @@ -29,6 +29,7 @@ export const NOTES_EDITOR_DEFAULTS: NotesEditorSettings = { codeFontFamily: 'SF Mono, Consolas, Menlo, Ubuntu Mono, monospace', lineHeight: 1.54, limitWidth: true, + wrapTables: false, lineNumbers: false, indentSize: 2, } diff --git a/src/main/store/module/app.ts b/src/main/store/module/app.ts index 4a48169c6..b3b9547da 100644 --- a/src/main/store/module/app.ts +++ b/src/main/store/module/app.ts @@ -5,7 +5,9 @@ import type { CommandPaletteRecentTarget, CommandPaletteUsageEntry, CommandPaletteUsageTarget, + ContentSortState, DonationsState, + DrawingViewportState, HttpState, NotesEditorMode, NotesRouteName, @@ -35,6 +37,10 @@ const APP_STORE_DEFAULTS: AppStore = { }, code: { selection: {}, + contentSort: { + sort: 'createdAt', + order: 'DESC', + }, layout: { mode: 'all-panels', tagsListHeight: LAYOUT_DEFAULTS.tags.height, @@ -42,6 +48,10 @@ const APP_STORE_DEFAULTS: AppStore = { }, http: { selection: {}, + contentSort: { + sort: 'createdAt', + order: 'DESC', + }, layout: { mode: 'all-panels', environmentsListHeight: LAYOUT_DEFAULTS.http.environmentsPanel.height, @@ -49,8 +59,14 @@ const APP_STORE_DEFAULTS: AppStore = { }, notes: { selection: {}, + contentSort: { + sort: 'createdAt', + order: 'DESC', + }, route: 'notes-space', editorMode: 'livePreview', + hideCompletedTasksInFolders: false, + lastTasksCleanupAt: 0, dashboard: { widgets: { stats: true, @@ -65,8 +81,20 @@ const APP_STORE_DEFAULTS: AppStore = { tagsListHeight: LAYOUT_DEFAULTS.tags.height, }, }, + math: { + contentSort: { + sort: 'createdAt', + order: 'DESC', + }, + }, notifications: { lastNotifiedUpdateVersion: '', + lastWhatsNewVersion: '', + }, + license: { + key: null, + name: null, + email: null, }, commandPalette: { recent: [], @@ -81,12 +109,14 @@ const APP_STORE_DEFAULTS: AppStore = { notes: 0, math: 0, tools: 0, + drawings: 0, }, created: { code: 0, http: 0, notes: 0, math: 0, + drawings: 0, }, sent: { http: 0, @@ -97,12 +127,14 @@ const APP_STORE_DEFAULTS: AppStore = { notes: 0, math: 0, tools: 0, + drawings: 0, }, lastShownCreatedMilestones: { code: 0, http: 0, notes: 0, math: 0, + drawings: 0, }, lastShownSentMilestones: { http: 0, @@ -110,6 +142,14 @@ const APP_STORE_DEFAULTS: AppStore = { shownStreakMilestones: [], lastGreetingDay: '', }, + drawings: { + contentSort: { + sort: 'createdAt', + order: 'DESC', + }, + activeDrawingId: null, + viewport: {}, + }, activeSpaceId: 'code', } @@ -202,12 +242,18 @@ function sanitizeDonations(value: unknown): DonationsState { notes: readNumber(copiesSource, 'notes', defaults.copies.notes), math: readNumber(copiesSource, 'math', defaults.copies.math), tools: readNumber(copiesSource, 'tools', defaults.copies.tools), + drawings: readNumber(copiesSource, 'drawings', defaults.copies.drawings), }, created: { code: readNumber(createdSource, 'code', defaults.created.code), http: readNumber(createdSource, 'http', defaults.created.http), notes: readNumber(createdSource, 'notes', defaults.created.notes), math: readNumber(createdSource, 'math', defaults.created.math), + drawings: readNumber( + createdSource, + 'drawings', + defaults.created.drawings, + ), }, sent: { http: readNumber(sentSource, 'http', defaults.sent.http), @@ -238,6 +284,11 @@ function sanitizeDonations(value: unknown): DonationsState { 'tools', defaults.lastShownCopyMilestones.tools, ), + drawings: readNumber( + copyMilestonesSource, + 'drawings', + defaults.lastShownCopyMilestones.drawings, + ), }, lastShownCreatedMilestones: { code: readNumber( @@ -260,6 +311,11 @@ function sanitizeDonations(value: unknown): DonationsState { 'math', defaults.lastShownCreatedMilestones.math, ), + drawings: readNumber( + createdMilestonesSource, + 'drawings', + defaults.lastShownCreatedMilestones.drawings, + ), }, lastShownSentMilestones: { http: readNumber( @@ -277,6 +333,20 @@ function sanitizeDonations(value: unknown): DonationsState { } } +function sanitizeContentSort(value: unknown): ContentSortState { + const source = asRecord(value) + + return { + sort: readEnum( + source, + 'sort', + ['createdAt', 'updatedAt', 'name'] as const, + 'createdAt', + ), + order: readEnum(source, 'order', ['ASC', 'DESC'] as const, 'DESC'), + } +} + function sanitizeCommandPaletteRecent( value: unknown, ): CommandPaletteRecentEntry[] { @@ -296,6 +366,7 @@ function sanitizeCommandPaletteRecent( 'math', 'notes', 'http', + 'drawings', ] satisfies SpaceId[] const entries: CommandPaletteRecentEntry[] = [] const seen = new Set() @@ -397,6 +468,32 @@ function sanitizeCommandPaletteUsage( return entries.slice(0, 100) } +function sanitizeDrawingViewports( + value: unknown, +): Record { + const source = asRecord(value) + const result: Record = {} + + for (const [drawingId, rawViewport] of Object.entries(source)) { + const viewport = asRecord(rawViewport) + + if ( + typeof viewport.scrollX === 'number' + && typeof viewport.scrollY === 'number' + && typeof viewport.zoom === 'number' + && viewport.zoom > 0 + ) { + result[drawingId] = { + scrollX: viewport.scrollX, + scrollY: viewport.scrollY, + zoom: viewport.zoom, + } + } + } + + return result +} + function sanitizeAppStore(value: unknown): AppStore { const source = asRecord(value) const windowSource = asRecord(source.window) @@ -432,6 +529,7 @@ function sanitizeAppStore(value: unknown): AppStore { ? codeSource.selection : source.state, ), + contentSort: sanitizeContentSort(codeSource.contentSort), layout: { mode: readEnum( codeLayoutSource, @@ -462,6 +560,7 @@ function sanitizeAppStore(value: unknown): AppStore { }, http: { selection: sanitizeHttpState(httpSource.selection), + contentSort: sanitizeContentSort(httpSource.contentSort), layout: { mode: readEnum( httpLayoutSource, @@ -501,6 +600,7 @@ function sanitizeAppStore(value: unknown): AppStore { ? notesSource.selection : source.notesState, ), + contentSort: sanitizeContentSort(notesSource.contentSort), route: readEnum( notesSource, 'route', @@ -527,6 +627,15 @@ function sanitizeAppStore(value: unknown): AppStore { APP_STORE_DEFAULTS.notes.editorMode, ) as NotesEditorMode, ), + hideCompletedTasksInFolders: + typeof notesSource.hideCompletedTasksInFolders === 'boolean' + ? notesSource.hideCompletedTasksInFolders + : APP_STORE_DEFAULTS.notes.hideCompletedTasksInFolders, + lastTasksCleanupAt: readNumber( + notesSource, + 'lastTasksCleanupAt', + APP_STORE_DEFAULTS.notes.lastTasksCleanupAt, + ), dashboard: { widgets: (() => { const dashSource = asRecord(asRecord(notesSource.dashboard).widgets) @@ -591,16 +700,49 @@ function sanitizeAppStore(value: unknown): AppStore { : typeof source.lastNotifiedUpdateVersion === 'string' ? source.lastNotifiedUpdateVersion : APP_STORE_DEFAULTS.notifications.lastNotifiedUpdateVersion, + lastWhatsNewVersion: + typeof notificationsSource.lastWhatsNewVersion === 'string' + ? notificationsSource.lastWhatsNewVersion + : APP_STORE_DEFAULTS.notifications.lastWhatsNewVersion, }, + license: (() => { + const licenseSource = asRecord(source.license) + + return { + key: + typeof licenseSource.key === 'string' && licenseSource.key + ? licenseSource.key + : null, + name: + typeof licenseSource.name === 'string' && licenseSource.name + ? licenseSource.name + : null, + email: + typeof licenseSource.email === 'string' && licenseSource.email + ? licenseSource.email + : null, + } + })(), commandPalette: { recent: sanitizeCommandPaletteRecent(commandPaletteSource.recent), usage: sanitizeCommandPaletteUsage(commandPaletteSource.usage), }, donations: sanitizeDonations(source.donations), + math: { + contentSort: sanitizeContentSort(asRecord(source.math).contentSort), + }, + drawings: { + contentSort: sanitizeContentSort(asRecord(source.drawings).contentSort), + activeDrawingId: + typeof asRecord(source.drawings).activeDrawingId === 'string' + ? String(asRecord(source.drawings).activeDrawingId) + : APP_STORE_DEFAULTS.drawings.activeDrawingId, + viewport: sanitizeDrawingViewports(asRecord(source.drawings).viewport), + }, activeSpaceId: readEnum( source, 'activeSpaceId', - ['code', 'tools', 'math', 'notes', 'http'] as const, + ['code', 'tools', 'math', 'notes', 'http', 'drawings'] as const, APP_STORE_DEFAULTS.activeSpaceId, ) as SpaceId, } diff --git a/src/main/store/module/preferences.ts b/src/main/store/module/preferences.ts index 041252636..dd5f58a36 100644 --- a/src/main/store/module/preferences.ts +++ b/src/main/store/module/preferences.ts @@ -5,6 +5,7 @@ import type { MathSettings, NotesEditorSettings, PreferencesStore, + TasksSettings, } from '../types' import { homedir, platform } from 'node:os' import Store from 'electron-store' @@ -32,21 +33,38 @@ const HTTP_DEFAULTS: HttpSettings = { wrapLines: true, defaultPreviewFormat: 'http', autoSwitchToResponse: true, + skipCertificateVerification: false, +} + +const TASKS_DEFAULTS: TasksSettings = { + autoCleanupCompleted: 'never', +} + +const API_INTEGRATIONS_DEFAULTS: PreferencesStore['api']['integrations'] = { + enabled: false, + tokenHash: null, + tokenPreview: null, } const PREFERENCES_DEFAULTS: PreferencesStore = { appearance: { theme: 'auto', + dockBadgeSource: 'none', + }, + updates: { + autoUpdate: true, }, localization: { locale: 'en_US', }, api: { port: 4321, + integrations: API_INTEGRATIONS_DEFAULTS, }, storage: { rootPath: storagePath, vaultPath: null, + sqliteMigrated: false, }, editor: { code: EDITOR_DEFAULTS, @@ -57,6 +75,30 @@ const PREFERENCES_DEFAULTS: PreferencesStore = { }, math: MATH_DEFAULTS, http: HTTP_DEFAULTS, + tasks: TASKS_DEFAULTS, +} + +function sanitizeApiIntegrationsSettings( + value: unknown, +): PreferencesStore['api']['integrations'] { + const source = asRecord(value) + + return { + enabled: + typeof source.enabled === 'boolean' + ? source.enabled + : API_INTEGRATIONS_DEFAULTS.enabled, + tokenHash: readNullableString( + source, + 'tokenHash', + API_INTEGRATIONS_DEFAULTS.tokenHash, + ), + tokenPreview: readNullableString( + source, + 'tokenPreview', + API_INTEGRATIONS_DEFAULTS.tokenPreview, + ), + } } function sanitizeCodeEditorSettings(value: unknown): EditorSettings { @@ -135,6 +177,10 @@ function sanitizeNotesEditorSettings(value: unknown): NotesEditorSettings { typeof source.limitWidth === 'boolean' ? source.limitWidth : PREFERENCES_DEFAULTS.editor.notes.limitWidth, + wrapTables: + typeof source.wrapTables === 'boolean' + ? source.wrapTables + : PREFERENCES_DEFAULTS.editor.notes.wrapTables, lineNumbers: typeof source.lineNumbers === 'boolean' ? source.lineNumbers @@ -196,6 +242,23 @@ function sanitizeHttpSettings(value: unknown): HttpSettings { typeof source.autoSwitchToResponse === 'boolean' ? source.autoSwitchToResponse : HTTP_DEFAULTS.autoSwitchToResponse, + skipCertificateVerification: + typeof source.skipCertificateVerification === 'boolean' + ? source.skipCertificateVerification + : HTTP_DEFAULTS.skipCertificateVerification, + } +} + +function sanitizeTasksSettings(value: unknown): TasksSettings { + const source = asRecord(value) + + return { + autoCleanupCompleted: readEnum( + source, + 'autoCleanupCompleted', + ['never', '1d', '7d', '30d'] as const, + TASKS_DEFAULTS.autoCleanupCompleted, + ), } } @@ -220,6 +283,7 @@ function sanitizePreferences(value: unknown): PreferencesStore { : asRecord(source.markdown) const mathSource = asRecord(source.math) const httpSource = asRecord(source.http) + const tasksSource = asRecord(source.tasks) return { appearance: { @@ -228,6 +292,18 @@ function sanitizePreferences(value: unknown): PreferencesStore { 'theme', readString(source, 'theme', PREFERENCES_DEFAULTS.appearance.theme), ), + dockBadgeSource: readEnum( + appearanceSource, + 'dockBadgeSource', + ['none', 'codeInbox', 'notesInbox', 'tasksDue'] as const, + PREFERENCES_DEFAULTS.appearance.dockBadgeSource, + ), + }, + updates: { + autoUpdate: + typeof asRecord(source.updates).autoUpdate === 'boolean' + ? Boolean(asRecord(source.updates).autoUpdate) + : PREFERENCES_DEFAULTS.updates.autoUpdate, }, localization: { locale: readString( @@ -246,6 +322,7 @@ function sanitizePreferences(value: unknown): PreferencesStore { 'port', readNumber(source, 'apiPort', PREFERENCES_DEFAULTS.api.port), ), + integrations: sanitizeApiIntegrationsSettings(apiSource.integrations), }, storage: { rootPath: readString( @@ -262,6 +339,10 @@ function sanitizePreferences(value: unknown): PreferencesStore { 'vaultPath', PREFERENCES_DEFAULTS.storage.vaultPath, ), + sqliteMigrated: + typeof storageSource.sqliteMigrated === 'boolean' + ? storageSource.sqliteMigrated + : PREFERENCES_DEFAULTS.storage.sqliteMigrated, }, editor: { code: sanitizeCodeEditorSettings(codeEditorSource), @@ -270,6 +351,7 @@ function sanitizePreferences(value: unknown): PreferencesStore { }, math: sanitizeMathSettings(mathSource), http: sanitizeHttpSettings(httpSource), + tasks: sanitizeTasksSettings(tasksSource), } } diff --git a/src/main/store/types/index.ts b/src/main/store/types/index.ts index 47cae2f04..0e9f70b0e 100644 --- a/src/main/store/types/index.ts +++ b/src/main/store/types/index.ts @@ -1,5 +1,12 @@ export type SpaceLayoutMode = 'all-panels' | 'list-editor' | 'editor-only' export type NotesEditorMode = 'raw' | 'livePreview' | 'preview' +export type ContentSortField = 'createdAt' | 'updatedAt' | 'name' +export type ContentSortOrder = 'ASC' | 'DESC' + +export interface ContentSortState { + sort: ContentSortField + order: ContentSortOrder +} export interface CodeState { snippetId?: number @@ -35,7 +42,7 @@ export interface NotesDashboardWidgets { topLinked: boolean } -export type SpaceId = 'code' | 'tools' | 'math' | 'notes' | 'http' +export type SpaceId = 'code' | 'tools' | 'math' | 'notes' | 'http' | 'drawings' export type CommandPaletteRecentTarget = | 'space' | 'snippet' @@ -71,12 +78,14 @@ export interface DonationsState { notes: number math: number tools: number + drawings: number } created: { code: number http: number notes: number math: number + drawings: number } sent: { http: number @@ -87,12 +96,14 @@ export interface DonationsState { notes: number math: number tools: number + drawings: number } lastShownCreatedMilestones: { code: number http: number notes: number math: number + drawings: number } lastShownSentMilestones: { http: number @@ -110,6 +121,7 @@ export interface AppStore { } code: { selection: CodeState + contentSort: ContentSortState layout: { mode: SpaceLayoutMode tagsListHeight: number @@ -119,8 +131,11 @@ export interface AppStore { } notes: { selection: NotesState + contentSort: ContentSortState route: NotesRouteName editorMode: NotesEditorMode + hideCompletedTasksInFolders: boolean + lastTasksCleanupAt: number dashboard: { widgets: NotesDashboardWidgets } @@ -133,6 +148,7 @@ export interface AppStore { } http: { selection: HttpState + contentSort: ContentSortState layout: { mode: SpaceLayoutMode environmentsListHeight: number @@ -141,17 +157,37 @@ export interface AppStore { responsePanelHeight?: number } } + math: { + contentSort: ContentSortState + } notifications: { lastNotifiedUpdateVersion: string + lastWhatsNewVersion: string + } + license: { + key: string | null + name: string | null + email: string | null } commandPalette: { recent: CommandPaletteRecentEntry[] usage: CommandPaletteUsageEntry[] } donations: DonationsState + drawings: { + contentSort: ContentSortState + activeDrawingId: string | null + viewport: Record + } activeSpaceId: SpaceId } +export interface DrawingViewportState { + scrollX: number + scrollY: number + zoom: number +} + export interface EditorSettings { fontSize: number fontFamily: string @@ -170,6 +206,7 @@ export interface MarkdownSettings { export interface StorageSettings { vaultPath: string | null + sqliteMigrated: boolean } export interface NotesEditorSettings { @@ -178,6 +215,7 @@ export interface NotesEditorSettings { codeFontFamily: string lineHeight: number limitWidth: boolean + wrapTables: boolean lineNumbers: boolean indentSize: number } @@ -192,17 +230,36 @@ export interface HttpSettings { wrapLines: boolean defaultPreviewFormat: 'http' | 'curl' autoSwitchToResponse: boolean + skipCertificateVerification: boolean +} + +export interface UpdatesSettings { + autoUpdate: boolean +} + +export type TasksAutoCleanupInterval = 'never' | '1d' | '7d' | '30d' +export type DockBadgeSource = 'none' | 'codeInbox' | 'notesInbox' | 'tasksDue' + +export interface TasksSettings { + autoCleanupCompleted: TasksAutoCleanupInterval } export interface PreferencesStore { appearance: { theme: string + dockBadgeSource: DockBadgeSource } + updates: UpdatesSettings localization: { locale: string } api: { port: number + integrations: { + enabled: boolean + tokenHash: string | null + tokenPreview: string | null + } } storage: StorageSettings & { rootPath: string @@ -214,6 +271,7 @@ export interface PreferencesStore { } math: MathSettings http: HttpSettings + tasks: TasksSettings } export interface MathSheet { diff --git a/src/main/tasks.ts b/src/main/tasks.ts new file mode 100644 index 000000000..46396f2a7 --- /dev/null +++ b/src/main/tasks.ts @@ -0,0 +1,86 @@ +import type { TasksAutoCleanupInterval } from './store/types' +import { useNotesStorage } from './storage' +import { store } from './store' + +const DAY_MS = 24 * 60 * 60 * 1000 + +const INTERVAL_MS: Record< + Exclude, + number +> = { + '1d': DAY_MS, + '7d': 7 * DAY_MS, + '30d': 30 * DAY_MS, +} + +const CHECK_INTERVAL_MS = 60 * 60 * 1000 + +let timer: ReturnType | null = null + +export function cleanupCompletedTasks(): number { + const storage = useNotesStorage() + const completed = storage.notes.getNotes({ + propertyType: 'task', + propertyStatus: 'done', + }) + + let moved = 0 + + for (const note of completed) { + const result = storage.notes.updateNote(note.id, { + folderId: null, + isDeleted: 1, + }) + + if (!result.notFound && !result.invalidInput) { + moved++ + } + } + + return moved +} + +export function runTasksCleanupNow(): number { + const moved = cleanupCompletedTasks() + store.app.set('notes.lastTasksCleanupAt', Date.now()) + + return moved +} + +export function runTasksAutoCleanupIfDue(): void { + const interval = store.preferences.get('tasks.autoCleanupCompleted') as + | TasksAutoCleanupInterval + | undefined + + if (!interval || interval === 'never') { + return + } + + const lastRunAt + = (store.app.get('notes.lastTasksCleanupAt') as number | undefined) ?? 0 + const now = Date.now() + + if (now - lastRunAt < INTERVAL_MS[interval]) { + return + } + + cleanupCompletedTasks() + store.app.set('notes.lastTasksCleanupAt', now) +} + +export function startTasksCleanupScheduler(): void { + runTasksAutoCleanupIfDue() + + if (timer) { + return + } + + timer = setInterval(runTasksAutoCleanupIfDue, CHECK_INTERVAL_MS) +} + +export function stopTasksCleanupScheduler(): void { + if (timer) { + clearInterval(timer) + timer = null + } +} diff --git a/src/main/types/http.ts b/src/main/types/http.ts index 8e26489aa..d2d94e5a6 100644 --- a/src/main/types/http.ts +++ b/src/main/types/http.ts @@ -58,6 +58,7 @@ export interface HttpExecutePayload { request: HttpExecuteRequest requestId: number | null environmentId: number | null + skipCertificateVerification?: boolean timeoutMs?: number } diff --git a/src/main/types/ipc.ts b/src/main/types/ipc.ts index ddeb2f45a..bb20b9b06 100644 --- a/src/main/types/ipc.ts +++ b/src/main/types/ipc.ts @@ -11,10 +11,13 @@ type MainMenuAction = | 'font-size-increase' | 'font-size-reset' | 'format' + | 'normalize-code-line-breaks' + | 'normalize-note-line-breaks' | 'goto-preferences' | 'goto-devtools' | 'new-note' | 'new-note-folder' + | 'new-task' | 'new-folder' | 'new-fragment' | 'new-sheet' @@ -26,39 +29,67 @@ type MainMenuAction = | 'preview-code' | 'preview-json' | 'presentation-mode' + | 'set-content-sort-field' + | 'set-content-sort-order' | 'set-layout-mode' | 'set-notes-editor-mode' | 'send-http-request' | 'toggle-sidebar' | 'toggle-compact-mode' + | 'toggle-hide-completed-tasks' | 'update-context' | 'goto-math-notebook' type DBAction = 'migrate-to-markdown' type SystemAction = + | 'activate-license' + | 'api-token-generate' + | 'api-token-revoke' | 'currency-rates' | 'currency-rates-refresh' | 'crypto-rates-refresh' | 'get-directory-state' | 'reload' | 'move-vault' + | 'set-vault-path' | 'open-external' + | 'refresh-dock-badge' | 'show-http-request-in-file-manager' | 'show-notes-folder-in-file-manager' | 'show-note-in-file-manager' | 'show-snippet-in-file-manager' | 'deep-link' + | 'install-update' | 'update-available' + | 'update-downloaded' | 'renderer-ready' | 'storage-synced' + | 'cloud-download-status' + | 'cloud-download-progress' | 'migration-complete' | 'migration-error' + | 'notes-asset-ready' | 'error' type PrettierAction = 'format' -type FsAction = 'assets' | 'import-markdown-folder' | 'notes-asset' +type FsAction = + | 'assets' + | 'folder-icon:set' + | 'folder-icon:write' + | 'import-markdown-folder' + | 'notes-asset' type ThemeAction = 'list' | 'get' | 'open-dir' | 'create-template' | 'changed' -type SpacesAction = 'math:read' | 'math:write' | 'http:execute' +type SpacesAction = + | 'math:read' + | 'math:write' + | 'http:execute' + | 'drawings:list' + | 'drawings:read' + | 'drawings:write' + | 'drawings:create' + | 'drawings:rename' + | 'drawings:duplicate' + | 'drawings:delete' export type MainMenuChannel = CombineWith export type DBChannel = CombineWith @@ -91,6 +122,21 @@ export interface FsAssetsOptions { path: string } +export type FolderIconSpaceId = 'code' | 'notes' | 'http' + +export interface FolderIconTarget { + folderId: number + spaceId: FolderIconSpaceId +} + +export interface FolderIconWritePayload extends FolderIconTarget { + buffer: ArrayBuffer +} + +export interface FolderIconSetPayload extends FolderIconTarget { + icon: string | null +} + export interface ImportMarkdownFolderFile { content: string name: string diff --git a/src/main/types/menu.ts b/src/main/types/menu.ts index a79ced28d..b63900698 100644 --- a/src/main/types/menu.ts +++ b/src/main/types/menu.ts @@ -1,4 +1,6 @@ export type MainMenuLayoutMode = 'all-panels' | 'list-editor' | 'editor-only' +export type MainMenuContentSortField = 'createdAt' | 'updatedAt' | 'name' +export type MainMenuContentSortOrder = 'ASC' | 'DESC' export type MainMenuPrimaryAction = | 'new-snippet' @@ -13,13 +15,18 @@ export interface MainMenuFileContext { primaryAction: MainMenuPrimaryAction secondaryAction: MainMenuSecondaryAction canCreateFragment: boolean + canCreateTask: boolean } export interface MainMenuViewContext { layoutMode: MainMenuLayoutMode | null layoutModes: MainMenuLayoutMode[] + contentSortField: MainMenuContentSortField | null + contentSortOrder: MainMenuContentSortOrder | null canToggleCompactMode: boolean isCompactMode: boolean + canToggleHideCompletedTasks: boolean + isHideCompletedTasksInFolders: boolean canToggleMindmap: boolean isMindmapShown: boolean canTogglePresentation: boolean diff --git a/src/main/updates/index.ts b/src/main/updates/index.ts index 4eedcc88d..16302267c 100644 --- a/src/main/updates/index.ts +++ b/src/main/updates/index.ts @@ -1,125 +1,139 @@ -/* eslint-disable node/prefer-global/process */ +import { app, dialog, shell } from 'electron' +import { autoUpdater } from 'electron-updater' import { repository, version } from '../../../package.json' +import i18n from '../i18n' import { send } from '../ipc' +import { setQuitting } from '../quitState' import { store } from '../store' - -interface GitHubRelease { - tag_name: string -} +import { log } from '../utils' const INTERVAL = 1000 * 60 * 60 * 3 // 3 часа -const isDev = process.env.NODE_ENV === 'development' -const currentVersionParts = parseVersion(version)! -const currentMajorVersion = currentVersionParts[0] - -function parseVersion(rawVersion: string): [number, number, number] | null { - const normalizedVersion = rawVersion.trim().replace(/^v/, '') - const match = normalizedVersion.match(/^(\d+)\.(\d+)\.(\d+)$/) - - if (!match) { - return null - } +const currentMajorVersion = Number.parseInt(version.split('.')[0], 10) - return [ - Number.parseInt(match[1], 10), - Number.parseInt(match[2], 10), - Number.parseInt(match[3], 10), - ] +function getMajorVersion(rawVersion: string) { + return Number.parseInt(rawVersion.replace(/^v/, '').split('.')[0], 10) } -function compareVersions( - left: [number, number, number], - right: [number, number, number], -): 1 | -1 | 0 { - for (let i = 0; i < 3; i += 1) { - if (left[i] === right[i]) { - continue - } +// Уведомление без установки: для выключенного автообновления и нового мажора, +// который требует осознанного перехода (миграция vault). +function notifyAboutUpdate(latestVersion: string) { + const lastNotifiedVersion = store.app.get( + 'notifications.lastNotifiedUpdateVersion', + ) - return left[i] > right[i] ? 1 : -1 + if (lastNotifiedVersion === latestVersion) { + return } - return 0 + send('system:update-available') + store.app.set('notifications.lastNotifiedUpdateVersion', latestVersion) } -function getLatestReleaseVersion(releases: GitHubRelease[]) { - let latestParsedVersion: [number, number, number] | null = null - - for (const release of releases) { - const parsedVersion = parseVersion(release.tag_name) - if (!parsedVersion || parsedVersion[0] !== currentMajorVersion) { - continue - } - - if ( - !latestParsedVersion - || compareVersions(parsedVersion, latestParsedVersion) > 0 - ) { - latestParsedVersion = parsedVersion - } +async function runUpdateCheck() { + try { + await autoUpdater.checkForUpdates() } + catch (error) { + log('Error checking for updates', error) + } +} - return latestParsedVersion?.join('.') +export function installDownloadedUpdate() { + // На macOS quitAndInstall закрывает окна, а обработчик 'close' без этого флага + // спрятал бы окно вместо выхода и заблокировал установку. + setQuitting(true) + autoUpdater.quitAndInstall() } -function isNewerVersion(versionToCompare: string) { - const parsedVersion = parseVersion(versionToCompare) - if (!parsedVersion) { - return false +// Ручная проверка из меню. Скачивание при необходимости запустит глобальный +// обработчик 'update-available', здесь только диалоги. +export async function checkForUpdatesFromMenu() { + function showNoUpdatesDialog() { + dialog.showMessageBoxSync({ + message: i18n.t('messages:update.noAvailable'), + }) } - return compareVersions(parsedVersion, currentVersionParts) > 0 -} - -export async function fetchUpdates() { - if (isDev) { + if (!app.isPackaged) { + showNoUpdatesDialog() return } try { - const url = `${repository.replace('github.com', 'api.github.com/repos')}/releases` + const result = await autoUpdater.checkForUpdates() - const response = await fetch(url) - if (!response.ok) { + if (!result?.isUpdateAvailable) { + showNoUpdatesDialog() return } - const data = (await response.json()) as GitHubRelease[] - if (!Array.isArray(data) || data.length === 0) { + const latestVersion = result.updateInfo.version + const isAutoUpdateEnabled = store.preferences.get('updates.autoUpdate') + const isSameMajor = getMajorVersion(latestVersion) === currentMajorVersion + + if (isAutoUpdateEnabled && isSameMajor) { + dialog.showMessageBoxSync({ + message: i18n.t('messages:update.downloading', { + version: latestVersion, + }), + }) return } - const latestVersion = getLatestReleaseVersion(data) - if (latestVersion && isNewerVersion(latestVersion)) { - return latestVersion + const buttonId = dialog.showMessageBoxSync({ + message: i18n.t('messages:update.available', { + newVersion: latestVersion, + oldVersion: version, + }), + buttons: [i18n.t('button.update.0'), i18n.t('button.update.1')], + defaultId: 0, + cancelId: 1, + }) + + if (buttonId === 0) { + void shell.openExternal(`${repository}/releases`) } } - catch (err) { - console.error('Error checking for updates:', err) + catch (error) { + log('Error checking for updates', error) + showNoUpdatesDialog() } } -async function notifyAboutUpdate() { - const latestVersion = await fetchUpdates() - if (!latestVersion) { +export function checkForUpdates() { + if (!app.isPackaged) { return } - const lastNotifiedVersion = store.app.get( - 'notifications.lastNotifiedUpdateVersion', - ) - if (lastNotifiedVersion === latestVersion) { - return - } + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = true + autoUpdater.logger = null - send('system:update-available') - store.app.set('notifications.lastNotifiedUpdateVersion', latestVersion) -} + autoUpdater.on('update-available', (info) => { + const isAutoUpdateEnabled = store.preferences.get('updates.autoUpdate') + const isSameMajor = getMajorVersion(info.version) === currentMajorVersion -export function checkForUpdates() { - void notifyAboutUpdate() + if (!isAutoUpdateEnabled || !isSameMajor) { + notifyAboutUpdate(info.version) + return + } + + autoUpdater.downloadUpdate().catch((error) => { + log('Error downloading update', error) + }) + }) + + autoUpdater.on('update-downloaded', (info) => { + send('system:update-downloaded', { version: info.version }) + }) + + autoUpdater.on('error', (error) => { + log('Auto-update error', error) + }) + + void runUpdateCheck() setInterval(() => { - void notifyAboutUpdate() + void runUpdateCheck() }, INTERVAL) } diff --git a/src/renderer/App.vue b/src/renderer/App.vue index e42e7260c..cb2e45d5c 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -5,7 +5,10 @@ import { useApp, useCopyTracker, useDonationTriggers, + useSonner, useTheme, + useVaultDoctor, + VAULT_DOCTOR_NOTICE_ID, } from '@/composables' import { i18n, ipc, store } from '@/electron' import { router, RouterName } from '@/router' @@ -16,6 +19,7 @@ import { loadWASM } from 'onigasm' import onigasmFile from 'onigasm/lib/onigasm.wasm?url' import { useRoute } from 'vue-router' import { Toaster } from 'vue-sonner' +import { repository, version } from '../../package.json' import { loadGrammars } from './components/editor/grammars' import { registerIPCListeners } from './ipc' @@ -70,6 +74,135 @@ watch( useTheme() +// Одноразовый тост после смены версии приложения; контент живёт в release notes. +function showWhatsNewOnce() { + if (store.app.get('notifications.lastWhatsNewVersion') === version) { + return + } + + store.app.set('notifications.lastWhatsNewVersion', version) + + const { sonner } = useSonner() + + sonner({ + message: i18n.t('messages:update.whatsNewToast', { version }), + type: 'success', + closeButton: true, + action: { + label: i18n.t('messages:update.releaseNotes'), + onClick: () => { + ipc.invoke( + 'system:open-external', + `${repository}/releases/tag/v${version}`, + ) + }, + }, + }) +} + +// Неблокирующая проверка vault после загрузки приложения. Doctor живёт в +// Storage settings, куда пользователь почти не заходит, поэтому о конфликтах +// синхронизации (дубли id, merge-маркеры, битый frontmatter) сообщаем +// проактивно. Safe fixes сюда не входят — их watcher применяет молча. +function checkVaultHealth() { + const { sonner } = useSonner() + const { scan } = useVaultDoctor() + + // Startup-скан почти всегда стартует раньше конца фоновой сверки vault: + // на notReady проверка повторяется по событию синхронизации, иначе + // проблемный vault выглядел бы чистым до ручного скана. Fallback-таймер + // страхует от потерянного события: storage-synced может прийти, пока + // предыдущий scan ещё выполняется, и его перезапуск ничего не даст. + // + // Подписка на storage-synced ставится ОДИН раз и не снимается: + // contextBridge оборачивает функцию в новый прокси при каждой передаче, + // и ipc.removeListener по ссылке не срабатывает — динамическая + // add/remove-подписка накапливала бы обработчики на каждый event + // (см. комментарий у Editor.vue про removeListeners). + const VAULT_HEALTH_RETRY_FALLBACK_MS = 15_000 + let retryFallbackTimer: ReturnType | undefined + let isResolved = false + let isRunning = false + + const scheduleRetryFallback = () => { + if (retryFallbackTimer) { + clearTimeout(retryFallbackTimer) + } + + retryFallbackTimer = setTimeout(() => { + retryFallbackTimer = undefined + void run() + }, VAULT_HEALTH_RETRY_FALLBACK_MS) + } + + const clearRetryFallback = () => { + if (retryFallbackTimer) { + clearTimeout(retryFallbackTimer) + retryFallbackTimer = undefined + } + } + + ipc.on('system:storage-synced', () => { + void run() + }) + + async function run() { + if (isResolved || isRunning) { + return + } + + isRunning = true + try { + const data = await scan() + if (!data || data.notReady) { + // Постоянный listener и fallback-таймер гарантируют повтор. + scheduleRetryFallback() + return + } + + isResolved = true + clearRetryFallback() + + if (data.summary.conflicts === 0) { + return + } + + sonner({ + id: VAULT_DOCTOR_NOTICE_ID, + message: i18n.t('messages:warning.vaultDoctorConflicts', { + count: data.summary.conflicts, + }), + type: 'warning', + closeButton: true, + action: { + label: i18n.t('messages:warning.vaultDoctorReview'), + onClick: () => { + router.push({ + name: RouterName.preferencesStorage, + query: { doctor: 'scan' }, + }) + }, + }, + }) + } + catch { + // Health check не критичен: транзиентная ошибка не показывается, но + // повтор перепланируется — события storage-synced могли уже отгреметь. + scheduleRetryFallback() + } + finally { + isRunning = false + } + } + + if ('requestIdleCallback' in window) { + requestIdleCallback(() => run(), { timeout: 5000 }) + } + else { + setTimeout(run, 2000) + } +} + function restoreSavedSpace() { const savedSpaceId = store.app.get('activeSpaceId') if (savedSpaceId && savedSpaceId !== 'code') { @@ -88,9 +221,11 @@ async function init() { await loadGrammars() useActivityTracker() useCopyTracker() - if (!isSponsored) { + if (!isSponsored.value) { useDonationTriggers() } + showWhatsNewOnce() + checkVaultHealth() } init() diff --git a/src/renderer/components/code-space-layout/CodeSpaceLayout.vue b/src/renderer/components/code-space-layout/CodeSpaceLayout.vue index 999074eaa..31c687a45 100644 --- a/src/renderer/components/code-space-layout/CodeSpaceLayout.vue +++ b/src/renderer/components/code-space-layout/CodeSpaceLayout.vue @@ -1,9 +1,18 @@
+import { Button } from '@/components/ui/shadcn/button' +import { useCopyToClipboard } from '@/composables' +import { i18n } from '@/electron' +import { normalizeTerminalText } from '@/utils/normalizeTerminalText' + +const input = ref('') +const output = computed(() => normalizeTerminalText(input.value)) + +const copy = useCopyToClipboard() + +const title = computed(() => + i18n.t('devtools:converters.lineBreakNormalizer.label'), +) +const description = computed(() => + i18n.t('devtools:converters.lineBreakNormalizer.description'), +) + + + diff --git a/src/renderer/components/drawings/ExcalidrawCanvas.vue b/src/renderer/components/drawings/ExcalidrawCanvas.vue new file mode 100644 index 000000000..64cf727ff --- /dev/null +++ b/src/renderer/components/drawings/ExcalidrawCanvas.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/src/renderer/components/drawings/List.vue b/src/renderer/components/drawings/List.vue new file mode 100644 index 000000000..de2fa2651 --- /dev/null +++ b/src/renderer/components/drawings/List.vue @@ -0,0 +1,323 @@ + + + diff --git a/src/renderer/components/drawings/Workspace.vue b/src/renderer/components/drawings/Workspace.vue new file mode 100644 index 000000000..3e3f47e49 --- /dev/null +++ b/src/renderer/components/drawings/Workspace.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/renderer/components/drawings/excalidrawHost.ts b/src/renderer/components/drawings/excalidrawHost.ts new file mode 100644 index 000000000..2babe9bb4 --- /dev/null +++ b/src/renderer/components/drawings/excalidrawHost.ts @@ -0,0 +1,407 @@ +import type { ComponentProps } from 'react' +import type { Root } from 'react-dom/client' +import type { DrawingViewportState } from '~/main/store/types' +import { + Button, + CaptureUpdateAction, + Excalidraw, + Footer, + getSceneVersion, + restore, + serializeAsJSON, +} from '@excalidraw/excalidraw' +import { createElement } from 'react' +import { createRoot } from 'react-dom/client' +import '@excalidraw/excalidraw/index.css' + +type ExcalidrawProps = ComponentProps +type ExcalidrawApi = Parameters< + NonNullable +>[0] +type SceneElements = Parameters[0] +type SceneAppState = Parameters[1] +type SceneFiles = Parameters[2] + +const UI_OPTIONS: ExcalidrawProps['UIOptions'] = { + canvasActions: { + changeViewBackgroundColor: false, + clearCanvas: false, + export: false, + loadScene: false, + saveToActiveFile: false, + toggleTheme: false, + }, +} + +export type ExcalidrawChangeKind = 'scene' | 'viewport' + +export interface ExcalidrawHostOptions { + initialContent: string | null + initialName: string + initialViewport: DrawingViewportState | null + theme: 'light' | 'dark' + langCode: string + fitToContentLabel: string + onChange: (kind: ExcalidrawChangeKind) => void +} + +export interface ExcalidrawHost { + destroy: () => void + getViewport: () => DrawingViewportState | null + loadScene: ( + content: string | null, + name: string, + viewport: DrawingViewportState | null, + ) => void + openImageExportDialog: () => void + serializeScene: () => string | null + setTheme: (theme: 'light' | 'dark') => void +} + +function readLegacyFileViewport( + data: Parameters[0], +): DrawingViewportState | null { + const rawAppState + = data && typeof data.appState === 'object' && data.appState !== null + ? (data.appState as Record) + : {} + const rawZoom + = typeof rawAppState.zoom === 'number' + ? rawAppState.zoom + : (rawAppState.zoom as { value?: unknown } | undefined)?.value + + if ( + typeof rawAppState.scrollX !== 'number' + || typeof rawAppState.scrollY !== 'number' + ) { + return null + } + + return { + scrollX: rawAppState.scrollX, + scrollY: rawAppState.scrollY, + zoom: typeof rawZoom === 'number' && rawZoom > 0 ? rawZoom : 1, + } +} + +export function parseSceneContent( + content: string | null, + viewport: DrawingViewportState | null = null, +) { + let data: Parameters[0] = null + + if (content) { + try { + data = JSON.parse(content) + } + catch (error) { + console.error('Failed to parse drawing content:', error) + } + } + + const scene = restore(data, null, null) + // The canvas background is transparent so the app background shows + // through and the drawing surface matches the active app theme. + scene.appState.viewBackgroundColor = 'transparent' + // The restored appState always carries a default theme. Drop it so it + // never overrides the controlled `theme` prop of the component. + delete (scene.appState as Partial).theme + + // Viewport lives in store.app; files written by older builds may still + // carry it inline, so fall back to that. + const resolvedViewport = viewport ?? readLegacyFileViewport(data) + + if (resolvedViewport) { + scene.appState.scrollX + = resolvedViewport.scrollX as typeof scene.appState.scrollX + scene.appState.scrollY + = resolvedViewport.scrollY as typeof scene.appState.scrollY + scene.appState.zoom = { + value: resolvedViewport.zoom, + } as typeof scene.appState.zoom + } + + return { hasViewState: resolvedViewport !== null, scene } +} + +export function mountExcalidraw( + container: HTMLElement, + options: ExcalidrawHostOptions, +): ExcalidrawHost { + let root: Root | null = createRoot(container) + let api: ExcalidrawApi | null = null + let theme = options.theme + let isExportDialogOpen = false + let isExportDialogPending = false + let hasAppliedScene = false + let lastSceneVersion = -1 + let lastFilesCount = -1 + let latestElements: SceneElements | null = null + let latestAppState: SceneAppState | null = null + let latestFiles: SceneFiles | null = null + + // The scene always goes through updateScene (also on mount): the + // initialData path runs Excalidraw's own appState restore, which + // drops the viewport applied in parseSceneContent. + let pendingScene: { + content: string | null + name: string + viewport: DrawingViewportState | null + } | null = { + content: options.initialContent, + name: options.initialName, + viewport: options.initialViewport, + } + + function showExportDialog() { + if (!api) { + return + } + + isExportDialogOpen = true + // Give the export dialog a real background color to work with: + // exporting the transparent display background is not useful. + api.updateScene({ + appState: { + openDialog: { name: 'imageExport' }, + viewBackgroundColor: '#ffffff', + }, + captureUpdate: CaptureUpdateAction.NEVER, + }) + } + + function flushPendingScene() { + if (!api || !pendingScene || !root) { + return + } + + const { content, name, viewport } = pendingScene + pendingScene = null + + const { hasViewState, scene } = parseSceneContent(content, viewport) + + // Loading a scene implicitly closes any open dialog. + isExportDialogOpen = false + + api.updateScene({ + appState: { ...scene.appState, name }, + captureUpdate: CaptureUpdateAction.NEVER, + elements: scene.elements, + }) + api.addFiles(Object.values(scene.files ?? {})) + api.history.clear() + + if (!hasViewState) { + api.scrollToContent(scene.elements, { fitToContent: true }) + } + + lastSceneVersion = getSceneVersion(scene.elements) + lastFilesCount = Object.keys(scene.files ?? {}).length + hasAppliedScene = true + + if (isExportDialogPending) { + isExportDialogPending = false + showExportDialog() + } + } + + // The excalidrawAPI callback fires before the component finishes + // mounting, so the scene is applied asynchronously: calling + // updateScene during the React commit triggers setState warnings. + function applyScene( + content: string | null, + name: string, + viewport: DrawingViewportState | null, + ) { + pendingScene = { content, name, viewport } + setTimeout(flushPendingScene, 0) + } + + function scrollToContent() { + if (!api) { + return + } + + // Center and zoom the viewport so every element fits, regardless of how + // far the user has panned away. + api.scrollToContent(latestElements ?? undefined, { + fitToContent: true, + animate: true, + duration: 400, + }) + } + + function renderFitToContentFooter() { + return createElement( + Footer, + null, + createElement( + Button, + { + 'type': 'button', + 'onSelect': scrollToContent, + 'className': 'fit-to-content-button', + 'title': options.fitToContentLabel, + 'aria-label': options.fitToContentLabel, + }, + createElement( + 'svg', + { + 'width': 16, + 'height': 16, + 'viewBox': '0 0 24 24', + 'fill': 'none', + 'stroke': 'currentColor', + 'strokeWidth': 2, + 'strokeLinecap': 'round', + 'strokeLinejoin': 'round', + 'aria-hidden': true, + }, + [ + createElement('path', { key: 'a', d: 'M8 3H5a2 2 0 0 0-2 2v3' }), + createElement('path', { key: 'b', d: 'M21 8V5a2 2 0 0 0-2-2h-3' }), + createElement('path', { key: 'c', d: 'M3 16v3a2 2 0 0 0 2 2h3' }), + createElement('path', { key: 'd', d: 'M16 21h3a2 2 0 0 0 2-2v-3' }), + ], + ), + ), + ) + } + + function render() { + root?.render( + createElement( + Excalidraw, + { + excalidrawAPI: (value) => { + api = value + setTimeout(flushPendingScene, 0) + }, + initialData: { + appState: { + name: options.initialName, + viewBackgroundColor: 'transparent', + }, + } as unknown as ExcalidrawProps['initialData'], + langCode: options.langCode, + onChange: (elements, appState, files) => { + latestElements = elements + latestAppState = appState + latestFiles = files + + // Until the real scene is applied the canvas shows a bootstrap + // empty scene: never report it as user changes. + if (!hasAppliedScene) { + return + } + + if (isExportDialogOpen) { + // Restore the transparent canvas once the dialog is closed and + // skip change notifications while it is open: the temporary + // white background must not be persisted. + if (!appState.openDialog) { + isExportDialogOpen = false + api?.updateScene({ + appState: { viewBackgroundColor: 'transparent' }, + captureUpdate: CaptureUpdateAction.NEVER, + }) + } + return + } + + // Pure pan/zoom/selection changes do not touch elements or + // files: report them as viewport-only so the full scene is not + // serialized and written to disk. + const sceneVersion = getSceneVersion(elements) + const filesCount = files ? Object.keys(files).length : 0 + + if ( + sceneVersion !== lastSceneVersion + || filesCount !== lastFilesCount + ) { + lastSceneVersion = sceneVersion + lastFilesCount = filesCount + options.onChange('scene') + } + else { + options.onChange('viewport') + } + }, + theme, + UIOptions: UI_OPTIONS, + }, + renderFitToContentFooter(), + ), + ) + } + + render() + + return { + destroy() { + root?.unmount() + root = null + api = null + latestElements = null + latestAppState = null + latestFiles = null + }, + getViewport() { + if (!latestAppState) { + return null + } + + const { scrollX, scrollY, zoom } = latestAppState + + if (typeof scrollX !== 'number' || typeof scrollY !== 'number') { + return null + } + + return { + scrollX, + scrollY, + zoom: typeof zoom?.value === 'number' ? zoom.value : 1, + } + }, + loadScene(content, name, viewport) { + applyScene(content, name, viewport) + }, + openImageExportDialog() { + // Defer until the API is ready and any queued scene is applied, + // otherwise the scene load would instantly close the dialog. + if (!api || pendingScene) { + isExportDialogPending = true + return + } + + showExportDialog() + }, + serializeScene() { + // While the export dialog is open the appState carries a temporary + // white background: never persist that snapshot. + if ( + !hasAppliedScene + || isExportDialogOpen + || !latestElements + || !latestAppState + ) { + return null + } + + return serializeAsJSON( + latestElements, + latestAppState, + latestFiles ?? {}, + 'local', + ) + }, + setTheme(nextTheme: 'light' | 'dark') { + if (theme === nextTheme) { + return + } + + theme = nextTheme + render() + }, + } +} diff --git a/src/renderer/components/editor/Editor.vue b/src/renderer/components/editor/Editor.vue index 3375a724b..80dbf6a61 100644 --- a/src/renderer/components/editor/Editor.vue +++ b/src/renderer/components/editor/Editor.vue @@ -10,6 +10,10 @@ import { useTheme, } from '@/composables' import { i18n, ipc } from '@/electron' +import { + mapNormalizedCursorIndex, + normalizeTerminalText, +} from '@/utils/normalizeTerminalText' import { useClipboard, useCssVar, useDebounceFn } from '@vueuse/core' import CodeMirror from 'codemirror' import 'codemirror/addon/edit/closebrackets' @@ -48,6 +52,9 @@ const { let editor: CodeMirror.Editor | null = null let currentSearchOverlay: any = null +// id фрагмента, чьё тело сейчас отображается в редакторе: пока полная запись +// сниппета загружается, selectedSnippetContent содержит только метаданные. +let lastAppliedContentId: number | undefined const previewHandleRef = ref() const previewHeight = ref(300) @@ -137,23 +144,34 @@ async function init() { scrollbarStyle: 'null', }) + if (selectedSnippetContent.value?.value !== undefined) { + lastAppliedContentId = selectedSnippetContent.value.id + } + editor.on('change', (e) => { if (isProgrammaticChange.value || !selectedSnippet.value?.id) return - const initValue = JSON.stringify(selectedSnippetContent.value?.value) - const updatedValue = JSON.stringify(e.getValue()) - - if (initValue !== updatedValue) { - addToUpdateContentQueue( - selectedSnippet.value.id, - selectedSnippetContent.value!.id, - { - label: selectedSnippetContent.value!.label, - value: e.getValue(), - language: selectedSnippetContent.value!.language, - }, - ) + const content = selectedSnippetContent.value + // Сохраняем только когда тело загружено и редактор отображает именно + // этот фрагмент — иначе в момент переключения можно перезаписать + // сниппет чужим текстом. + if ( + !content + || content.value === undefined + || content.id !== lastAppliedContentId + ) { + return + } + + const updatedValue = e.getValue() + + if (content.value !== updatedValue) { + addToUpdateContentQueue(selectedSnippet.value.id, content.id, { + label: content.label, + value: updatedValue, + language: content.language, + }) } }) @@ -200,16 +218,31 @@ async function init() { }, }) - ipc.on('main-menu:copy-snippet', () => { - const { copy } = useClipboard({ source: editor?.getValue() || '' }) - copy() - useDonations().incrementCopy('code') - }) + ipc.on('main-menu:copy-snippet', onCopySnippetMenu) + + watch(selectedSnippetContent, (v) => { + const scheduledSnippetId = selectedSnippet.value?.id + const scheduledContentId = v?.id - watch(selectedSnippetContent, (v, oldV) => { nextTick(() => { - const isNewValue = v?.id !== oldV?.id - const isSameContent = v?.id === oldV?.id + if ( + selectedSnippet.value?.id !== scheduledSnippetId + || selectedSnippetContent.value?.id !== scheduledContentId + ) { + return + } + + // Полная запись выбранного сниппета ещё загружается — не очищаем + // редактор промежуточным состоянием (метаданные без value). + if (selectedSnippet.value && (!v || v.value === undefined)) { + return + } + + // Сравниваем с последним реально отображённым фрагментом, а не с + // предыдущим значением computed: между сниппетами проскакивает + // metadata-only состояние с тем же id. + const isNewValue = v?.id !== lastAppliedContentId + const isSameContent = v?.id === lastAppliedContentId const snippetId = selectedSnippet.value?.id const contentId = v?.id let nextValue = v?.value || '' @@ -232,6 +265,7 @@ async function init() { // Не сохраняем вьюпорт при смене фрагмента/сниппета setValue(nextValue, true, !isNewValue) + lastAppliedContentId = contentId nextTick(() => { if (searchQuery.value) { updateSearchOverlay() @@ -241,9 +275,17 @@ async function init() { }) watch(selectedSnippetContent, (v) => { + const scheduledSnippetId = selectedSnippet.value?.id + const scheduledContentId = v?.id + nextTick(() => { - if (!v) + if ( + !v + || selectedSnippet.value?.id !== scheduledSnippetId + || selectedSnippetContent.value?.id !== scheduledContentId + ) { return + } setLanguage(v.language as Language) }) }) @@ -308,6 +350,9 @@ function setValue(value: string, programmatic = true, preserveViewport = true) { isProgrammaticChange.value = programmatic editor.setValue(value) + if (programmatic) { + editor.clearHistory() + } isProgrammaticChange.value = false if (preserveViewport) { @@ -325,6 +370,17 @@ function setLanguage(language: Language) { editor?.setOption('mode', language) } +function focusEditor() { + isShowCodeImage.value = false + isShowJsonVisualizer.value = false + + nextTick(() => { + requestAnimationFrame(() => { + editor?.focus() + }) + }) +} + async function format() { const availableLang: Language[] = [ 'css', @@ -388,7 +444,52 @@ async function format() { } } +function onCopySnippetMenu() { + const { copy } = useClipboard({ source: editor?.getValue() || '' }) + copy() + useDonations().incrementCopy('code') +} + +function normalizeTerminalOutput() { + if (!editor) + return + + if (editor.somethingSelected()) { + const selections = editor.getSelections() + const normalized = selections.map(normalizeTerminalText) + + if (normalized.some((value, index) => value !== selections[index])) + editor.replaceSelections(normalized, 'around') + + return + } + + const value = editor.getValue() + const normalized = normalizeTerminalText(value) + + if (normalized === value) + return + + const cursorIndex = editor.indexFromPos(editor.getCursor()) + const mappedIndex = mapNormalizedCursorIndex(value, cursorIndex, normalized) + + setValue(normalized, false) + editor.setCursor(editor.posFromIndex(mappedIndex)) +} + ipc.on('main-menu:format', format) +ipc.on('main-menu:normalize-code-line-breaks', normalizeTerminalOutput) + +// Спейсы пересоздаются при переключении: без снятия listeners каждый цикл +// добавляет обработчик и удерживает мёртвый инстанс CodeMirror от GC. +// removeListeners по каналу, т.к. contextBridge оборачивает функцию в новый +// прокси и removeListener по ссылке не срабатывает; владелец каналов — только +// этот компонент. +onBeforeUnmount(() => { + ipc.removeListeners('main-menu:format') + ipc.removeListeners('main-menu:normalize-code-line-breaks') + ipc.removeListeners('main-menu:copy-snippet') +}) function createSearchOverlay(query: string) { if (!query) @@ -456,9 +557,16 @@ onMounted(() => {