├── .github └── workflows │ ├── changelog.yml │ ├── release.yml │ └── update-changelog-version.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── en.md └── zh.md ├── esbuild.config.mjs ├── manifest.json ├── package.json ├── packages └── webdav-explorer │ ├── package.json │ ├── postcss.config.mjs │ ├── rslib.config.ts │ ├── src │ ├── App.tsx │ ├── assets │ │ └── styles │ │ │ └── global.css │ ├── components │ │ ├── File.tsx │ │ ├── FileList.tsx │ │ ├── Folder.tsx │ │ └── NewFolder.tsx │ ├── i18n │ │ ├── index.ts │ │ └── locales │ │ │ ├── en.ts │ │ │ └── zh.ts │ └── index.tsx │ ├── tsconfig.json │ └── unocss.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── api │ ├── delta.ts │ ├── latestDeltaCursor.ts │ └── webdav.ts ├── assets │ └── styles │ │ └── global.css ├── components │ ├── CacheClearModal.ts │ ├── CacheRestoreModal.ts │ ├── CacheSaveModal.ts │ ├── FilterEditorModal.ts │ ├── LogoutConfirmModal.ts │ ├── SelectRemoteBaseDirModal.ts │ ├── SyncConfirmModal.ts │ ├── SyncProgressModal.ts │ ├── SyncRibbonManager.ts │ ├── TaskListConfirmModal.ts │ └── TextAreaModal.ts ├── consts.ts ├── events │ ├── index.ts │ ├── sso-receive.ts │ ├── sync-cancel.ts │ ├── sync-end.ts │ ├── sync-error.ts │ ├── sync-progress.ts │ ├── sync-start.ts │ └── vault.ts ├── fs │ ├── fs.interface.ts │ ├── local-vault.ts │ ├── nutstore.ts │ └── utils │ │ ├── complete-loss-dir.ts │ │ └── is-root.ts ├── i18n │ ├── index.ts │ └── locales │ │ ├── en.ts │ │ └── zh.ts ├── index.ts ├── model │ ├── stat.model.ts │ └── sync-record.model.ts ├── polyfill.ts ├── services │ ├── cache.service.v1.ts │ ├── command.service.ts │ ├── events.service.ts │ ├── i18n.service.ts │ ├── logger.service.ts │ ├── progress.service.ts │ ├── realtime-sync.service.ts │ ├── status.service.ts │ └── webdav.service.ts ├── settings │ ├── account.ts │ ├── cache.ts │ ├── common.ts │ ├── filter.ts │ ├── index.ts │ ├── log.ts │ └── settings.base.ts ├── storage │ ├── blob.ts │ ├── helper.ts │ ├── index.ts │ ├── kv.ts │ ├── logs.ts │ └── use-storage.ts ├── sync │ ├── core │ │ ├── merge-utils.test.ts │ │ └── merge-utils.ts │ ├── decision │ │ ├── base.decision.ts │ │ └── two-way.decision.ts │ ├── index.ts │ └── tasks │ │ ├── conflict-resolve.task.ts │ │ ├── filename-error.task.ts │ │ ├── mkdir-local.task.ts │ │ ├── mkdir-remote.task.ts │ │ ├── noop.task.ts │ │ ├── pull.task.ts │ │ ├── push.task.ts │ │ ├── remove-local.task.ts │ │ ├── remove-remote.task.ts │ │ └── task.interface.ts ├── utils │ ├── api-limiter.ts │ ├── breakable-sleep.ts │ ├── decrypt-ticket-response.ts │ ├── deep-stringify.ts │ ├── file-stat-to-stat-model.ts │ ├── get-db-key.ts │ ├── get-root-folder-name.ts │ ├── get-task-name.ts │ ├── glob-match.ts │ ├── has-invalid-char.ts │ ├── is-503-error.ts │ ├── is-binary-file.ts │ ├── is-sub.ts │ ├── logger.ts │ ├── logs-stringify.ts │ ├── merge-dig-in.ts │ ├── mkdirs-vault.ts │ ├── mkdirs-webdav.ts │ ├── ns-api.ts │ ├── rate-limited-client.ts │ ├── remote-path-to-absolute.ts │ ├── remote-path-to-local-path.ts │ ├── request-url.ts │ ├── sha256.ts │ ├── sleep.ts │ ├── stat-vault-item.ts │ ├── stat-webdav-item.ts │ ├── std-remote-path.ts │ ├── traverse-local-vault.ts │ ├── traverse-webdav.ts │ ├── types.ts │ └── wait-until.ts └── webdav-patch.ts ├── tsconfig.json ├── uno.config.ts ├── version-bump.mjs └── versions.json /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Generate Changelog 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write # Needed to commit the CHANGELOG.md file 10 | pull-requests: read # Optional: Needed if you want to read PR data 11 | 12 | jobs: 13 | generate_changelog: 14 | runs-on: ubuntu-latest 15 | # Prevent running on commits made by this action itself 16 | if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} 17 | 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 # Fetch all history 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Set up Git user 26 | run: | 27 | git config user.name "github-actions[bot]" 28 | git config user.email "github-actions[bot]@users.noreply.github.com" 29 | 30 | - name: Get Latest Tag 31 | id: latest_tag 32 | run: | 33 | # Get the current tag that triggered the workflow 34 | CURRENT_TAG=${GITHUB_REF#refs/tags/} 35 | # Get the previous tag 36 | PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -A 1 "^$CURRENT_TAG$" | tail -n 1) 37 | echo "current_tag=$CURRENT_TAG" >> $GITHUB_OUTPUT 38 | echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT 39 | 40 | - name: Get Commit Messages Since Last Tag 41 | id: commit_messages 42 | run: | 43 | if [ -z "${{ steps.latest_tag.outputs.previous_tag }}" ]; then 44 | echo "No previous tag found. Getting recent commits..." 45 | COMMIT_RANGE="HEAD~20..HEAD" # Adjust limit as needed 46 | else 47 | echo "Getting commits between ${{ steps.latest_tag.outputs.previous_tag }} and ${{ steps.latest_tag.outputs.current_tag }}..." 48 | COMMIT_RANGE="${{ steps.latest_tag.outputs.previous_tag }}..${{ steps.latest_tag.outputs.current_tag }}" 49 | fi 50 | COMMIT_LOG=$(git log $COMMIT_RANGE --pretty=format:"- %s" --no-merges | grep -E "^- (feat|fix|refactor):") 51 | COMMIT_LOG_ESCAPED=$(echo "$COMMIT_LOG" | sed -z 's/\n/\\n/g' | sed 's/"/\\"/g') # Escape for JSON 52 | echo "commits<> $GITHUB_OUTPUT 53 | echo "$COMMIT_LOG_ESCAPED" >> $GITHUB_OUTPUT 54 | echo "EOF" >> $GITHUB_OUTPUT 55 | echo "Raw commits fetched:" 56 | echo "$COMMIT_LOG" 57 | 58 | - name: Call Google Gemini API to Generate Changelog Entry 59 | id: ai_changelog 60 | # Only run if there are commit messages 61 | if: steps.commit_messages.outputs.commits != '' 62 | run: | 63 | MAX_RETRIES=3 64 | RETRY_DELAY=5 65 | ATTEMPT=0 66 | SUCCESS=false 67 | 68 | while [ $ATTEMPT -lt $MAX_RETRIES ] && [ "$SUCCESS" = "false" ]; do 69 | ATTEMPT=$((ATTEMPT+1)) 70 | echo "Attempt $ATTEMPT of $MAX_RETRIES" 71 | 72 | # System prompt defines the AI's role and overall instructions (from OpenAI version) 73 | SYSTEM_PROMPT="You are a helpful assistant that generates changelog entries. Generate a concise and user-friendly changelog entry in Markdown bullet points based on the provided commit messages. Focus on user-facing changes, features, and bug fixes. Group similar items if possible. This will be running in a GitHub Action, please make sure to use CI compatible characters. You should write in both Chinese and English, with Chinese first. Do not include a header, just the bullet points." 74 | 75 | # User prompt contains the specific data for this run (from OpenAI version) 76 | USER_INPUT_PROMPT="Commit messages:\n\n${{ steps.commit_messages.outputs.commits }}" 77 | 78 | # Combine system and user prompts for Gemini 79 | COMBINED_PROMPT="${SYSTEM_PROMPT}\n\n${USER_INPUT_PROMPT}" 80 | 81 | # Calculate dynamic max tokens based on commit message lines 82 | COMMIT_LINES=$(echo "${{ steps.commit_messages.outputs.commits }}" | grep -c '^-') 83 | BASE_TOKENS=300 # Base tokens for template and formatting 84 | PER_LINE_TOKENS=20 # Additional tokens per commit line 85 | MAX_TOKENS=$(( BASE_TOKENS + (COMMIT_LINES * PER_LINE_TOKENS) )) 86 | MAX_TOKENS=$(( MAX_TOKENS > 1000 ? 1000 : MAX_TOKENS )) # Cap at 1000 tokens 87 | 88 | # Construct the JSON payload for Gemini API as a single line 89 | # Using gemini-1.5-flash model 90 | JSON_PAYLOAD=$(jq -n --arg prompt_text "$COMBINED_PROMPT" --argjson maxOutputTokens "$MAX_TOKENS" \ 91 | '{ 92 | "contents":[{"parts":[{"text": $prompt_text}]}], 93 | "generationConfig":{"temperature":0.4,"topP":0.85,"maxOutputTokens": $maxOutputTokens,"stopSequences":["##"]}, 94 | "safetySettings":[{"category":"HARM_CATEGORY_DANGEROUS_CONTENT","threshold":"BLOCK_NONE"}] 95 | }') 96 | 97 | echo "Sending prompt to Gemini API (gemini-1.5-flash)..." 98 | # Use gemini-1.5-flash model 99 | API_ENDPOINT="https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key=${{ secrets.GEMINI_API_KEY }}" 100 | 101 | API_RESPONSE=$(curl -s -X POST "$API_ENDPOINT" \ 102 | -H "Content-Type: application/json" \ 103 | -d "$JSON_PAYLOAD") 104 | 105 | echo "Gemini Raw Response: $API_RESPONSE" 106 | 107 | # Check for error in response 108 | ERROR_MSG=$(echo "$API_RESPONSE" | jq -r '.error.message // empty') 109 | if [ -n "$ERROR_MSG" ]; then 110 | echo "API Error: $ERROR_MSG" 111 | sleep $RETRY_DELAY 112 | continue 113 | fi 114 | 115 | # Validate JSON structure and extract content 116 | if ! jq -e '.candidates[0].content.parts[0].text' <<< "$API_RESPONSE" >/dev/null 2>&1; then 117 | echo "Invalid JSON response structure" 118 | echo "Response structure: $(echo "$API_RESPONSE" | jq -r 'keys')" 119 | sleep $RETRY_DELAY 120 | continue 121 | fi 122 | 123 | GENERATED_TEXT=$(echo "$API_RESPONSE" | jq -r '.candidates[0].content.parts[0].text? // empty' | sed '/^$/d') 124 | 125 | if [ -n "$GENERATED_TEXT" ]; then 126 | SUCCESS=true 127 | echo "Generated Changelog Text:" 128 | echo "$GENERATED_TEXT" 129 | echo "changelog_entry<> $GITHUB_OUTPUT 130 | echo "$GENERATED_TEXT" >> $GITHUB_OUTPUT 131 | echo "EOF" >> $GITHUB_OUTPUT 132 | else 133 | echo "Empty response from API" 134 | sleep $RETRY_DELAY 135 | fi 136 | done 137 | 138 | if [ "$SUCCESS" = "false" ]; then 139 | echo "Failed after $MAX_RETRIES attempts. Exiting with error." 140 | exit 1 141 | fi 142 | env: 143 | GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} 144 | 145 | 146 | - name: Update CHANGELOG.md 147 | # Only run if AI generated text 148 | if: steps.ai_changelog.outputs.changelog_entry != '' 149 | run: | 150 | CHANGELOG_FILE="CHANGELOG.md" 151 | NEW_ENTRY_HEADING="## [Unreleased] - $(date +'%Y-%m-%d')" 152 | GENERATED_CONTENT="${{ steps.ai_changelog.outputs.changelog_entry }}" 153 | 154 | # Create file if it doesn't exist with a basic structure 155 | if [ ! -f "$CHANGELOG_FILE" ]; then 156 | echo "# Changelog" > "$CHANGELOG_FILE" 157 | echo "" >> "$CHANGELOG_FILE" 158 | echo "All notable changes to this project will be documented in this file." >> "$CHANGELOG_FILE" 159 | echo "" >> "$CHANGELOG_FILE" 160 | fi 161 | 162 | # Check if [Unreleased] section exists 163 | if grep -q "## \[Unreleased\]" "$CHANGELOG_FILE"; then 164 | # Insert below the [Unreleased] heading 165 | # Use awk for safer multi-line insertion 166 | awk -v heading="$NEW_ENTRY_HEADING" -v content="$GENERATED_CONTENT" ' 167 | /## \[Unreleased\]/ { 168 | print; 169 | print ""; # Add a newline after heading 170 | print content; 171 | next # Skip original line printing if needed or adjust logic 172 | } 173 | { print } 174 | ' "$CHANGELOG_FILE" > temp_changelog.md && mv temp_changelog.md "$CHANGELOG_FILE" 175 | else 176 | # Prepend a new [Unreleased] section at the top (after header lines) 177 | # This logic might need refinement based on your exact header structure 178 | { 179 | head -n 3 "$CHANGELOG_FILE" # Adjust line count based on your header 180 | echo "" 181 | echo "$NEW_ENTRY_HEADING" 182 | echo "" 183 | echo "$GENERATED_CONTENT" 184 | echo "" 185 | tail -n +4 "$CHANGELOG_FILE" # Adjust line count 186 | } > temp_changelog.md && mv temp_changelog.md "$CHANGELOG_FILE" 187 | fi 188 | 189 | echo "CHANGELOG.md updated." 190 | 191 | 192 | - name: Commit and Push CHANGELOG.md 193 | # Only run if AI generated text 194 | if: steps.ai_changelog.outputs.changelog_entry != '' 195 | run: | 196 | # Check if there are changes to commit 197 | if git diff --quiet $CHANGELOG_FILE; then 198 | echo "No changes to CHANGELOG.md to commit." 199 | exit 0 200 | fi 201 | 202 | git add CHANGELOG.md 203 | # Add [skip ci] to prevent triggering this workflow again 204 | git commit -m "chore: update changelog [skip ci]" 205 | # Handle potential push conflicts/errors if needed 206 | git push origin HEAD:refs/heads/main 207 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | environment: production 12 | permissions: 13 | contents: write 14 | packages: read 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '18.x' 22 | 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v2 25 | with: 26 | version: latest 27 | 28 | - name: Build plugin 29 | env: 30 | NS_NSDAV_ENDPOINT: ${{ vars.NS_NSDAV_ENDPOINT }} 31 | NS_DAV_ENDPOINT: ${{ vars.NS_DAV_ENDPOINT }} 32 | NUTSTORE_PAT: ${{ secrets.GITHUB_TOKEN }} 33 | run: | 34 | pnpm install 35 | pnpm run build 36 | 37 | - name: Package plugin 38 | run: | 39 | tag="${GITHUB_REF#refs/tags/}" 40 | mkdir ${{ github.event.repository.name }} 41 | cp main.js manifest.json styles.css ${{ github.event.repository.name }} 42 | zip -r ${{ github.event.repository.name }}-${tag}.zip ${{ github.event.repository.name }} 43 | tar -czf ${{ github.event.repository.name }}-${tag}.tar.gz ${{ github.event.repository.name }} 44 | 45 | - name: Create release 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | run: | 49 | tag="${GITHUB_REF#refs/tags/}" 50 | 51 | gh release create "$tag" \ 52 | --title="$tag" \ 53 | --draft \ 54 | main.js manifest.json styles.css \ 55 | ${{ github.event.repository.name }}-${tag}.zip \ 56 | ${{ github.event.repository.name }}-${tag}.tar.gz 57 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog-version.yml: -------------------------------------------------------------------------------- 1 | name: Update Changelog Version 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | update-changelog: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout main branch 15 | uses: actions/checkout@v4 16 | with: 17 | ref: main # Explicitly checkout main branch 18 | fetch-depth: 0 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | - name: Set up Git user 22 | run: | 23 | git config user.name "github-actions[bot]" 24 | git config user.email "github-actions[bot]@users.noreply.github.com" 25 | 26 | - name: Update version in CHANGELOG.md 27 | run: | 28 | echo "GITHUB_REF: $GITHUB_REF" 29 | # Get release version (without 'v' prefix if present) 30 | VERSION=${GITHUB_REF#refs/tags/} 31 | echo "After removing refs/tags/: $VERSION" 32 | VERSION=${VERSION#v} 33 | echo "After removing v prefix: $VERSION" 34 | 35 | echo "Current content before replacement:" 36 | cat CHANGELOG.md 37 | 38 | # Only replace "Unreleased" in the heading, not in other places 39 | sed -i "s/## \[Unreleased\]/## [$VERSION]/" CHANGELOG.md 40 | 41 | echo "Content after replacement:" 42 | cat CHANGELOG.md 43 | 44 | # Check if there are changes 45 | if git diff --quiet CHANGELOG.md; then 46 | echo "No changes were made to CHANGELOG.md" 47 | exit 1 48 | fi 49 | 50 | # Commit and push changes 51 | git add CHANGELOG.md 52 | git commit -m "docs: update changelog version to $VERSION [skip ci]" 53 | git push origin HEAD:main 54 | 55 | # Update release description with CHANGELOG link 56 | CHANGELOG_URL="https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md#$VERSION" 57 | RELEASE_ID=$(jq --raw-output .release.id "$GITHUB_EVENT_PATH") 58 | 59 | # Add CHANGELOG link to release description 60 | curl \ 61 | -X PATCH \ 62 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 63 | -H "Accept: application/vnd.github.v3+json" \ 64 | "https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID" \ 65 | -d "{ 66 | \"body\": \"📝 View the changelog for this version: $CHANGELOG_URL\" 67 | }" 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | 8 | # Exclude sourcemaps 9 | *.map 10 | 11 | # obsidian 12 | data.json 13 | 14 | # Exclude macOS Finder (System Explorer) View States 15 | .DS_Store 16 | 17 | # Local 18 | *.local 19 | *.log* 20 | 21 | # Environment variables 22 | .env 23 | 24 | # build output 25 | dist 26 | styles.css 27 | main.js 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @nutstore:registry=https://npm.pkg.github.com 2 | //npm.pkg.github.com/:_authToken=${NUTSTORE_PAT} 3 | tag-version-prefix="" 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "tailwindCSS.codeActions": false, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | 6 | "editor.codeActionsOnSave": { 7 | "source.organizeImports": "explicit", 8 | "source.removeUnusedImports": "explicit" 9 | }, 10 | 11 | "[typescript]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | 15 | "[json]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | 19 | "[jsonc]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | 23 | "[css]": { 24 | "editor.defaultFormatter": "esbenp.prettier-vscode" 25 | }, 26 | 27 | "[yaml]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode" 29 | }, 30 | 31 | "[markdown]": { 32 | "editor.defaultFormatter": "esbenp.prettier-vscode" 33 | }, 34 | "cSpell.words": ["Nutstore", "webdav"] 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔄 Nutstore Sync 2 | 3 | [English](docs/en.md) | [简体中文](docs/zh.md) 4 | 5 | This plugin enables two-way synchronization between Obsidian notes and Nutstore (坚果云) via WebDAV protocol. 6 | 7 | ## ✨ Key Features 8 | 9 | - **Two-way Sync**: Efficiently synchronize your notes across devices 10 | - **Incremental Sync**: Fast updates that only transfer changed files, making large vaults sync quickly 11 | - **Single Sign-On**: Connect to Nutstore with simple authorization instead of manually entering WebDAV credentials 12 | - **WebDAV Explorer**: Visual file browser for remote file management 13 | - **Smart Conflict Resolution**: 14 | - Character-level comparison to automatically merge changes when possible 15 | - Option to use timestamp-based resolution (newest file wins) 16 | - **Loose Sync Mode**: Optimize performance for vaults with thousands of notes 17 | - **Large File Handling**: Set size limits to skip large files for better performance 18 | - **Sync Status Tracking**: Clear visual indicators of sync progress and completion 19 | - **Detailed Logging**: Comprehensive logs for troubleshooting 20 | 21 | ## ⚠️ Important Notes 22 | 23 | - ⏳ Initial sync may take longer (especially with many files) 24 | - 💾 Please backup before syncing 25 | -------------------------------------------------------------------------------- /docs/en.md: -------------------------------------------------------------------------------- 1 | # 🔄 Nutstore Sync 2 | 3 | This plugin enables two-way synchronization between Obsidian notes and Nutstore via WebDAV protocol. 4 | 5 | ## ✨ Key Features 6 | 7 | - **Two-way Sync**: Efficiently synchronize your notes across devices 8 | - **Incremental Sync**: Fast updates that only transfer changed files, making large vaults sync quickly 9 | - **Single Sign-On**: Connect to Nutstore with simple authorization instead of manually entering WebDAV credentials 10 | - **WebDAV Explorer**: Visual file browser for remote file management 11 | - **Smart Conflict Resolution**: 12 | - Character-level comparison to automatically merge changes when possible 13 | - Option to use timestamp-based resolution (newest file wins) 14 | - **Loose Sync Mode**: Optimize performance for vaults with thousands of notes 15 | - **Large File Handling**: Set size limits to skip large files for better performance 16 | - **Sync Status Tracking**: Clear visual indicators of sync progress and completion 17 | - **Detailed Logging**: Comprehensive logs for troubleshooting 18 | 19 | ## ⚠️ Important Notes 20 | 21 | - ⏳ Initial sync may take longer (especially with many files) 22 | - 💾 Please backup before syncing 23 | 24 | ## 🔍 Sync Algorithm 25 | 26 | ```mermaid 27 | flowchart TD 28 | subgraph s1["Directory Sync Process"] 29 | RemoteToLocal{"Check Remote Dir"} 30 | SyncFolders{"Start Dir Sync"} 31 | ValidateType{"Type Validation
Ensure both are dirs"} 32 | ErrorFolder["Error: Type Conflict
One is file, other is dir"] 33 | CheckRemoteFolderChanged{"Check Remote Dir
for Changes"} 34 | CreateLocalDir["Create Local Dir"] 35 | CheckRemoteRemovable{"Check if Remote Removable
1.Scan subfiles
2.Verify timestamps"} 36 | RemoveRemoteFolder["Remove Remote Dir"] 37 | CreateLocalFolder["Create Local Dir"] 38 | LocalToRemote{"Check Local Dir"} 39 | CheckLocalFolderRecord{"Check Local Sync Record"} 40 | CreateRemoteFolder["Create Remote Dir"] 41 | CheckLocalFolderRemovable{"Check if Local Removable
1.Scan subfiles
2.Verify timestamps"} 42 | RemoveLocalFolder["Remove Local Dir"] 43 | CreateRemoteDirNew["Create Remote Dir"] 44 | end 45 | subgraph s2["File Sync Process"] 46 | CheckSyncRecord{"Check Sync Record"} 47 | SyncFiles{"Start File Sync"} 48 | ExistenceCheck{"Check File Existence"} 49 | ChangeCheck{"Check Change Status
Compare Timestamps"} 50 | Conflict["Resolve Conflict
Use Latest Timestamp"] 51 | Download["Download Remote File"] 52 | Upload["Upload Local File"] 53 | RemoteOnlyCheck{"Check Remote File"} 54 | DownloadNew["Download New File"] 55 | DeleteRemoteFile["Delete Remote File"] 56 | LocalOnlyCheck{"Check Local File"} 57 | UploadNew["Upload New File"] 58 | DeleteLocalFile["Delete Local File"] 59 | NoRecordCheck{"Check File Status"} 60 | ResolveConflict["Resolve Conflict
Use Latest Timestamp"] 61 | PullNewFile["Download Remote File"] 62 | PushNewFile["Upload Local File"] 63 | end 64 | Start(["Start Sync"]) --> PrepareSync["Prepare Sync Environment
1.Create Remote Base Dir
2.Load Sync Records"] 65 | PrepareSync --> LoadStats["Get File Stats
1.Scan Local Files
2.Scan Remote Files"] 66 | LoadStats --> SyncFolders 67 | SyncFolders -- "Step 1: Remote to Local" --> RemoteToLocal 68 | RemoteToLocal -- "Local Exists" --> ValidateType 69 | ValidateType -- "Type Mismatch" --> ErrorFolder 70 | RemoteToLocal -- "Local Missing but Has Record" --> CheckRemoteFolderChanged 71 | CheckRemoteFolderChanged -- "Remote Modified" --> CreateLocalDir 72 | CheckRemoteFolderChanged -- "Remote Unchanged" --> CheckRemoteRemovable 73 | CheckRemoteRemovable -- "Can Remove" --> RemoveRemoteFolder 74 | RemoteToLocal -- "No Record" --> CreateLocalFolder 75 | SyncFolders -- "Step 2: Local to Remote" --> LocalToRemote 76 | LocalToRemote -- "Remote Missing" --> CheckLocalFolderRecord 77 | CheckLocalFolderRecord -- "Has Record & Local Changed" --> CreateRemoteFolder 78 | CheckLocalFolderRecord -- "Has Record Unchanged" --> CheckLocalFolderRemovable 79 | CheckLocalFolderRemovable -- "Can Remove" --> RemoveLocalFolder 80 | CheckLocalFolderRecord -- "No Record" --> CreateRemoteDirNew 81 | SyncFiles --> CheckSyncRecord & UpdateRecords["Update Sync Records"] 82 | CheckSyncRecord -- "Has Sync Record" --> ExistenceCheck 83 | ExistenceCheck -- "Both Exist" --> ChangeCheck 84 | ChangeCheck -- "Both Changed" --> Conflict 85 | ChangeCheck -- "Remote Changed Only" --> Download 86 | ChangeCheck -- "Local Changed Only" --> Upload 87 | ExistenceCheck -- "Remote Only" --> RemoteOnlyCheck 88 | RemoteOnlyCheck -- "Remote Changed" --> DownloadNew 89 | RemoteOnlyCheck -- "Remote Unchanged" --> DeleteRemoteFile 90 | ExistenceCheck -- "Local Only" --> LocalOnlyCheck 91 | LocalOnlyCheck -- "Local Changed" --> UploadNew 92 | LocalOnlyCheck -- "Local Unchanged" --> DeleteLocalFile 93 | CheckSyncRecord -- "No Sync Record" --> NoRecordCheck 94 | NoRecordCheck -- "Both Exist" --> ResolveConflict 95 | NoRecordCheck -- "Remote Only" --> PullNewFile 96 | NoRecordCheck -- "Local Only" --> PushNewFile 97 | SyncFolders --> SyncFiles 98 | UpdateRecords --> End(["Sync Complete"]) 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/zh.md: -------------------------------------------------------------------------------- 1 | # 🔄 Nutstore Sync 2 | 3 | 此插件允许您通过 WebDAV 协议将 Obsidian 笔记与坚果云进行双向同步。 4 | 5 | ## ✨ 主要特性 6 | 7 | - **双向同步**: 高效地在多设备间同步笔记 8 | - **增量同步**: 只传输更改过的文件,使大型笔记库也能快速同步 9 | - **单点登录**: 通过简单授权连接坚果云,无需手动输入 WebDAV 凭据 10 | - **WebDAV 文件浏览器**: 远程文件管理的可视化界面 11 | - **智能冲突解决**: 12 | - 字符级比较自动合并可能的更改 13 | - 支持基于时间戳的解决方案(最新文件优先) 14 | - **宽松同步模式**: 优化对包含数千笔记的仓库的性能 15 | - **大文件处理**: 设置大小限制以跳过大文件,提升性能 16 | - **同步状态跟踪**: 清晰的同步进度和完成提示 17 | - **详细日志**: 全面的故障排查日志 18 | 19 | ## ⚠️ 注意事项 20 | 21 | - ⏳ 首次同步可能需要较长时间 (文件比较多时) 22 | - 💾 请在同步之前备份 23 | 24 | ## 🔍 同步算法 25 | 26 | ```mermaid 27 | flowchart TD 28 | subgraph s1["文件夹同步流程"] 29 | RemoteToLocal["检查远程文件夹"] 30 | SyncFolders["开始文件夹同步"] 31 | ValidateType["类型验证
确保两端都是文件夹"] 32 | ErrorFolder["错误:类型冲突
一端是文件另一端是文件夹"] 33 | CheckRemoteFolderChanged["检查远程文件夹
是否有变更"] 34 | CreateLocalDir["创建本地文件夹"] 35 | CheckRemoteRemovable["检查远程是否可删除
1.遍历子文件
2.验证修改时间"] 36 | RemoveRemoteFolder["删除远程文件夹"] 37 | CreateLocalFolder["创建本地文件夹"] 38 | LocalToRemote["检查本地文件夹"] 39 | CheckLocalFolderRecord["检查本地同步记录"] 40 | CreateRemoteFolder["创建远程文件夹"] 41 | CheckLocalFolderRemovable["检查本地是否可删除
1.遍历子文件
2.验证修改时间"] 42 | RemoveLocalFolder["删除本地文件夹"] 43 | CreateRemoteDirNew["创建远程文件夹"] 44 | end 45 | subgraph s2["文件同步流程"] 46 | CheckSyncRecord["检查同步记录"] 47 | SyncFiles["开始文件同步"] 48 | ExistenceCheck["检查文件存在情况"] 49 | ChangeCheck["检查变更状态
对比修改时间"] 50 | Conflict["冲突解决
使用最新时间戳"] 51 | Download["下载远程文件"] 52 | Upload["上传本地文件"] 53 | RemoteOnlyCheck["远程文件检查"] 54 | DownloadNew["下载新文件"] 55 | DeleteRemoteFile["删除远程文件"] 56 | LocalOnlyCheck["本地文件检查"] 57 | UploadNew["上传新文件"] 58 | DeleteLocalFile["删除本地文件"] 59 | NoRecordCheck["检查文件情况"] 60 | ResolveConflict["解决冲突
使用最新时间戳"] 61 | PullNewFile["下载远程文件"] 62 | PushNewFile["上传本地文件"] 63 | end 64 | Start(["开始同步"]) --> PrepareSync["准备同步环境
1.创建远程基础目录
2.加载同步记录"] 65 | PrepareSync --> LoadStats["获取文件状态
1.遍历本地文件统计
2.遍历远程文件统计"] 66 | LoadStats --> SyncFolders 67 | SyncFolders -- 第一步:远程到本地 --> RemoteToLocal 68 | RemoteToLocal -- 本地存在 --> ValidateType 69 | ValidateType -- 类型不匹配 --> ErrorFolder 70 | RemoteToLocal -- 本地不存在但有记录 --> CheckRemoteFolderChanged 71 | CheckRemoteFolderChanged -- 远程已修改 --> CreateLocalDir 72 | CheckRemoteFolderChanged -- 远程未修改 --> CheckRemoteRemovable 73 | CheckRemoteRemovable -- 可以删除 --> RemoveRemoteFolder 74 | RemoteToLocal -- 完全无记录 --> CreateLocalFolder 75 | SyncFolders -- 第二步:本地到远程 --> LocalToRemote 76 | LocalToRemote -- 远程不存在 --> CheckLocalFolderRecord 77 | CheckLocalFolderRecord -- 有记录且本地变更 --> CreateRemoteFolder 78 | CheckLocalFolderRecord -- 有记录未变更 --> CheckLocalFolderRemovable 79 | CheckLocalFolderRemovable -- 可以删除 --> RemoveLocalFolder 80 | CheckLocalFolderRecord -- 无记录 --> CreateRemoteDirNew 81 | SyncFiles --> CheckSyncRecord & UpdateRecords["更新同步记录"] 82 | CheckSyncRecord -- 存在同步记录 --> ExistenceCheck 83 | ExistenceCheck -- 双端都存在 --> ChangeCheck 84 | ChangeCheck -- 双端都有变更 --> Conflict 85 | ChangeCheck -- 仅远程变更 --> Download 86 | ChangeCheck -- 仅本地变更 --> Upload 87 | ExistenceCheck -- 仅远程存在 --> RemoteOnlyCheck 88 | RemoteOnlyCheck -- 远程有变更 --> DownloadNew 89 | RemoteOnlyCheck -- 远程无变更 --> DeleteRemoteFile 90 | ExistenceCheck -- 仅本地存在 --> LocalOnlyCheck 91 | LocalOnlyCheck -- 本地有变更 --> UploadNew 92 | LocalOnlyCheck -- 本地无变更 --> DeleteLocalFile 93 | CheckSyncRecord -- 无同步记录 --> NoRecordCheck 94 | NoRecordCheck -- 双端都存在 --> ResolveConflict 95 | NoRecordCheck -- 仅远程存在 --> PullNewFile 96 | NoRecordCheck -- 仅本地存在 --> PushNewFile 97 | SyncFolders --> SyncFiles 98 | UpdateRecords --> End(["同步完成"]) 99 | ``` 100 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import postcss from '@deanc/esbuild-plugin-postcss' 2 | import UnoCSS from '@unocss/postcss' 3 | import dotenv from 'dotenv' 4 | import esbuild from 'esbuild' 5 | import fs from 'fs' 6 | import postcssMergeRules from 'postcss-merge-rules' 7 | import process from 'process' 8 | 9 | const renamePlugin = { 10 | name: 'rename-plugin', 11 | setup(build) { 12 | build.onEnd(async () => { 13 | fs.renameSync('./main.css', './styles.css') 14 | }) 15 | }, 16 | } 17 | 18 | dotenv.config() 19 | 20 | const prod = process.argv[2] === 'production' 21 | 22 | const context = await esbuild.context({ 23 | entryPoints: ['src/index.ts'], 24 | bundle: true, 25 | external: [ 26 | 'obsidian', 27 | 'electron', 28 | '@codemirror/autocomplete', 29 | '@codemirror/collab', 30 | '@codemirror/commands', 31 | '@codemirror/language', 32 | '@codemirror/lint', 33 | '@codemirror/search', 34 | '@codemirror/state', 35 | '@codemirror/view', 36 | '@lezer/common', 37 | '@lezer/highlight', 38 | '@lezer/lr', 39 | ], 40 | define: { 41 | 'process.env.NS_NSDAV_ENDPOINT': JSON.stringify( 42 | process.env.NS_NSDAV_ENDPOINT, 43 | ), 44 | 'process.env.NS_DAV_ENDPOINT': JSON.stringify(process.env.NS_DAV_ENDPOINT), 45 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || ''), 46 | }, 47 | format: 'cjs', 48 | target: 'es2015', 49 | logLevel: 'info', 50 | sourcemap: prod ? false : 'inline', 51 | treeShaking: true, 52 | outfile: 'main.js', 53 | minify: prod, 54 | platform: 'browser', 55 | plugins: [ 56 | postcss({ 57 | plugins: [UnoCSS(), postcssMergeRules()], 58 | }), 59 | renamePlugin, 60 | ], 61 | }) 62 | 63 | if (prod) { 64 | await context.rebuild() 65 | process.exit(0) 66 | } else { 67 | await context.watch() 68 | } 69 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nutstore-sync", 3 | "name": "Nutstore Sync", 4 | "version": "0.7.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Sync your vault with Nutstore/坚果云 using WebDAV protocol", 7 | "author": "nutstore", 8 | "authorUrl": "https://github.com/nutstore", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-nutstore-sync", 3 | "version": "0.7.0", 4 | "main": "main.js", 5 | "scripts": { 6 | "dev": "run-p dev:*", 7 | "dev:plugin": "node esbuild.config.mjs", 8 | "dev:webdav-explorer": "pnpm --filter webdav-explorer dev", 9 | "build": "run-s build:webdav-explorer build:plugin", 10 | "build:webdav-explorer": "pnpm --filter webdav-explorer build", 11 | "build:plugin": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 12 | "version": "node version-bump.mjs && git add manifest.json versions.json", 13 | "test": "vitest" 14 | }, 15 | "devDependencies": { 16 | "@deanc/esbuild-plugin-postcss": "^1.0.2", 17 | "@electron/remote": "^2.1.2", 18 | "@nutstore/sso-js": "^0.0.3", 19 | "@types/diff-match-patch": "^1.0.36", 20 | "@types/glob-to-regexp": "^0.4.4", 21 | "@types/lodash-es": "^4.17.12", 22 | "@types/node": "^16.11.6", 23 | "@types/pako": "^2.0.3", 24 | "@types/ramda": "^0.30.2", 25 | "@typescript-eslint/eslint-plugin": "5.29.0", 26 | "@typescript-eslint/parser": "5.29.0", 27 | "@unocss/postcss": "66.1.0-beta.3", 28 | "@vitest/coverage-v8": "^3.1.2", 29 | "assert": "^2.1.0", 30 | "bottleneck": "^2.19.5", 31 | "buffer": "^6.0.3", 32 | "builtin-modules": "3.3.0", 33 | "bytes-iec": "^3.1.1", 34 | "consola": "^3.4.0", 35 | "core-js": "^3.41.0", 36 | "crypto-browserify": "^3.12.1", 37 | "diff-match-patch": "^1.0.5", 38 | "dotenv": "^16.4.7", 39 | "esbuild": "0.17.3", 40 | "esbuild-sass-plugin": "^3.3.1", 41 | "fast-xml-parser": "^4.5.1", 42 | "glob-to-regexp": "^0.4.1", 43 | "hash-wasm": "^4.12.0", 44 | "html-entities": "^2.6.0", 45 | "http-status-codes": "^2.3.0", 46 | "i18next": "^24.2.2", 47 | "js-base64": "^3.7.7", 48 | "localforage": "^1.10.0", 49 | "lodash-es": "^4.17.21", 50 | "node-diff3": "^3.1.2", 51 | "npm-run-all2": "^7.0.2", 52 | "obsidian": "latest", 53 | "ohash": "^1.1.4", 54 | "pako": "^2.1.0", 55 | "path-browserify": "^1.0.1", 56 | "postcss-merge-rules": "^7.0.4", 57 | "prettier": "^3.5.0", 58 | "ramda": "^0.30.1", 59 | "rxjs": "^7.8.1", 60 | "stream-browserify": "^3.0.0", 61 | "superjson": "^2.2.2", 62 | "tslib": "2.4.0", 63 | "typescript": "^5", 64 | "unocss": "66.1.0-beta.3", 65 | "vitest": "^3.1.2", 66 | "webdav": "^5.7.1", 67 | "webdav-explorer": "workspace: *" 68 | }, 69 | "browser": { 70 | "path": "path-browserify", 71 | "stream": "stream-browserify", 72 | "buffer": "buffer", 73 | "crypto": "crypto-browserify" 74 | } 75 | } -------------------------------------------------------------------------------- /packages/webdav-explorer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webdav-explorer", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "import": "./dist/index.js" 9 | } 10 | }, 11 | "module": "./dist/index.js", 12 | "types": "./dist/index.d.ts", 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "rslib build", 18 | "dev": "rslib build --watch" 19 | }, 20 | "devDependencies": { 21 | "@rsbuild/plugin-babel": "^1.0.4", 22 | "@rsbuild/plugin-solid": "^1.0.5", 23 | "@rslib/core": "^0.5.3", 24 | "@solid-primitives/i18n": "^2.2.0", 25 | "@solid-primitives/media": "^2.3.0", 26 | "@unocss/postcss": "66.1.0-beta.3", 27 | "typescript": "^5.8.2", 28 | "unocss": "66.1.0-beta.3" 29 | }, 30 | "dependencies": { 31 | "solid-js": "^1.9.5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/webdav-explorer/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | import UnoCSS from '@unocss/postcss' 2 | 3 | export default { 4 | plugins: [UnoCSS()], 5 | } 6 | -------------------------------------------------------------------------------- /packages/webdav-explorer/rslib.config.ts: -------------------------------------------------------------------------------- 1 | import { pluginBabel } from '@rsbuild/plugin-babel' 2 | import { pluginSolid } from '@rsbuild/plugin-solid' 3 | import { defineConfig } from '@rslib/core' 4 | 5 | export default defineConfig({ 6 | source: { 7 | entry: { 8 | index: ['./src/**'], 9 | }, 10 | }, 11 | tools: { 12 | rspack: { 13 | plugins: [], 14 | }, 15 | }, 16 | lib: [ 17 | { 18 | bundle: false, 19 | dts: true, 20 | format: 'esm', 21 | }, 22 | ], 23 | output: { 24 | target: 'web', 25 | }, 26 | plugins: [ 27 | pluginBabel({ 28 | include: /\.(?:jsx|tsx)$/, 29 | }), 30 | pluginSolid(), 31 | ], 32 | }) 33 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { createMediaQuery } from '@solid-primitives/media' 2 | import { Notice } from 'obsidian' 3 | import path from 'path' 4 | import { createSignal, Show } from 'solid-js' 5 | import { createFileList, FileStat } from './components/FileList' 6 | import NewFolder from './components/NewFolder' 7 | import { t } from './i18n' 8 | 9 | type MaybePromise = Promise | T 10 | 11 | export interface fs { 12 | ls: (path: string) => MaybePromise 13 | mkdirs: (path: string) => MaybePromise 14 | } 15 | 16 | export interface AppProps { 17 | fs: fs 18 | onConfirm: (path: string) => void 19 | onClose: () => void 20 | } 21 | 22 | function App(props: AppProps) { 23 | const [stack, setStack] = createSignal(['/']) 24 | const [showNewFolder, setShowNewFolder] = createSignal(false) 25 | const cwd = () => stack().at(-1) 26 | // @ts-ignore 27 | const isSmall = createMediaQuery('(max-width: 767px)') 28 | 29 | function enter(path: string) { 30 | setStack((stack) => [...stack, path]) 31 | } 32 | 33 | function pop() { 34 | setStack((stack) => 35 | stack.length > 1 ? stack.slice(0, stack.length - 1) : stack, 36 | ) 37 | } 38 | 39 | const SingleCol = () => { 40 | const list = createFileList() 41 | return ( 42 |
43 | 44 | setShowNewFolder(false)} 47 | onConfirm={async (name) => { 48 | const target = path.join(cwd() ?? '/', name) 49 | await Promise.resolve(props.fs.mkdirs(target)) 50 | .then(() => { 51 | setShowNewFolder(false) 52 | list.refresh() 53 | }) 54 | .catch((e) => { 55 | if (e instanceof Error) { 56 | new Notice(e.message) 57 | } 58 | }) 59 | }} 60 | /> 61 | 62 | enter(f.path)} 66 | /> 67 |
68 | ) 69 | } 70 | 71 | return ( 72 |
73 | 74 |
75 | {t('currentPath')}: 76 | {cwd() ?? '/'} 77 |
78 |
79 | 80 | setShowNewFolder(true)}> 81 | {t('newFolder')} 82 | 83 |
84 | 85 | 88 |
89 |
90 | ) 91 | } 92 | 93 | export default App 94 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/assets/styles/global.css: -------------------------------------------------------------------------------- 1 | @unocss; 2 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/components/File.tsx: -------------------------------------------------------------------------------- 1 | export interface FolderProps { 2 | name: string 3 | } 4 | 5 | function File(props: FolderProps) { 6 | return ( 7 |
8 |
9 | {props.name} 10 |
11 | ) 12 | } 13 | 14 | export default File 15 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/components/FileList.tsx: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian' 2 | import { createEffect, createSignal, For, Show } from 'solid-js' 3 | import { type fs } from '../App' 4 | import File from './File' 5 | import Folder from './Folder' 6 | 7 | export interface FileStat { 8 | path: string 9 | basename: string 10 | isDir: boolean 11 | } 12 | 13 | export interface FileListProps { 14 | path: string 15 | fs: fs 16 | onClick: (file: FileStat) => void 17 | } 18 | 19 | export function createFileList() { 20 | const [version, setVersion] = createSignal(0) 21 | return { 22 | refresh() { 23 | setVersion((v) => ++v) 24 | }, 25 | FileList(props: FileListProps) { 26 | const [items, setItems] = createSignal([]) 27 | 28 | const sortedItems = () => 29 | items().sort((a, b) => { 30 | if (a.isDir === b.isDir) { 31 | return a.basename.localeCompare(b.basename, ['zh']) 32 | } 33 | if (a.isDir && !b.isDir) { 34 | return -1 35 | } else { 36 | return 1 37 | } 38 | }) 39 | 40 | async function refresh() { 41 | try { 42 | const items = await props.fs.ls(props.path) 43 | setItems(items) 44 | } catch (e) { 45 | if (e instanceof Error) { 46 | new Notice(e.message) 47 | } 48 | } 49 | } 50 | 51 | createEffect(async () => { 52 | if (version() === 0) { 53 | await refresh() 54 | return 55 | } 56 | setVersion(0) 57 | }) 58 | 59 | return ( 60 | 61 | {(f) => ( 62 | }> 63 | props.onClick(f)} 67 | /> 68 | 69 | )} 70 | 71 | ) 72 | }, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/components/Folder.tsx: -------------------------------------------------------------------------------- 1 | export interface FolderProps { 2 | name: string 3 | path: string 4 | onClick: (path: string) => void 5 | } 6 | 7 | function Folder(props: FolderProps) { 8 | return ( 9 |
props.onClick(props.path)} 12 | > 13 |
14 | {props.name} 15 |
16 | ) 17 | } 18 | 19 | export default Folder 20 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/components/NewFolder.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js' 2 | import { t } from '../i18n' 3 | 4 | interface NewFolderProps { 5 | class?: string 6 | onConfirm: (name: string) => void 7 | onCancel: () => void 8 | } 9 | 10 | function NewFolder(props: NewFolderProps) { 11 | const [name, setName] = createSignal('') 12 | 13 | const className = () => `flex items-center gap-2 px-1 ${props.class}` 14 | 15 | return ( 16 |
17 |
18 | setName(e.target.value)} 24 | /> 25 | 26 | 27 |
28 | ) 29 | } 30 | 31 | export default NewFolder 32 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import * as i18n from '@solid-primitives/i18n' 2 | import { createResource, createSignal } from 'solid-js' 3 | import en from './locales/en' 4 | import zh from './locales/zh' 5 | 6 | export type Locale = 'zh' | 'en' 7 | 8 | export function toLocale(language: string) { 9 | switch (language.split('-')[0].toLowerCase()) { 10 | case 'zh': 11 | return 'zh' 12 | default: 13 | return 'en' 14 | } 15 | } 16 | 17 | export const [locale, setLocale] = createSignal( 18 | toLocale(navigator.language), 19 | ) 20 | 21 | const [dict] = createResource(locale, (locale) => { 22 | switch (locale) { 23 | case 'zh': 24 | return i18n.flatten(zh) 25 | default: 26 | return i18n.flatten(en) 27 | } 28 | }) 29 | 30 | export const t = i18n.translator(dict) 31 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/i18n/locales/en.ts: -------------------------------------------------------------------------------- 1 | const en = { 2 | newFolder: 'New Folder', 3 | goBack: 'Go Back', 4 | confirm: 'Confirm', 5 | cancel: 'Cancel', 6 | currentPath: 'Current Path', 7 | } 8 | 9 | export default en 10 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/i18n/locales/zh.ts: -------------------------------------------------------------------------------- 1 | const zh = { 2 | newFolder: '新建文件夹', 3 | goBack: '返回上一层', 4 | confirm: '确定', 5 | cancel: '取消', 6 | currentPath: '当前路径', 7 | } 8 | 9 | export default zh 10 | -------------------------------------------------------------------------------- /packages/webdav-explorer/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './assets/styles/global.css' 2 | 3 | import { render } from 'solid-js/web' 4 | import App, { AppProps } from './App' 5 | 6 | export function mount(el: Element, props: AppProps) { 7 | return render(() => , el) 8 | } 9 | -------------------------------------------------------------------------------- /packages/webdav-explorer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "ES2020"], 4 | "jsx": "preserve", 5 | "target": "ES2020", 6 | "noEmit": true, 7 | "skipLibCheck": true, 8 | "jsxImportSource": "solid-js", 9 | "useDefineForClassFields": true, 10 | 11 | /* modules */ 12 | "module": "ESNext", 13 | "isolatedModules": true, 14 | "resolveJsonModule": true, 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | 18 | /* type checking */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/webdav-explorer/unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetIcons, presetUno } from 'unocss' 2 | 3 | export default defineConfig({ 4 | content: { 5 | filesystem: ['**/*.{html,js,ts,jsx,tsx,vue,svelte,astro}'], 6 | }, 7 | rules: [ 8 | [ 9 | /^scrollbar-hide$/, 10 | ([_]) => { 11 | return `.scrollbar-hide{scrollbar-width:none} 12 | .scrollbar-hide::-webkit-scrollbar{display:none}` 13 | }, 14 | ], 15 | [ 16 | /^scrollbar-default$/, 17 | ([_]) => { 18 | return `.scrollbar-default{scrollbar-width:auto} 19 | .scrollbar-default::-webkit-scrollbar{display:block}` 20 | }, 21 | ], 22 | ], 23 | presets: [ 24 | presetIcons({ 25 | collections: { 26 | custom: { 27 | folder: 28 | 'folder', 29 | file: 'unknown', 30 | }, 31 | }, 32 | }), 33 | presetUno(), 34 | ], 35 | }) 36 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | -------------------------------------------------------------------------------- /src/api/delta.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from 'fast-xml-parser' 2 | import { isNil } from 'lodash-es' 3 | import { apiLimiter } from '~/utils/api-limiter' 4 | import { NSAPI } from '~/utils/ns-api' 5 | import requestUrl from '~/utils/request-url' 6 | 7 | export interface DeltaEntry { 8 | path: string 9 | size: number 10 | isDeleted: boolean 11 | isDir: boolean 12 | modified: string 13 | revision: number 14 | } 15 | 16 | export interface DeltaResponse { 17 | reset: boolean 18 | cursor: string 19 | hasMore: boolean 20 | delta: { 21 | entry: DeltaEntry[] 22 | } 23 | } 24 | 25 | interface GetDeltaInput { 26 | folderName: string 27 | cursor?: string 28 | token: string 29 | } 30 | 31 | export const getDelta = apiLimiter.wrap( 32 | async ({ folderName, cursor, token }: GetDeltaInput) => { 33 | const body = ` 34 | 35 | ${folderName} 36 | ${cursor ?? ''} 37 | ` 38 | const response = await requestUrl({ 39 | url: NSAPI('delta'), 40 | method: 'POST', 41 | headers: { 42 | Authorization: `Basic ${token}`, 43 | 'Content-Type': 'application/xml', 44 | }, 45 | body, 46 | }) 47 | 48 | const parseXml = new XMLParser({ 49 | attributeNamePrefix: '', 50 | removeNSPrefix: true, 51 | numberParseOptions: { 52 | eNotation: false, 53 | hex: true, 54 | leadingZeros: true, 55 | }, 56 | }) 57 | const result: { response: DeltaResponse } = parseXml.parse(response.text) 58 | 59 | if (!isNil(result?.response?.cursor)) { 60 | result.response.cursor = result.response.cursor.toString() 61 | } 62 | if (result.response.delta) { 63 | const entry = result.response.delta.entry 64 | if (!Array.isArray(entry)) { 65 | result.response.delta.entry = [entry] 66 | } 67 | } else { 68 | result.response.delta = { 69 | entry: [], 70 | } 71 | } 72 | return result 73 | }, 74 | ) 75 | -------------------------------------------------------------------------------- /src/api/latestDeltaCursor.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from 'fast-xml-parser' 2 | import { apiLimiter } from '~/utils/api-limiter' 3 | import { NSAPI } from '~/utils/ns-api' 4 | import requestUrl from '~/utils/request-url' 5 | 6 | interface GetLatestDeltaCursorInput { 7 | folderName: string 8 | token: string 9 | } 10 | 11 | export const getLatestDeltaCursor = apiLimiter.wrap( 12 | async ({ folderName, token }: GetLatestDeltaCursorInput) => { 13 | const body = ` 14 | 15 | ${folderName} 16 | ` 17 | const headers = { 18 | Authorization: `Basic ${token}`, 19 | 'Content-Type': 'application/xml', 20 | } 21 | const response = await requestUrl({ 22 | url: NSAPI('latestDeltaCursor'), 23 | method: 'POST', 24 | headers, 25 | body, 26 | }) 27 | const parseXml = new XMLParser({ 28 | attributeNamePrefix: '', 29 | removeNSPrefix: true, 30 | parseTagValue: false, 31 | numberParseOptions: { 32 | eNotation: false, 33 | hex: true, 34 | leadingZeros: true, 35 | }, 36 | }) 37 | const result: { 38 | response: { 39 | cursor: string 40 | } 41 | } = parseXml.parse(response.text) 42 | return result 43 | }, 44 | ) 45 | -------------------------------------------------------------------------------- /src/api/webdav.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from 'fast-xml-parser' 2 | import { isNil, partial } from 'lodash-es' 3 | import { basename, join } from 'path' 4 | import { FileStat } from 'webdav' 5 | import { NS_DAV_ENDPOINT } from '~/consts' 6 | import { is503Error } from '~/utils/is-503-error' 7 | import logger from '~/utils/logger' 8 | import requestUrl from '~/utils/request-url' 9 | 10 | interface WebDAVResponse { 11 | multistatus: { 12 | response: Array<{ 13 | href: string 14 | propstat: { 15 | prop: { 16 | displayname: string 17 | resourcetype: { collection?: any } 18 | getlastmodified?: string 19 | getcontentlength?: string 20 | getcontenttype?: string 21 | } 22 | status: string 23 | } 24 | }> 25 | } 26 | } 27 | 28 | function extractNextLink(linkHeader: string): string | null { 29 | const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/) 30 | return matches ? matches[1] : null 31 | } 32 | 33 | function convertToFileStat( 34 | serverBase: string, 35 | item: WebDAVResponse['multistatus']['response'][number], 36 | ): FileStat { 37 | const props = item.propstat.prop 38 | const isDir = !isNil(props.resourcetype?.collection) 39 | const href = decodeURIComponent(item.href) 40 | const filename = 41 | serverBase === '/' ? href : join('/', href.replace(serverBase, '')) 42 | 43 | return { 44 | filename, 45 | basename: basename(filename), 46 | lastmod: props.getlastmodified || '', 47 | size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0, 48 | type: isDir ? 'directory' : 'file', 49 | etag: null, 50 | mime: props.getcontenttype, 51 | } 52 | } 53 | 54 | export async function getDirectoryContents( 55 | token: string, 56 | path: string, 57 | ): Promise { 58 | const contents: FileStat[] = [] 59 | path = path.split('/').map(encodeURIComponent).join('/') 60 | if (!path.startsWith('/')) { 61 | path = '/' + path 62 | } 63 | let currentUrl = `${NS_DAV_ENDPOINT}${path}` 64 | 65 | while (true) { 66 | try { 67 | const response = await requestUrl({ 68 | url: currentUrl, 69 | method: 'PROPFIND', 70 | headers: { 71 | Authorization: `Basic ${token}`, 72 | 'Content-Type': 'application/xml', 73 | Depth: '1', 74 | }, 75 | body: ` 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | `, 85 | }) 86 | const parseXml = new XMLParser({ 87 | attributeNamePrefix: '', 88 | removeNSPrefix: true, 89 | parseTagValue: false, 90 | numberParseOptions: { 91 | eNotation: false, 92 | hex: true, 93 | leadingZeros: true, 94 | }, 95 | }) 96 | const result: WebDAVResponse = parseXml.parse(response.text) 97 | const items = Array.isArray(result.multistatus.response) 98 | ? result.multistatus.response 99 | : [result.multistatus.response] 100 | 101 | // 跳过第一个条目(当前目录) 102 | contents.push(...items.slice(1).map(partial(convertToFileStat, '/dav'))) 103 | 104 | const linkHeader = response.headers['link'] || response.headers['Link'] 105 | if (!linkHeader) { 106 | break 107 | } 108 | 109 | const nextLink = extractNextLink(linkHeader) 110 | if (!nextLink) { 111 | break 112 | } 113 | const nextUrl = new URL(nextLink) 114 | nextUrl.pathname = decodeURI(nextUrl.pathname) 115 | currentUrl = nextUrl.toString() 116 | } catch (e) { 117 | if (is503Error(e)) { 118 | logger.error('503 error, retrying...') 119 | await sleep(60_000) 120 | continue 121 | } 122 | throw e 123 | } 124 | } 125 | 126 | return contents 127 | } 128 | -------------------------------------------------------------------------------- /src/assets/styles/global.css: -------------------------------------------------------------------------------- 1 | @unocss; 2 | 3 | .view-action[aria-disabled='true'] { 4 | opacity: 0.5; 5 | cursor: not-allowed; 6 | } 7 | 8 | @keyframes nutstore-sync-spin { 9 | from { 10 | transform: rotate(0deg); 11 | } 12 | to { 13 | transform: rotate(-360deg); 14 | } 15 | } 16 | 17 | .nutstore-sync-spinning { 18 | animation: nutstore-sync-spin 2s linear infinite; 19 | } 20 | 21 | .conflict.ours, 22 | .conflict.theirs { 23 | position: relative; 24 | white-space: pre-wrap; 25 | word-break: break-word; 26 | } 27 | 28 | .conflict.ours { 29 | background-color: rgb(0, 255, 208); 30 | } 31 | 32 | .conflict.theirs { 33 | background-color: rgb(0, 132, 255); 34 | } 35 | 36 | .connection-status { 37 | padding: 6px 12px; 38 | border-radius: 4px; 39 | margin-top: 8px; 40 | transition: all 0.3s ease; 41 | opacity: 0; 42 | } 43 | 44 | .connection-status.success { 45 | background-color: var(--background-modifier-success); 46 | color: var(--text-success); 47 | opacity: 1; 48 | } 49 | 50 | .connection-status.error { 51 | background-color: var(--background-modifier-error); 52 | color: var(--text-error); 53 | opacity: 1; 54 | } 55 | 56 | .connection-button.loading { 57 | opacity: 0.5; 58 | pointer-events: none; 59 | } 60 | 61 | .connection-button.success { 62 | background-color: var(--background-modifier-success); 63 | color: var(--background-primary); 64 | border-color: var(--background-modifier-success); 65 | } 66 | 67 | .connection-button.error { 68 | background-color: var(--background-modifier-error); 69 | color: var(--background-primary); 70 | border-color: var(--background-modifier-error); 71 | } 72 | 73 | input.error { 74 | border-color: var(--background-modifier-error) !important; 75 | box-shadow: none !important; 76 | } 77 | 78 | input.success { 79 | border-color: var(--background-modifier-success) !important; 80 | box-shadow: none !important; 81 | } 82 | 83 | .task-list-table { 84 | width: 100%; 85 | border-collapse: collapse; 86 | margin: 1em 0; 87 | } 88 | 89 | .task-list-table th, 90 | .task-list-table td { 91 | padding: 8px; 92 | word-break: keep-all; 93 | border: 1px solid var(--background-modifier-border); 94 | text-align: left; 95 | } 96 | 97 | .task-list-table th { 98 | background-color: var(--background-secondary); 99 | font-weight: bold; 100 | } 101 | 102 | .task-list-table tr:hover { 103 | background-color: var(--background-modifier-hover); 104 | } 105 | -------------------------------------------------------------------------------- /src/components/CacheClearModal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from 'obsidian' 2 | import i18n from '~/i18n' 3 | import { blobKV, deltaCacheKV, syncRecordKV } from '~/storage/kv' 4 | import logger from '~/utils/logger' 5 | import type NutstorePlugin from '..' 6 | 7 | export interface CacheClearOptions { 8 | deltaCacheEnabled: boolean 9 | syncRecordEnabled: boolean 10 | blobEnabled: boolean 11 | } 12 | 13 | export default class CacheClearModal extends Modal { 14 | private options: CacheClearOptions = { 15 | deltaCacheEnabled: false, 16 | syncRecordEnabled: false, 17 | blobEnabled: false, 18 | } 19 | 20 | constructor( 21 | private plugin: NutstorePlugin, 22 | private onSuccess?: (options: CacheClearOptions) => void, 23 | ) { 24 | super(plugin.app) 25 | } 26 | 27 | onOpen() { 28 | const { contentEl } = this 29 | 30 | new Setting(contentEl) 31 | .setName(i18n.t('settings.cache.clearModal.title')) 32 | .setDesc(i18n.t('settings.cache.clearModal.description')) 33 | 34 | const optionsContainer = contentEl.createDiv({ 35 | cls: 'py-2', 36 | }) 37 | 38 | // Delta Cache Option 39 | new Setting(optionsContainer) 40 | .setName(i18n.t('settings.cache.clearModal.deltaCache.name')) 41 | .setDesc(i18n.t('settings.cache.clearModal.deltaCache.desc')) 42 | .addToggle((toggle) => { 43 | toggle.setValue(this.options.deltaCacheEnabled).onChange((value) => { 44 | this.options.deltaCacheEnabled = value 45 | }) 46 | }) 47 | 48 | // Sync Record Cache Option 49 | new Setting(optionsContainer) 50 | .setName(i18n.t('settings.cache.clearModal.syncRecordCache.name')) 51 | .setDesc(i18n.t('settings.cache.clearModal.syncRecordCache.desc')) 52 | .addToggle((toggle) => { 53 | toggle.setValue(this.options.syncRecordEnabled).onChange((value) => { 54 | this.options.syncRecordEnabled = value 55 | }) 56 | }) 57 | 58 | // Blob Cache Option 59 | new Setting(optionsContainer) 60 | .setName(i18n.t('settings.cache.clearModal.blobCache.name')) 61 | .setDesc(i18n.t('settings.cache.clearModal.blobCache.desc')) 62 | .addToggle((toggle) => { 63 | toggle.setValue(this.options.blobEnabled).onChange((value) => { 64 | this.options.blobEnabled = value 65 | }) 66 | }) 67 | 68 | // Action buttons 69 | new Setting(contentEl) 70 | .addButton((button) => { 71 | button 72 | .setButtonText(i18n.t('settings.cache.clearModal.cancel')) 73 | .onClick(() => { 74 | this.close() 75 | }) 76 | }) 77 | .addButton((button) => { 78 | let confirmed = false 79 | button 80 | .setButtonText(i18n.t('settings.cache.clear')) 81 | .onClick(async () => { 82 | if (confirmed) { 83 | try { 84 | if (this.onSuccess) { 85 | this.onSuccess(this.options) 86 | } 87 | this.close() 88 | } catch (error) { 89 | logger.error('Error clearing cache:', error) 90 | } finally { 91 | button.setButtonText( 92 | i18n.t('settings.cache.clearModal.confirm'), 93 | ) 94 | button.buttonEl.classList.remove('mod-warning') 95 | confirmed = false 96 | } 97 | } else { 98 | confirmed = true 99 | button 100 | .setButtonText(i18n.t('settings.cache.confirm')) 101 | .setWarning() 102 | } 103 | }) 104 | 105 | button.buttonEl.addEventListener('blur', () => { 106 | if (confirmed) { 107 | confirmed = false 108 | button.setButtonText(i18n.t('settings.cache.clear')) 109 | button.buttonEl.classList.remove('mod-warning') 110 | } 111 | }) 112 | }) 113 | } 114 | 115 | onClose() { 116 | const { contentEl } = this 117 | contentEl.empty() 118 | } 119 | 120 | /** 121 | * Static method to clear selected caches 122 | */ 123 | static async clearSelectedCaches(options: CacheClearOptions) { 124 | const { deltaCacheEnabled, syncRecordEnabled, blobEnabled } = options 125 | const cleared = [] 126 | 127 | try { 128 | if (deltaCacheEnabled) { 129 | await deltaCacheKV.clear() 130 | cleared.push(i18n.t('settings.cache.clearModal.deltaCache.name')) 131 | } 132 | 133 | if (syncRecordEnabled) { 134 | await syncRecordKV.clear() 135 | cleared.push(i18n.t('settings.cache.clearModal.syncRecordCache.name')) 136 | } 137 | 138 | if (blobEnabled) { 139 | await blobKV.clear() 140 | cleared.push(i18n.t('settings.cache.clearModal.blobCache.name')) 141 | } 142 | 143 | return cleared 144 | } catch (error) { 145 | logger.error('Error clearing caches:', error) 146 | throw error 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/components/CacheRestoreModal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from 'obsidian' 2 | import i18n from '~/i18n' 3 | import { StatModel } from '~/model/stat.model' 4 | import CacheService from '~/services/cache.service.v1' 5 | import logger from '~/utils/logger' 6 | import type NutstorePlugin from '..' 7 | 8 | export default class CacheRestoreModal extends Modal { 9 | private fileList: HTMLElement 10 | private files: StatModel[] = [] 11 | private cacheService: CacheService 12 | 13 | constructor( 14 | private plugin: NutstorePlugin, 15 | private remoteCacheDir: string, 16 | private onSuccess?: () => void, 17 | ) { 18 | super(plugin.app) 19 | this.cacheService = new CacheService(plugin, remoteCacheDir) 20 | } 21 | 22 | async onOpen() { 23 | const { contentEl } = this 24 | 25 | new Setting(contentEl) 26 | .setName(i18n.t('settings.cache.restoreModal.title')) 27 | .setDesc(i18n.t('settings.cache.restoreModal.description')) 28 | 29 | this.fileList = contentEl.createDiv({ 30 | cls: 'max-h-50vh overflow-y-auto pb-2 flex flex-col', 31 | }) 32 | 33 | await this.loadFileList() 34 | 35 | new Setting(contentEl) 36 | .addButton((button) => { 37 | button 38 | .setButtonText(i18n.t('settings.cache.restoreModal.refresh')) 39 | .onClick(async () => { 40 | await this.loadFileList() 41 | }) 42 | }) 43 | .addButton((button) => { 44 | button 45 | .setButtonText(i18n.t('settings.cache.restoreModal.close')) 46 | .onClick(() => { 47 | this.close() 48 | }) 49 | }) 50 | } 51 | 52 | private async loadFileList() { 53 | try { 54 | this.files = await this.cacheService.loadCacheFileList() 55 | 56 | if (this.files.length === 0) { 57 | this.renderEmptyList() 58 | return 59 | } 60 | 61 | // Render file list 62 | this.fileList.empty() 63 | this.files.forEach(({ basename }) => { 64 | const fileItem = this.fileList.createDiv({ 65 | cls: 'flex justify-between items-center py-2', 66 | }) 67 | 68 | fileItem.createSpan({ 69 | text: basename, 70 | cls: 'flex-1 break-all mr-2', 71 | }) 72 | 73 | const actionContainer = fileItem.createDiv({ 74 | cls: 'flex gap-2', 75 | }) 76 | 77 | const restoreBtn = actionContainer.createEl('button', { 78 | text: i18n.t('settings.cache.restoreModal.restore'), 79 | cls: 'mod-cta', 80 | }) 81 | restoreBtn.addEventListener('click', async () => { 82 | try { 83 | await this.cacheService.restoreCache(basename) 84 | this.onSuccess?.() 85 | this.close() 86 | } catch (error) { 87 | // Error is already handled in the service 88 | } 89 | }) 90 | 91 | let confirmedDelete = false 92 | const deleteBtn = actionContainer.createEl('button', { 93 | text: i18n.t('settings.cache.restoreModal.delete'), 94 | cls: 'transition', 95 | }) 96 | deleteBtn.addEventListener('click', async () => { 97 | if (confirmedDelete) { 98 | try { 99 | await this.cacheService.deleteCache(basename) 100 | await this.loadFileList() 101 | } catch (error) { 102 | // Error is already handled in the service 103 | } 104 | } else { 105 | confirmedDelete = true 106 | deleteBtn.setText( 107 | i18n.t('settings.cache.restoreModal.deleteConfirm'), 108 | ) 109 | deleteBtn.classList.add('mod-warning') 110 | } 111 | }) 112 | deleteBtn.addEventListener('blur', () => { 113 | confirmedDelete = false 114 | deleteBtn.setText(i18n.t('settings.cache.restoreModal.delete')) 115 | deleteBtn.classList.remove('mod-warning') 116 | }) 117 | }) 118 | } catch (error) { 119 | logger.error('Error loading cache file list:', error) 120 | this.fileList.empty() 121 | this.fileList.createEl('p', { 122 | text: i18n.t('settings.cache.restoreModal.loadError', { 123 | message: error.message, 124 | }), 125 | cls: 'p-12px text-center text-[var(--text-error)]', 126 | }) 127 | } 128 | } 129 | 130 | private renderEmptyList() { 131 | this.fileList.empty() 132 | this.fileList.createEl('p', { 133 | text: i18n.t('settings.cache.restoreModal.noFiles'), 134 | cls: 'p-12px text-center', 135 | }) 136 | } 137 | 138 | onClose() { 139 | const { contentEl } = this 140 | contentEl.empty() 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/components/CacheSaveModal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, moment } from 'obsidian' 2 | import i18n from '~/i18n' 3 | import CacheService from '~/services/cache.service.v1' 4 | import NutstorePlugin from '..' 5 | 6 | export default class CacheSaveModal extends Modal { 7 | private cacheService: CacheService 8 | 9 | constructor( 10 | private plugin: NutstorePlugin, 11 | private remoteCacheDir: string, 12 | private onSuccess?: () => void, 13 | ) { 14 | super(plugin.app) 15 | this.cacheService = new CacheService(plugin, remoteCacheDir) 16 | } 17 | 18 | onOpen() { 19 | const { contentEl } = this 20 | 21 | contentEl.createEl('h2', { 22 | text: i18n.t('settings.cache.saveModal.title'), 23 | }) 24 | contentEl.createEl('p', { 25 | text: i18n.t('settings.cache.saveModal.description'), 26 | cls: 'setting-item-description', 27 | }) 28 | 29 | const defaultFilename = `${this.plugin.app.vault.getName()}.${moment().format('YYYY-MM-DD HH_mm_ss')}.SyncCache` 30 | 31 | const inputContainer = contentEl.createDiv() 32 | const filenameInput = inputContainer.createEl('input', { 33 | cls: 'w-full', 34 | type: 'text', 35 | value: defaultFilename, 36 | }) 37 | 38 | new Setting(contentEl) 39 | .addButton((button) => { 40 | button 41 | .setButtonText(i18n.t('settings.cache.saveModal.save')) 42 | .setCta() 43 | .onClick(async () => { 44 | try { 45 | let filename = filenameInput.value 46 | if (!filename.endsWith('.v1')) { 47 | filename += '.v1' 48 | } 49 | await this.cacheService.saveCache(filename) 50 | this.onSuccess?.() 51 | this.close() 52 | } catch (error) { 53 | // Error is already handled in the service 54 | } 55 | }) 56 | }) 57 | .addButton((button) => { 58 | button 59 | .setButtonText(i18n.t('settings.cache.saveModal.cancel')) 60 | .onClick(() => { 61 | this.close() 62 | }) 63 | }) 64 | } 65 | 66 | onClose() { 67 | const { contentEl } = this 68 | contentEl.empty() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/components/FilterEditorModal.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from 'lodash-es' 2 | import { Modal, Setting } from 'obsidian' 3 | import i18n from '~/i18n' 4 | import { getUserOptions, GlobMatchOptions } from '~/utils/glob-match' 5 | import NutstorePlugin from '..' 6 | 7 | export default class FilterEditorModal extends Modal { 8 | filters: GlobMatchOptions[] 9 | 10 | constructor( 11 | private plugin: NutstorePlugin, 12 | filters: GlobMatchOptions[] = [], 13 | private onSave: (filters: GlobMatchOptions[]) => void, 14 | ) { 15 | super(plugin.app) 16 | this.filters = cloneDeep(filters) 17 | } 18 | 19 | onOpen() { 20 | const { contentEl } = this 21 | contentEl.empty() 22 | 23 | contentEl.createEl('h2', { text: i18n.t('settings.filters.edit') }) 24 | contentEl.createEl('p', { 25 | text: i18n.t('settings.filters.description'), 26 | cls: 'setting-item-description', 27 | }) 28 | 29 | const listContainer = contentEl.createDiv({ 30 | cls: 'flex flex-col gap-2 pb-2', 31 | }) 32 | 33 | const updateList = () => { 34 | listContainer.empty() 35 | this.filters.forEach((filter, index) => { 36 | const itemContainer = listContainer.createDiv({ 37 | cls: 'flex gap-2', 38 | }) 39 | const input = listContainer.createEl('input', { 40 | type: 'text', 41 | cls: 'flex-1', 42 | placeholder: i18n.t('settings.filters.placeholder'), 43 | value: filter.expr, 44 | }) 45 | input.spellcheck = false 46 | input.addEventListener('input', () => { 47 | filter.expr = input.value 48 | this.filters[index] = filter 49 | }) 50 | const forceCaseBtn = listContainer.createEl('button', { 51 | text: 'Aa', 52 | cls: 'shadow-none!', 53 | }) 54 | function updateButtonStatus() { 55 | const opt = getUserOptions(filter) 56 | const activeCls = ['bg-[var(--interactive-accent)]!'] 57 | const inactiveCls = [ 58 | 'background-none!', 59 | 'hover:bg-[--interactive-normal]!', 60 | ] 61 | if (opt.caseSensitive) { 62 | forceCaseBtn.classList.add(...activeCls) 63 | forceCaseBtn.classList.remove(...inactiveCls) 64 | } else { 65 | forceCaseBtn.classList.remove(...activeCls) 66 | forceCaseBtn.classList.add(...inactiveCls) 67 | } 68 | } 69 | updateButtonStatus() 70 | forceCaseBtn.addEventListener('click', () => { 71 | filter.options.caseSensitive = !filter.options.caseSensitive 72 | updateButtonStatus() 73 | }) 74 | const trash = listContainer.createEl('button', { 75 | text: i18n.t('settings.filters.remove'), 76 | }) 77 | let confirmDelete = false 78 | trash.addEventListener('click', () => { 79 | if (!confirmDelete) { 80 | confirmDelete = true 81 | trash.setText(i18n.t('settings.filters.confirmRemove')) 82 | trash.addClass('mod-warning') 83 | } else { 84 | this.filters.splice(index, 1) 85 | updateList() 86 | } 87 | }) 88 | trash.addEventListener('blur', () => { 89 | confirmDelete = false 90 | trash.setText(i18n.t('settings.filters.remove')) 91 | trash.removeClass('mod-warning') 92 | }) 93 | itemContainer.appendChild(input) 94 | itemContainer.appendChild(forceCaseBtn) 95 | itemContainer.appendChild(trash) 96 | }) 97 | } 98 | 99 | updateList() 100 | 101 | new Setting(contentEl).addButton((button) => { 102 | button.setButtonText(i18n.t('settings.filters.add')).onClick(() => { 103 | this.filters.push({ 104 | expr: '', 105 | options: { 106 | caseSensitive: false, 107 | }, 108 | }) 109 | updateList() 110 | }) 111 | }) 112 | 113 | new Setting(contentEl) 114 | .addButton((button) => { 115 | button 116 | .setButtonText(i18n.t('settings.filters.save')) 117 | .setCta() 118 | .onClick(() => { 119 | this.onSave(this.filters) 120 | this.close() 121 | }) 122 | }) 123 | .addButton((button) => { 124 | button.setButtonText(i18n.t('settings.filters.cancel')).onClick(() => { 125 | this.close() 126 | }) 127 | }) 128 | } 129 | 130 | onClose() { 131 | const { contentEl } = this 132 | contentEl.empty() 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/components/LogoutConfirmModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian' 2 | import i18n from '../i18n' 3 | 4 | export default class LogoutConfirmModal extends Modal { 5 | private onConfirm: () => void 6 | 7 | constructor(app: App, onConfirm: () => void) { 8 | super(app) 9 | this.onConfirm = onConfirm 10 | } 11 | 12 | onOpen() { 13 | const { contentEl } = this 14 | 15 | contentEl.createEl('h2', { text: i18n.t('settings.logout.confirmTitle') }) 16 | contentEl.createEl('p', { text: i18n.t('settings.logout.confirmMessage') }) 17 | 18 | new Setting(contentEl) 19 | .addButton((button) => 20 | button 21 | .setButtonText(i18n.t('settings.logout.cancel')) 22 | .onClick(() => this.close()), 23 | ) 24 | .addButton((button) => 25 | button 26 | .setButtonText(i18n.t('settings.logout.confirm')) 27 | .setWarning() 28 | .onClick(() => { 29 | this.close() 30 | this.onConfirm() 31 | }), 32 | ) 33 | } 34 | 35 | onClose() { 36 | const { contentEl } = this 37 | contentEl.empty() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/SelectRemoteBaseDirModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from 'obsidian' 2 | import NutstorePlugin from '..' 3 | 4 | import { mount as mountWebDAVExplorer } from 'webdav-explorer' 5 | import { getDirectoryContents } from '~/api/webdav' 6 | import { fileStatToStatModel } from '~/utils/file-stat-to-stat-model' 7 | import { mkdirsWebDAV } from '~/utils/mkdirs-webdav' 8 | import { stdRemotePath } from '~/utils/std-remote-path' 9 | 10 | export default class SelectRemoteBaseDirModal extends Modal { 11 | constructor( 12 | app: App, 13 | private plugin: NutstorePlugin, 14 | private onConfirm: (path: string) => void, 15 | ) { 16 | super(app) 17 | } 18 | 19 | async onOpen() { 20 | const { contentEl } = this 21 | 22 | const explorer = document.createElement('div') 23 | contentEl.appendChild(explorer) 24 | 25 | const webdav = await this.plugin.webDAVService.createWebDAVClient() 26 | 27 | mountWebDAVExplorer(explorer, { 28 | fs: { 29 | ls: async (target) => { 30 | const token = await this.plugin.getToken() 31 | const items = await getDirectoryContents(token, target) 32 | return items.map(fileStatToStatModel) 33 | }, 34 | mkdirs: async (path) => { 35 | await mkdirsWebDAV(webdav, path) 36 | }, 37 | }, 38 | onClose: () => { 39 | explorer.remove() 40 | this.close() 41 | }, 42 | onConfirm: async (path) => { 43 | await Promise.resolve(this.onConfirm(stdRemotePath(path))) 44 | explorer.remove() 45 | this.close() 46 | }, 47 | }) 48 | } 49 | 50 | onClose() { 51 | const { contentEl } = this 52 | contentEl.empty() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/SyncConfirmModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian' 2 | import i18n from '../i18n' 3 | import { useSettings } from '../settings' 4 | 5 | export default class SyncConfirmModal extends Modal { 6 | private onConfirm: () => void 7 | 8 | constructor(app: App, onConfirm: () => void) { 9 | super(app) 10 | this.onConfirm = onConfirm 11 | } 12 | 13 | async onOpen() { 14 | const { contentEl } = this 15 | const settings = await useSettings() 16 | 17 | contentEl.createEl('h2', { text: i18n.t('sync.confirmModal.title') }) 18 | const infoDiv = contentEl.createDiv({ cls: 'sync-info' }) 19 | infoDiv.createEl('p', { 20 | text: i18n.t('sync.confirmModal.remoteDir', { dir: settings.remoteDir }), 21 | }) 22 | infoDiv.createEl('p', { 23 | text: i18n.t('sync.confirmModal.strategy', { 24 | strategy: i18n.t( 25 | `settings.conflictStrategy.${settings.conflictStrategy === 'diff-match-patch' ? 'diffMatchPatch' : 'latestTimestamp'}`, 26 | ), 27 | }), 28 | }) 29 | contentEl.createEl('pre', { text: i18n.t('sync.confirmModal.message') }) 30 | 31 | new Setting(contentEl) 32 | .addButton((button) => 33 | button 34 | .setButtonText(i18n.t('sync.confirmModal.cancel')) 35 | .onClick(() => this.close()), 36 | ) 37 | .addButton((button) => 38 | button 39 | .setButtonText(i18n.t('sync.confirmModal.confirm')) 40 | .setCta() 41 | .onClick(() => { 42 | this.close() 43 | this.onConfirm() 44 | }), 45 | ) 46 | } 47 | 48 | onClose() { 49 | const { contentEl } = this 50 | contentEl.empty() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/SyncProgressModal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, setIcon, Setting } from 'obsidian' 2 | import { Subscription } from 'rxjs' 3 | import getTaskName from '~/utils/get-task-name' 4 | import NutstorePlugin from '..' 5 | import { emitCancelSync, onCancelSync } from '../events' 6 | import i18n from '../i18n' 7 | import ConflictResolveTask from '../sync/tasks/conflict-resolve.task' 8 | import MkdirLocalTask from '../sync/tasks/mkdir-local.task' 9 | import MkdirRemoteTask from '../sync/tasks/mkdir-remote.task' 10 | import PullTask from '../sync/tasks/pull.task' 11 | import PushTask from '../sync/tasks/push.task' 12 | import RemoveLocalTask from '../sync/tasks/remove-local.task' 13 | import RemoveRemoteTask from '../sync/tasks/remove-remote.task' 14 | 15 | export default class SyncProgressModal extends Modal { 16 | private progressBar: HTMLDivElement 17 | private progressText: HTMLDivElement 18 | private progressStats: HTMLDivElement 19 | private currentFile: HTMLDivElement 20 | private filesList: HTMLDivElement 21 | private syncCancelled = false 22 | private cancelSubscription: Subscription 23 | 24 | constructor( 25 | private plugin: NutstorePlugin, 26 | private closeCallback?: () => void, 27 | ) { 28 | super(plugin.app) 29 | this.cancelSubscription = onCancelSync().subscribe(() => { 30 | this.syncCancelled = true 31 | this.update() 32 | }) 33 | } 34 | 35 | public update(): void { 36 | if ( 37 | !this.progressBar || 38 | !this.progressText || 39 | !this.progressStats || 40 | !this.currentFile || 41 | !this.filesList 42 | ) { 43 | return 44 | } 45 | 46 | let progress = this.plugin.progressService.syncProgress 47 | 48 | const percent = 49 | Math.round((progress.completed.length / progress.total) * 100) || 0 50 | 51 | this.progressBar.style.width = `${percent}%` 52 | this.progressText.setText( 53 | i18n.t('sync.percentComplete', { 54 | percent, 55 | }), 56 | ) 57 | 58 | this.progressStats.setText( 59 | i18n.t('sync.progressStats', { 60 | completed: progress.completed.length, 61 | total: progress.total, 62 | }), 63 | ) 64 | 65 | if (progress.completed.length > 0) { 66 | if (this.plugin.progressService.syncEnd) { 67 | this.currentFile.setText(i18n.t('sync.complete')) 68 | } else if (this.syncCancelled) { 69 | this.currentFile.setText(i18n.t('sync.cancelled')) 70 | } else { 71 | const lastFile = progress.completed.at(-1) 72 | if (lastFile) { 73 | this.currentFile.setText( 74 | i18n.t('sync.currentFile', { 75 | path: lastFile.localPath, 76 | }), 77 | ) 78 | } 79 | } 80 | } 81 | 82 | this.filesList.empty() 83 | 84 | const recentFiles = progress.completed.reverse() 85 | 86 | recentFiles.forEach((file) => { 87 | const item = this.filesList.createDiv({ 88 | cls: 'flex items-center p-1 rounded text-3 gap-2 hover:bg-[var(--background-secondary)]', 89 | }) 90 | 91 | const icon = item.createSpan({ cls: 'text-[var(--text-muted)]' }) 92 | 93 | if (file instanceof PullTask) { 94 | setIcon(icon, 'arrow-down-narrow-wide') 95 | } else if (file instanceof PushTask) { 96 | setIcon(icon, 'arrow-up-narrow-wide') 97 | } else if ( 98 | file instanceof MkdirLocalTask || 99 | file instanceof MkdirRemoteTask 100 | ) { 101 | setIcon(icon, 'folder-plus') 102 | } else if ( 103 | file instanceof RemoveLocalTask || 104 | file instanceof RemoveRemoteTask 105 | ) { 106 | setIcon(icon, 'trash') 107 | } else if (file instanceof ConflictResolveTask) { 108 | setIcon(icon, 'git-merge') 109 | } else { 110 | setIcon(icon, 'arrow-left-right') 111 | } 112 | 113 | const typeLabel = item.createSpan({ 114 | cls: 'flex-none w-15 text-[var(--text-normal)] font-500', 115 | }) 116 | 117 | typeLabel.setText(getTaskName(file)) 118 | 119 | const filePath = item.createSpan({ 120 | cls: 'flex-1 truncate overflow-hidden whitespace-nowrap', 121 | }) 122 | filePath.setText( 123 | i18n.t('sync.filePath', { 124 | path: file.localPath, 125 | }), 126 | ) 127 | }) 128 | } 129 | 130 | onOpen() { 131 | const { contentEl } = this 132 | contentEl.empty() 133 | 134 | const container = contentEl.createDiv({ 135 | cls: 'flex flex-col gap-4 h-50vh max-h-50vh', 136 | }) 137 | 138 | const header = container.createDiv({ 139 | cls: 'border-b border-[var(--background-modifier-border)]', 140 | }) 141 | 142 | const title = header.createEl('h2', { 143 | cls: 'm-0', 144 | }) 145 | title.setText(i18n.t('sync.progressTitle')) 146 | 147 | const statusSection = container.createDiv({ 148 | cls: 'flex flex-col gap-1', 149 | }) 150 | 151 | const currentOperation = statusSection.createDiv() 152 | currentOperation.setText(i18n.t('sync.syncingFiles')) 153 | 154 | const currentFile = statusSection.createDiv({ 155 | cls: 'text-3 text-[var(--text-muted)] truncate overflow-hidden whitespace-nowrap', 156 | }) 157 | 158 | const progressSection = container.createDiv({ 159 | cls: 'flex flex-col gap-2', 160 | }) 161 | 162 | const progressStats = progressSection.createDiv({ 163 | cls: 'text-3.25', 164 | }) 165 | 166 | const progressBarContainer = progressSection.createDiv({ 167 | cls: 'relative h-5 bg-[var(--background-secondary)] rounded overflow-hidden', 168 | }) 169 | 170 | const progressBar = progressBarContainer.createDiv({ 171 | cls: 'absolute h-full bg-[var(--interactive-accent)] w-0 transition-width', 172 | }) 173 | 174 | const progressText = progressBarContainer.createDiv({ 175 | cls: 'absolute w-full text-center text-3 leading-5 text-[var(--text-on-accent)] mix-blend-difference', 176 | }) 177 | const filesSection = container.createDiv({ 178 | cls: 'flex flex-col flex-1 gap-2 mt-2 overflow-y-auto', 179 | }) 180 | 181 | const filesHeader = filesSection.createDiv({ 182 | cls: 'font-500 text-3.5 pb-1 border-b border-[var(--background-modifier-border)]', 183 | }) 184 | filesHeader.setText(i18n.t('sync.completedFilesTitle')) 185 | 186 | const filesList = filesSection.createDiv({ 187 | cls: 'flex-1 overflow-y-auto border border-[var(--background-modifier-border)] border-solid rounded p-1', 188 | }) 189 | 190 | this.progressBar = progressBar 191 | this.progressText = progressText 192 | this.progressStats = progressStats 193 | this.currentFile = currentFile 194 | this.filesList = filesList 195 | 196 | this.update() 197 | 198 | const footerButtons = container.createDiv({ 199 | cls: 'border-t border-[var(--background-modifier-border)]', 200 | }) 201 | 202 | new Setting(footerButtons) 203 | .addButton((button) => { 204 | button 205 | .setButtonText(i18n.t('sync.hideButton')) 206 | .onClick(() => this.close()) 207 | }) 208 | .addButton((button) => { 209 | button 210 | .setButtonText(i18n.t('sync.stopButton')) 211 | .setWarning() 212 | .onClick(() => { 213 | emitCancelSync() 214 | }) 215 | }) 216 | } 217 | 218 | onClose(): void { 219 | this.cancelSubscription.unsubscribe() 220 | const { contentEl } = this 221 | contentEl.empty() 222 | if (this.closeCallback) { 223 | this.closeCallback() 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/components/SyncRibbonManager.ts: -------------------------------------------------------------------------------- 1 | import { emitCancelSync } from '../events' 2 | import i18n from '../i18n' 3 | import type NutstorePlugin from '../index' 4 | import { NutstoreSync } from '../sync' 5 | import SyncConfirmModal from './SyncConfirmModal' 6 | 7 | export class SyncRibbonManager { 8 | private startRibbonEl: HTMLElement 9 | private stopRibbonEl: HTMLElement 10 | 11 | constructor(private plugin: NutstorePlugin) { 12 | this.startRibbonEl = this.plugin.addRibbonIcon( 13 | 'refresh-ccw', 14 | i18n.t('sync.startButton'), 15 | async () => { 16 | if (this.plugin.isSyncing) { 17 | return 18 | } 19 | const startSync = async () => { 20 | const sync = new NutstoreSync(this.plugin, { 21 | webdav: await this.plugin.webDAVService.createWebDAVClient(), 22 | vault: this.plugin.app.vault, 23 | token: await this.plugin.getToken(), 24 | remoteBaseDir: this.plugin.remoteBaseDir, 25 | }) 26 | await sync.start({ 27 | showNotice: true, 28 | }) 29 | } 30 | new SyncConfirmModal(this.plugin.app, startSync).open() 31 | }, 32 | ) 33 | this.stopRibbonEl = this.plugin.addRibbonIcon( 34 | 'square', 35 | i18n.t('sync.stopButton'), 36 | () => emitCancelSync(), 37 | ) 38 | this.stopRibbonEl.classList.add('hidden') 39 | } 40 | 41 | public update() { 42 | if (this.plugin.isSyncing) { 43 | this.startRibbonEl.setAttr('aria-disabled', 'true') 44 | this.startRibbonEl.addClass('nutstore-sync-spinning') 45 | this.stopRibbonEl.classList.remove('hidden') 46 | } else { 47 | this.startRibbonEl.removeAttribute('aria-disabled') 48 | this.startRibbonEl.removeClass('nutstore-sync-spinning') 49 | this.stopRibbonEl.classList.add('hidden') 50 | } 51 | } 52 | 53 | public unload() { 54 | if (this.startRibbonEl) { 55 | this.startRibbonEl.remove() 56 | } 57 | 58 | if (this.stopRibbonEl) { 59 | this.stopRibbonEl.remove() 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/TaskListConfirmModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian' 2 | import i18n from '~/i18n' 3 | import getTaskName from '~/utils/get-task-name' 4 | import { BaseTask } from '../sync/tasks/task.interface' 5 | 6 | export default class TaskListConfirmModal extends Modal { 7 | private result: boolean = false 8 | private selectedTasks: boolean[] = [] 9 | 10 | constructor( 11 | app: App, 12 | private tasks: BaseTask[], 13 | ) { 14 | super(app) 15 | this.selectedTasks = new Array(tasks.length).fill(true) 16 | } 17 | 18 | onOpen() { 19 | this.setTitle(i18n.t('taskList.title')) 20 | 21 | const { contentEl } = this 22 | contentEl.empty() 23 | 24 | const instruction = contentEl.createEl('p') 25 | instruction.setText(i18n.t('taskList.instruction')) 26 | 27 | const tableContainer = contentEl.createDiv({ 28 | cls: 'max-h-50vh overflow-y-auto', 29 | }) 30 | const table = tableContainer.createEl('table', { cls: 'task-list-table' }) 31 | 32 | const thead = table.createEl('thead') 33 | const headerRow = thead.createEl('tr') 34 | headerRow.createEl('th', { text: i18n.t('taskList.execute') }) 35 | headerRow.createEl('th', { text: i18n.t('taskList.action') }) 36 | headerRow.createEl('th', { text: i18n.t('taskList.localPath') }) 37 | headerRow.createEl('th', { text: i18n.t('taskList.remotePath') }) 38 | 39 | const tbody = table.createEl('tbody') 40 | this.tasks.forEach((task, index) => { 41 | const row = tbody.createEl('tr') 42 | const checkboxCell = row.createEl('td') 43 | const checkbox = checkboxCell.createEl('input') 44 | checkbox.type = 'checkbox' 45 | checkbox.checked = this.selectedTasks[index] 46 | checkbox.addEventListener('change', (e) => { 47 | this.selectedTasks[index] = checkbox.checked 48 | e.stopPropagation() 49 | }) 50 | row.addEventListener('click', (e) => { 51 | if (e.target === checkbox) { 52 | return 53 | } 54 | checkbox.checked = !checkbox.checked 55 | this.selectedTasks[index] = checkbox.checked 56 | e.stopPropagation() 57 | }) 58 | row.createEl('td', { text: getTaskName(task) }) 59 | row.createEl('td', { text: task.localPath }) 60 | row.createEl('td', { text: task.remotePath }) 61 | }) 62 | 63 | new Setting(contentEl) 64 | .addButton((button) => { 65 | button 66 | .setButtonText(i18n.t('taskList.continue')) 67 | .setCta() 68 | .onClick(() => { 69 | this.result = true 70 | this.close() 71 | }) 72 | }) 73 | .addButton((button) => { 74 | button.setButtonText(i18n.t('taskList.cancel')).onClick(() => { 75 | this.result = false 76 | this.close() 77 | }) 78 | }) 79 | } 80 | 81 | async open(): Promise<{ confirm: boolean; tasks: BaseTask[] }> { 82 | super.open() 83 | return new Promise((resolve) => { 84 | this.onClose = () => { 85 | const selectedTasks = this.tasks.filter( 86 | (_, index) => this.selectedTasks[index], 87 | ) 88 | resolve({ 89 | confirm: this.result, 90 | tasks: selectedTasks, 91 | }) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/components/TextAreaModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Notice, Setting } from 'obsidian' 2 | import i18n from '~/i18n' 3 | 4 | export default class TextAreaModal extends Modal { 5 | constructor( 6 | app: App, 7 | private text: string, 8 | ) { 9 | super(app) 10 | } 11 | 12 | onOpen() { 13 | const { contentEl } = this 14 | 15 | const textarea = contentEl.createEl('textarea', { 16 | cls: 'w-full h-50vh', 17 | text: this.text, 18 | }) 19 | textarea.disabled = true 20 | 21 | new Setting(contentEl) 22 | .addButton((button) => { 23 | button 24 | .setCta() 25 | .setButtonText(i18n.t('textAreaModal.copy')) 26 | .onClick(() => { 27 | navigator.clipboard.writeText(this.text).then(() => { 28 | new Notice(i18n.t('textAreaModal.copied')) 29 | }) 30 | }) 31 | }) 32 | .addButton((button) => { 33 | button.setButtonText(i18n.t('textAreaModal.close')).onClick(() => { 34 | this.close() 35 | }) 36 | }) 37 | } 38 | 39 | onClose() { 40 | const { contentEl } = this 41 | contentEl.empty() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | import { Platform, requireApiVersion } from 'obsidian' 2 | 3 | export const NS_NSDAV_ENDPOINT = process.env.NS_NSDAV_ENDPOINT! 4 | export const NS_DAV_ENDPOINT = process.env.NS_DAV_ENDPOINT! 5 | 6 | export const API_VER_STAT_FOLDER = '0.13.27' 7 | export const API_VER_REQURL = '0.13.26' // desktop ver 0.13.26, iOS ver 1.1.1 8 | export const API_VER_REQURL_ANDROID = '0.14.6' // Android ver 1.2.1 9 | export const API_VER_ENSURE_REQURL_OK = '1.0.0' // always bypass CORS here 10 | 11 | export const VALID_REQURL = 12 | (!Platform.isAndroidApp && requireApiVersion(API_VER_REQURL)) || 13 | (Platform.isAndroidApp && requireApiVersion(API_VER_REQURL_ANDROID)) 14 | 15 | export const IN_DEV = process.env.NODE_ENV === 'development' 16 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sync-cancel' 2 | export * from './sync-end' 3 | export * from './sync-error' 4 | export * from './sync-progress' 5 | export * from './sync-start' 6 | export * from './vault' 7 | -------------------------------------------------------------------------------- /src/events/sso-receive.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | 3 | interface SsoRxProps { 4 | token: string 5 | } 6 | 7 | const ssoReceive = new Subject() 8 | 9 | export const onSsoReceive = () => ssoReceive.asObservable() 10 | export const emitSsoReceive = (props: SsoRxProps) => ssoReceive.next(props) 11 | -------------------------------------------------------------------------------- /src/events/sync-cancel.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | 3 | const cancelSync = new Subject() 4 | 5 | export const onCancelSync = () => cancelSync.asObservable() 6 | export const emitCancelSync = () => cancelSync.next() 7 | -------------------------------------------------------------------------------- /src/events/sync-end.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | 3 | interface SyncEndProps { 4 | showNotice: boolean 5 | failedCount: number 6 | } 7 | 8 | const endSync = new Subject() 9 | 10 | export const onEndSync = () => endSync.asObservable() 11 | export const emitEndSync = (props: SyncEndProps) => endSync.next(props) 12 | -------------------------------------------------------------------------------- /src/events/sync-error.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | 3 | const syncError = new Subject() 4 | 5 | export const onSyncError = () => syncError.asObservable() 6 | export const emitSyncError = (error: Error) => syncError.next(error) 7 | -------------------------------------------------------------------------------- /src/events/sync-progress.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | import { BaseTask } from '~/sync/tasks/task.interface' 3 | 4 | export interface UpdateSyncProgress { 5 | total: number 6 | completed: BaseTask[] 7 | } 8 | 9 | const syncProgress = new Subject() 10 | 11 | export const onSyncProgress = () => syncProgress.asObservable() 12 | 13 | export const emitSyncProgress = (total: number, completed: BaseTask[]) => 14 | syncProgress.next({ 15 | total, 16 | completed, 17 | }) 18 | -------------------------------------------------------------------------------- /src/events/sync-start.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | 3 | interface SyncStartProps { 4 | showNotice: boolean 5 | } 6 | 7 | const startSync = new Subject() 8 | 9 | export const onStartSync = () => startSync.asObservable() 10 | export const emitStartSync = (props: SyncStartProps) => startSync.next(props) 11 | -------------------------------------------------------------------------------- /src/events/vault.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | 3 | interface VaultEventProps { 4 | type: string 5 | } 6 | 7 | const vaultEvent = new Subject() 8 | 9 | export const onVaultEvent = () => vaultEvent.asObservable() 10 | export const emitVaultEvent = (props: VaultEventProps) => vaultEvent.next(props) 11 | -------------------------------------------------------------------------------- /src/fs/fs.interface.ts: -------------------------------------------------------------------------------- 1 | import { StatModel } from '~/model/stat.model' 2 | import { MaybePromise } from '~/utils/types' 3 | 4 | export default abstract class AbstractFileSystem { 5 | abstract walk(): MaybePromise 6 | } 7 | -------------------------------------------------------------------------------- /src/fs/local-vault.ts: -------------------------------------------------------------------------------- 1 | import { Vault } from 'obsidian' 2 | import { useSettings } from '~/settings' 3 | import { SyncRecord } from '~/storage/helper' 4 | import GlobMatch, { 5 | extendRules, 6 | isVoidGlobMatchOptions, 7 | needIncludeFromGlobRules, 8 | } from '~/utils/glob-match' 9 | import { traverseLocalVault } from '~/utils/traverse-local-vault' 10 | import AbstractFileSystem from './fs.interface' 11 | import completeLossDir from './utils/complete-loss-dir' 12 | 13 | export class LocalVaultFileSystem implements AbstractFileSystem { 14 | constructor( 15 | private readonly options: { 16 | vault: Vault 17 | syncRecord: SyncRecord 18 | }, 19 | ) {} 20 | 21 | async walk() { 22 | const settings = await useSettings() 23 | const exclusions = extendRules( 24 | (settings?.filterRules.exclusionRules ?? []) 25 | .filter((opt) => !isVoidGlobMatchOptions(opt)) 26 | .map(({ expr, options }) => new GlobMatch(expr, options)), 27 | ) 28 | const inclusion = extendRules( 29 | (settings?.filterRules.inclusionRules ?? []) 30 | .filter((opt) => !isVoidGlobMatchOptions(opt)) 31 | .map(({ expr, options }) => new GlobMatch(expr, options)), 32 | ) 33 | const stats = await traverseLocalVault( 34 | this.options.vault, 35 | this.options.vault.getRoot().path, 36 | ) 37 | const filteredStats = stats.filter((s) => 38 | needIncludeFromGlobRules(s.path, inclusion, exclusions), 39 | ) 40 | return completeLossDir(stats, filteredStats) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/fs/nutstore.ts: -------------------------------------------------------------------------------- 1 | import { decode as decodeHtmlEntity } from 'html-entities' 2 | import { isArray } from 'lodash-es' 3 | import { Vault } from 'obsidian' 4 | import { basename, isAbsolute } from 'path' 5 | import { isNotNil } from 'ramda' 6 | import { createClient, WebDAVClient } from 'webdav' 7 | import { getDelta } from '~/api/delta' 8 | import { getLatestDeltaCursor } from '~/api/latestDeltaCursor' 9 | import { NS_DAV_ENDPOINT } from '~/consts' 10 | import { StatModel } from '~/model/stat.model' 11 | import { useSettings } from '~/settings' 12 | import { deltaCacheKV } from '~/storage' 13 | import { getDBKey } from '~/utils/get-db-key' 14 | import { getRootFolderName } from '~/utils/get-root-folder-name' 15 | import GlobMatch, { 16 | extendRules, 17 | isVoidGlobMatchOptions, 18 | needIncludeFromGlobRules, 19 | } from '~/utils/glob-match' 20 | import { isSub } from '~/utils/is-sub' 21 | import { stdRemotePath } from '~/utils/std-remote-path' 22 | import { traverseWebDAV } from '~/utils/traverse-webdav' 23 | import AbstractFileSystem from './fs.interface' 24 | import completeLossDir from './utils/complete-loss-dir' 25 | 26 | export class NutstoreFileSystem implements AbstractFileSystem { 27 | private webdav: WebDAVClient 28 | 29 | constructor( 30 | private options: { 31 | vault: Vault 32 | token: string 33 | remoteBaseDir: string 34 | }, 35 | ) { 36 | this.webdav = createClient(NS_DAV_ENDPOINT, { 37 | headers: { 38 | Authorization: `Basic ${this.options.token}`, 39 | }, 40 | }) 41 | } 42 | 43 | async walk() { 44 | const kvKey = getDBKey( 45 | this.options.vault.getName(), 46 | this.options.remoteBaseDir, 47 | ) 48 | let deltaCache = await deltaCacheKV.get(kvKey) 49 | if (deltaCache) { 50 | let cursor = deltaCache.deltas.at(-1)?.cursor ?? deltaCache.originCursor 51 | while (true) { 52 | const { response } = await getDelta({ 53 | token: this.options.token, 54 | cursor, 55 | folderName: getRootFolderName(this.options.remoteBaseDir), 56 | }) 57 | if (response.cursor === cursor) { 58 | break 59 | } 60 | if (response.reset) { 61 | deltaCache.deltas = [] 62 | deltaCache.files = await traverseWebDAV( 63 | this.options.token, 64 | this.options.remoteBaseDir, 65 | ) 66 | cursor = await getLatestDeltaCursor({ 67 | token: this.options.token, 68 | folderName: getRootFolderName(this.options.remoteBaseDir), 69 | }).then((d) => d?.response?.cursor) 70 | deltaCache.originCursor = cursor 71 | } else if (response.delta.entry) { 72 | if (!isArray(response.delta.entry)) { 73 | response.delta.entry = [response.delta.entry] 74 | } 75 | if (response.delta.entry.length > 0) { 76 | deltaCache.deltas.push(response) 77 | } 78 | if (response.hasMore) { 79 | cursor = response.cursor 80 | } else { 81 | break 82 | } 83 | } else { 84 | break 85 | } 86 | } 87 | } else { 88 | const files = await traverseWebDAV( 89 | this.options.token, 90 | this.options.remoteBaseDir, 91 | ) 92 | const { 93 | response: { cursor: originCursor }, 94 | } = await getLatestDeltaCursor({ 95 | token: this.options.token, 96 | folderName: getRootFolderName(this.options.remoteBaseDir), 97 | }) 98 | deltaCache = { 99 | files, 100 | originCursor, 101 | deltas: [], 102 | } 103 | } 104 | await deltaCacheKV.set(kvKey, deltaCache) 105 | deltaCache.deltas.forEach((delta) => { 106 | delta.delta.entry.forEach((entry) => { 107 | entry.path = decodeHtmlEntity(entry.path) 108 | }) 109 | }) 110 | deltaCache.files.forEach((file) => { 111 | file.path = decodeHtmlEntity(file.path) 112 | }) 113 | const deltasMap = new Map( 114 | deltaCache.deltas.flatMap((d) => d.delta.entry.map((d) => [d.path, d])), 115 | ) 116 | const filesMap = new Map( 117 | deltaCache.files.map((d) => [d.path, d]), 118 | ) 119 | for (const delta of deltasMap.values()) { 120 | if (delta.isDeleted) { 121 | filesMap.delete(delta.path) 122 | continue 123 | } 124 | filesMap.set(delta.path, { 125 | path: delta.path, 126 | basename: basename(delta.path), 127 | isDir: delta.isDir, 128 | isDeleted: delta.isDeleted, 129 | mtime: new Date(delta.modified).valueOf(), 130 | size: delta.size, 131 | }) 132 | } 133 | const stats = Array.from(filesMap.values()) 134 | if (stats.length === 0) { 135 | return [] 136 | } 137 | const base = stdRemotePath(this.options.remoteBaseDir) 138 | const subPath = new Set() 139 | for (let { path } of stats) { 140 | if (path.endsWith('/')) { 141 | path = path.slice(0, path.length - 1) 142 | } 143 | if (!path.startsWith('/')) { 144 | path = `/${path}` 145 | } 146 | if (isSub(base, path)) { 147 | subPath.add(path) 148 | } 149 | } 150 | const contents = [...subPath] 151 | .map((path) => filesMap.get(path)) 152 | .filter(isNotNil) 153 | for (const item of contents) { 154 | if (isAbsolute(item.path)) { 155 | item.path = item.path.replace(this.options.remoteBaseDir, '') 156 | if (item.path.startsWith('/')) { 157 | item.path = item.path.slice(1) 158 | } 159 | } 160 | } 161 | const settings = await useSettings() 162 | const exclusions = extendRules( 163 | (settings?.filterRules.exclusionRules ?? []) 164 | .filter((opt) => !isVoidGlobMatchOptions(opt)) 165 | .map(({ expr, options }) => new GlobMatch(expr, options)), 166 | ) 167 | const inclusion = extendRules( 168 | (settings?.filterRules.inclusionRules ?? []) 169 | .filter((opt) => !isVoidGlobMatchOptions(opt)) 170 | .map(({ expr, options }) => new GlobMatch(expr, options)), 171 | ) 172 | const filteredContents = contents.filter((item) => 173 | needIncludeFromGlobRules(item.path, inclusion, exclusions), 174 | ) 175 | return completeLossDir(contents, filteredContents) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/fs/utils/complete-loss-dir.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path' 2 | import { StatModel } from '~/model/stat.model' 3 | import isRoot from './is-root' 4 | 5 | /** 6 | * 经过 inclusion 和 exclusion 之后, 7 | * 有些符合规则的文件保留了下来, 8 | * 但是他们的父文件夹可能丢失了,需要补全 9 | */ 10 | export default function completeLossDir( 11 | stats: StatModel[], 12 | _filteredStats: StatModel[], 13 | ) { 14 | const filteredStats = new Set(_filteredStats) 15 | const statsMap = new Map(stats.map((d) => [d.path, d])) 16 | const filteredFolderMap = new Map( 17 | [...filteredStats].filter((d) => d.isDir).map((d) => [d.path, d]), 18 | ) 19 | for (let { path } of _filteredStats) { 20 | while (true) { 21 | path = dirname(path) 22 | if (isRoot(path)) { 23 | break 24 | } 25 | if (filteredFolderMap.has(path)) { 26 | continue 27 | } 28 | const dirStat = statsMap.get(path) 29 | if (!dirStat) { 30 | continue 31 | } 32 | filteredFolderMap.set(path, dirStat) 33 | filteredStats.add(dirStat) 34 | } 35 | } 36 | return [...filteredStats] 37 | } 38 | -------------------------------------------------------------------------------- /src/fs/utils/is-root.ts: -------------------------------------------------------------------------------- 1 | export default function isRoot(path: string) { 2 | return path === '/' || path === '.' || path === '' 3 | } 4 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next' 2 | import en from './locales/en' 3 | import zh from './locales/zh' 4 | 5 | const defaultNS = 'translation' 6 | const resources = { 7 | zh: { 8 | translation: zh, 9 | }, 10 | en: { 11 | translation: en, 12 | }, 13 | } as const 14 | 15 | declare module 'i18next' { 16 | interface CustomTypeOptions { 17 | defaultNS: 'translation' 18 | resources: (typeof resources)['en'] 19 | } 20 | } 21 | 22 | i18n.init({ 23 | ns: ['translation'], 24 | defaultNS, 25 | resources, 26 | fallbackLng: 'en', 27 | interpolation: { 28 | escapeValue: false, 29 | }, 30 | }) 31 | 32 | export default i18n 33 | -------------------------------------------------------------------------------- /src/i18n/locales/zh.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | settings: { 3 | title: 'WebDAV 设置', 4 | account: { 5 | name: '账号', 6 | desc: '输入你的 WebDAV 账号', 7 | placeholder: '输入你的账号', 8 | }, 9 | credential: { 10 | name: '凭证', 11 | desc: '输入你的 WebDAV 凭证', 12 | placeholder: '输入你的凭证', 13 | }, 14 | remoteDir: { 15 | name: '远程目录', 16 | desc: '输入远程目录', 17 | placeholder: '输入远程目录', 18 | edit: '编辑', 19 | }, 20 | checkConnection: { 21 | name: '检查连接', 22 | desc: '点击检查 WebDAV 连接', 23 | success: 'WebDAV 连接成功', 24 | failure: 'WebDAV 连接失败', 25 | successButton: '连接成功 ✓', 26 | failureButton: '连接失败 ×', 27 | }, 28 | login: { 29 | name: '登录', 30 | desc: '点击登录', 31 | success: '登录成功', 32 | failure: '登录失败,请重试', 33 | }, 34 | useGitStyle: { 35 | name: '使用Git样式的冲突标记', 36 | desc: '启用后将使用 <<<<<<< 和 >>>>>>> 等标记来显示冲突,而不是HTML标记', 37 | }, 38 | backupWarning: { 39 | name: '备份提醒', 40 | desc: '⚠️ 请注意:同步过程会修改或删除本地文件,建议在同步前备份重要文件。', 41 | }, 42 | conflictStrategy: { 43 | name: '冲突解决策略', 44 | desc: '选择解决文件冲突的方式。\n注意:建议在使用自动合并功能前,先手动备份重要文件,以防数据丢失。', 45 | diffMatchPatch: '智能合并(推荐)', 46 | latestTimestamp: '使用最新版本', 47 | }, 48 | loginMode: { 49 | name: '登录方式', 50 | manual: '手动输入', 51 | sso: '单点登录', 52 | }, 53 | ssoStatus: { 54 | loggedIn: '已登录', 55 | notLoggedIn: '未登录', 56 | logout: '退出登录', 57 | logoutSuccess: '已退出登录', 58 | }, 59 | logout: { 60 | confirmTitle: '确认退出', 61 | confirmMessage: '确定要退出登录吗?退出后需要重新登录才能继续同步。', 62 | confirm: '确认退出', 63 | cancel: '取消', 64 | }, 65 | help: { 66 | name: '如何获取 WebDAV 账号和凭证?', 67 | desc: '点击查看帮助文档', 68 | }, 69 | sections: { 70 | account: '账号设置', 71 | common: '通用设置', 72 | filters: '过滤规则', 73 | }, 74 | confirmBeforeSync: { 75 | name: '同步前确认', 76 | desc: '同步前显示待执行的任务列表,确认后再执行', 77 | }, 78 | realtimeSync: { 79 | name: '实时同步', 80 | desc: '文件修改后自动进行同步', 81 | }, 82 | syncMode: { 83 | name: '同步模式', 84 | desc: '建议在文件较多的情况下选择宽松模式,可以获得更快的同步速度。宽松模式在首次同步时会忽略同名且大小相等的文件。', 85 | strict: '严格', 86 | loose: '宽松', 87 | }, 88 | startupSyncDelay: { 89 | name: '启动后自动同步', 90 | desc: '设置启动后第几秒自动执行一次同步。设置为 0 则禁用启动时自动同步。', 91 | placeholder: '输入秒数 (例如 5, 0 则禁用)', 92 | }, 93 | filters: { 94 | name: '过滤器', 95 | desc: '添加同步时需要忽略文件或文件夹路径,例如: .DS_Store, .git', 96 | add: '添加规则', 97 | remove: '删除', 98 | confirmRemove: '确认删除', 99 | edit: '编辑规则', 100 | save: '保存', 101 | cancel: '取消', 102 | placeholder: '例如: .DS_Store, *.pdf', 103 | description: 104 | '符合这些规则的文件或文件夹在同步时会被忽略。使用 * 作为通配符。', 105 | exclude: { 106 | name: '排除规则', 107 | desc: '符合规则的文件/文件夹将不会被同步', 108 | }, 109 | include: { 110 | name: '包含规则', 111 | desc: '符合规则的文件/文件夹会被同步, 如果和排除规则有冲突, 会优先选择包含规则.', 112 | }, 113 | }, 114 | skipLargeFiles: { 115 | name: '跳过大文件', 116 | desc: '同步时将跳过超过此大小的文件。如遇同步崩溃,可尝试降低此值。留空表示不限制。', 117 | placeholder: '例如:10 MiB 或 500 KiB', 118 | }, 119 | log: { 120 | title: '调试日志', 121 | name: '控制台日志', 122 | desc: '查看控制台输出和调试信息', 123 | button: '查看', 124 | clear: '清除', 125 | cleared: '控制台日志已清除', 126 | clearName: '清除日志', 127 | clearDesc: '删除所有控制台输出记录', 128 | }, 129 | cache: { 130 | title: '缓存管理', 131 | dumpName: '缓存管理', 132 | dumpDesc: 133 | '插件会在您的设备上保存远程文件夹的目录信息。当您更换设备时,这些信息会丢失,需要在首次同步前重新获取。如果您的文件数量较多,可能会触发坚果云的访问频率限制,导致获取过程变慢。导出功能可将这些信息保存到坚果云,导入功能则可将数据恢复到新设备,使您无需等待即可直接同步。', 134 | dump: '导出', 135 | restoreName: '导入缓存', 136 | restoreDesc: 137 | '从坚果云导入之前导出的缓存数据到当前设备。这样可以避免在新设备上等待漫长的文件扫描过程,让您直接进行同步。', 138 | restore: '导入', 139 | clearName: '清除本地缓存', 140 | clearDesc: 141 | '删除当前设备上的本地缓存数据。此操作无法撤销,会导致下次同步前需要重新建立缓存。仅影响当前设备,不会影响您在坚果云中的数据。', 142 | clear: '清除', 143 | confirm: '确认清除', 144 | cleared: '缓存已成功清除', 145 | clearModal: { 146 | title: '清除缓存', 147 | description: '选择需要清除的缓存类型。此操作无法撤销。', 148 | cancel: '取消', 149 | confirm: '确认清除', 150 | deltaCache: { 151 | name: '增量同步缓存', 152 | desc: '存储文件增量变化的相关信息。', 153 | }, 154 | syncRecordCache: { 155 | name: '同步记录缓存', 156 | desc: '跟踪每个文件的同步状态。', 157 | }, 158 | blobCache: { 159 | name: '文件快照缓存', 160 | desc: '存储用于同步时对比变化的文件快照。', 161 | }, 162 | clearedType: '已清除: {{types}}', 163 | nothingSelected: '请至少选择一种缓存类型进行清除。', 164 | }, 165 | exportSuccess: '缓存已成功保存到插件数据文件', 166 | exportError: '保存缓存出错: {{message}}', 167 | noDataToRestore: '未找到已保存的缓存数据', 168 | restoreSuccess: '缓存恢复成功', 169 | restoreError: '恢复缓存出错: {{message}}', 170 | remoteCacheDir: { 171 | name: '远程缓存目录', 172 | desc: '输入用于存储缓存的远程目录', 173 | placeholder: '输入远程缓存目录', 174 | edit: '编辑', 175 | }, 176 | saveModal: { 177 | title: '保存缓存', 178 | description: 179 | '输入文件名以保存当前缓存状态。文件将保存在远程缓存目录中。', 180 | filename: '文件名', 181 | save: '保存', 182 | cancel: '取消', 183 | success: '缓存保存成功', 184 | error: '保存缓存出错: {{message}}', 185 | }, 186 | restoreModal: { 187 | title: '恢复缓存', 188 | description: '选择要恢复或删除的缓存文件。', 189 | restore: '恢复', 190 | delete: '删除', 191 | deleteConfirm: '确认删除', 192 | close: '关闭', 193 | refresh: '刷新', 194 | noFiles: '未找到缓存文件', 195 | fileNotFound: '未找到缓存文件', 196 | success: '缓存恢复成功', 197 | error: '恢复缓存出错: {{message}}', 198 | loadError: '加载文件列表出错: {{message}}', 199 | deleteSuccess: '缓存文件删除成功', 200 | deleteError: '删除缓存文件出错: {{message}}', 201 | }, 202 | }, 203 | }, 204 | sync: { 205 | failed: '同步失败!', 206 | error: { 207 | folderButFile: '预期文件夹但发现文件: {{path}}', 208 | notFound: '未找到: {{path}}', 209 | localPathNotFound: '本地路径未找到: {{path}}', 210 | cannotMergeBinary: '无法合并二进制文件', 211 | failedToAutoMerge: '自动合并失败', 212 | failedToUploadMerged: '上传合并内容失败', 213 | conflictsMarkedInFile: '发现冲突,已在文件中标记', 214 | requestsTooFrequent: '请求过于频繁,请等待几分钟后再试', 215 | }, 216 | requestsTooFrequent: '请求过于频繁,插件将在 {{time}} 后自动继续同步任务', 217 | start: '⌛️ 同步开始', 218 | complete: '✅ 同步完成', 219 | completeWithFailed: '❌ 同步完成,但有 {{failedCount}} 个任务失败', 220 | failedWithError: '同步失败,错误信息: {{error}}', 221 | progress: '同步进度: {{percent}}%', 222 | startButton: '开始同步', 223 | stopButton: '停止同步', 224 | hideButton: '隐藏', 225 | showProgressButton: '显示同步进度', 226 | notSyncing: '尚未开始同步', 227 | percentComplete: '{{percent}}%', 228 | currentFile: '{{path}}', 229 | filePath: '{{path}}', 230 | progressTitle: '同步进度', 231 | progressStats: '已完成: {{completed}} / {{total}} 个任务', 232 | completedFilesTitle: '已完成的任务', 233 | syncingFiles: '正在同步文件...', 234 | failedStatus: '同步失败', 235 | cancelled: '同步已取消', 236 | suggestUseClientForManyTasks: 237 | 'Tips: 当同步任务较多时,建议使用坚果云客户端同步,可获得更好的性能和稳定性,插件更适合在移动端使用!', 238 | modalTitle: '同步进行中', 239 | cancelButton: '取消同步', 240 | progressText: '正在同步文件', 241 | fileOp: { 242 | createLocalDir: '创建本地目录', 243 | createRemoteDir: '创建远程目录', 244 | download: '下载', 245 | filenameError: '路径含无效字符', 246 | merge: '合并', 247 | removeLocal: '删除本地', 248 | removeRemote: '删除远程', 249 | sync: '同步', 250 | upload: '上传', 251 | }, 252 | confirmModal: { 253 | title: '同步确认', 254 | message: 255 | '⚠️ 请注意:\n\n1. 同步操作可能会修改或删除本地文件\n2. 建议在同步前手动备份重要文件\n3. 如果出现文件冲突,可能需要手动解决\n4. 首次同步需要处理所有文件,可能会比较慢,请耐心等待\n\n确定要开始同步吗?', 256 | confirm: '确认同步', 257 | cancel: '取消', 258 | remoteDir: '远程目录:{{dir}}', 259 | strategy: '同步策略:{{strategy}}', 260 | }, 261 | }, 262 | taskList: { 263 | title: '同步任务列表', 264 | instruction: 265 | '请检查以下待执行的任务。点击"继续"将执行选中的任务,点击"取消"则跳过本次同步。', 266 | execute: '执行', 267 | action: '行为', 268 | localPath: '本地路径', 269 | remotePath: '远程路径', 270 | continue: '继续', 271 | cancel: '取消', 272 | }, 273 | textAreaModal: { 274 | copy: '复制', 275 | close: '关闭', 276 | copied: '文本已复制到剪贴板', 277 | }, 278 | } 279 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/actual/array' 2 | import 'core-js/actual/string/pad-start' 3 | import 'core-js/actual/string/replace-all' 4 | import './assets/styles/global.css' 5 | import './polyfill' 6 | import './webdav-patch' 7 | 8 | import { toBase64 } from 'js-base64' 9 | import { normalizePath, Notice, Plugin } from 'obsidian' 10 | import { join } from 'path' 11 | import { SyncRibbonManager } from './components/SyncRibbonManager' 12 | import { emitCancelSync } from './events' 13 | import { emitSsoReceive } from './events/sso-receive' 14 | import i18n from './i18n' 15 | import CommandService from './services/command.service' 16 | import EventsService from './services/events.service' 17 | import I18nService from './services/i18n.service' 18 | import LoggerService from './services/logger.service' 19 | import { ProgressService } from './services/progress.service' 20 | import RealtimeSyncService from './services/realtime-sync.service' 21 | import { StatusService } from './services/status.service' 22 | import { WebDAVService } from './services/webdav.service' 23 | import { 24 | NutstoreSettings, 25 | NutstoreSettingTab, 26 | setPluginInstance, 27 | SyncMode, 28 | } from './settings' 29 | import { decryptOAuthResponse } from './utils/decrypt-ticket-response' 30 | import { GlobMatchOptions } from './utils/glob-match' 31 | import { stdRemotePath } from './utils/std-remote-path' 32 | 33 | export default class NutstorePlugin extends Plugin { 34 | public isSyncing: boolean = false 35 | public settings: NutstoreSettings 36 | 37 | public commandService = new CommandService(this) 38 | public eventsService = new EventsService(this) 39 | public i18nService = new I18nService(this) 40 | public loggerService = new LoggerService(this) 41 | public progressService = new ProgressService(this) 42 | public ribbonManager = new SyncRibbonManager(this) 43 | public statusService = new StatusService(this) 44 | public webDAVService = new WebDAVService(this) 45 | public realtimeSyncService = new RealtimeSyncService(this) 46 | 47 | async onload() { 48 | await this.loadSettings() 49 | this.addSettingTab(new NutstoreSettingTab(this.app, this)) 50 | 51 | this.registerObsidianProtocolHandler('nutstore-sync/sso', async (data) => { 52 | if (data?.s) { 53 | this.settings.oauthResponseText = data.s 54 | await this.saveSettings() 55 | new Notice(i18n.t('settings.login.success'), 5000) 56 | } 57 | emitSsoReceive({ 58 | token: data?.s, 59 | }) 60 | }) 61 | setPluginInstance(this) 62 | } 63 | 64 | async onunload() { 65 | setPluginInstance(null) 66 | emitCancelSync() 67 | this.ribbonManager.unload() 68 | this.progressService.unload() 69 | this.eventsService.unload() 70 | } 71 | 72 | async loadSettings() { 73 | function createGlobMathOptions(expr: string) { 74 | return { 75 | expr, 76 | options: { 77 | caseSensitive: false, 78 | }, 79 | } satisfies GlobMatchOptions 80 | } 81 | const DEFAULT_SETTINGS: NutstoreSettings = { 82 | account: '', 83 | credential: '', 84 | remoteDir: '', 85 | remoteCacheDir: '', 86 | useGitStyle: false, 87 | conflictStrategy: 'diff-match-patch', 88 | oauthResponseText: '', 89 | loginMode: 'sso', 90 | confirmBeforeSync: true, 91 | syncMode: SyncMode.LOOSE, 92 | filterRules: { 93 | exclusionRules: [ 94 | '.git', 95 | '.DS_Store', 96 | '.trash', 97 | `${this.app.vault.configDir}`, 98 | ].map(createGlobMathOptions), 99 | inclusionRules: [ 100 | normalizePath(join(this.app.vault.configDir, 'bookmarks.json')), 101 | ].map(createGlobMathOptions), 102 | }, 103 | skipLargeFiles: { 104 | maxSize: '30 MB', 105 | }, 106 | realtimeSync: false, 107 | startupSyncDelaySeconds: 0, 108 | } 109 | 110 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) 111 | } 112 | 113 | async saveSettings() { 114 | await this.saveData(this.settings) 115 | } 116 | 117 | toggleSyncUI(isSyncing: boolean) { 118 | this.isSyncing = isSyncing 119 | this.ribbonManager.update() 120 | } 121 | 122 | async getDecryptedOAuthInfo() { 123 | return decryptOAuthResponse(this.settings.oauthResponseText) 124 | } 125 | 126 | async getToken() { 127 | let token 128 | if (this.settings.loginMode === 'sso') { 129 | const oauth = await this.getDecryptedOAuthInfo() 130 | token = `${oauth.username}:${oauth.access_token}` 131 | } else { 132 | token = `${this.settings.account}:${this.settings.credential}` 133 | } 134 | return toBase64(token) 135 | } 136 | 137 | get remoteBaseDir() { 138 | let remoteDir = normalizePath(this.settings.remoteDir.trim()) 139 | if (remoteDir === '' || remoteDir === '/') { 140 | remoteDir = this.app.vault.getName() 141 | } 142 | return stdRemotePath(remoteDir) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/model/stat.model.ts: -------------------------------------------------------------------------------- 1 | export interface StatModel { 2 | path: string 3 | basename: string 4 | isDir: boolean 5 | isDeleted: boolean 6 | mtime: number 7 | size: number 8 | } 9 | -------------------------------------------------------------------------------- /src/model/sync-record.model.ts: -------------------------------------------------------------------------------- 1 | import { StatModel } from './stat.model' 2 | 3 | export interface SyncRecordModel { 4 | local: StatModel 5 | remote: StatModel 6 | base?: { 7 | key: string 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/polyfill.ts: -------------------------------------------------------------------------------- 1 | const _process = globalThis.process ?? { 2 | cwd() { 3 | return '/' 4 | }, 5 | } 6 | 7 | globalThis.process = _process 8 | -------------------------------------------------------------------------------- /src/services/cache.service.v1.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian' 2 | import { deflate, inflate } from 'pako' 3 | import { join } from 'path' 4 | import superjson from 'superjson' 5 | import { BufferLike } from 'webdav' 6 | import { getDirectoryContents } from '~/api/webdav' 7 | import i18n from '~/i18n' 8 | import { ExportedStorage } from '~/settings/cache' 9 | import { deltaCacheKV } from '~/storage/kv' 10 | import { fileStatToStatModel } from '~/utils/file-stat-to-stat-model' 11 | import { getDBKey } from '~/utils/get-db-key' 12 | import logger from '~/utils/logger' 13 | import type NutstorePlugin from '..' 14 | 15 | /** 16 | * Service for handling cache operations (save, restore, delete, list) 17 | */ 18 | export default class CacheServiceV1 { 19 | constructor( 20 | private plugin: NutstorePlugin, 21 | private remoteCacheDir: string, 22 | ) {} 23 | 24 | get key() { 25 | const kvKey = getDBKey( 26 | this.plugin.app.vault.getName(), 27 | this.plugin.settings.remoteDir, 28 | ) 29 | return kvKey 30 | } 31 | /** 32 | * Save the current cache to a file in the remote cache directory 33 | */ 34 | async saveCache(filename: string) { 35 | try { 36 | const webdav = await this.plugin.webDAVService.createWebDAVClient() 37 | const deltaCache = await deltaCacheKV.get(this.key) 38 | const exportedStorage: ExportedStorage = { 39 | deltaCache: superjson.stringify(deltaCache), 40 | exportedAt: new Date().toISOString(), 41 | } 42 | const exportedStorageStr = JSON.stringify(exportedStorage) 43 | const deflatedStorage = deflate(exportedStorageStr, { level: 9 }) 44 | await webdav.createDirectory(this.remoteCacheDir, { recursive: true }) 45 | 46 | const filePath = join(this.remoteCacheDir, filename) 47 | await webdav.putFileContents(filePath, deflatedStorage.buffer, { 48 | overwrite: true, 49 | }) 50 | 51 | new Notice(i18n.t('settings.cache.saveModal.success')) 52 | return Promise.resolve() 53 | } catch (error) { 54 | logger.error('Error saving cache:', error) 55 | new Notice( 56 | i18n.t('settings.cache.saveModal.error', { 57 | message: error.message, 58 | }), 59 | ) 60 | return Promise.reject(error) 61 | } 62 | } 63 | 64 | /** 65 | * Restore the cache from a file in the remote cache directory 66 | */ 67 | async restoreCache(filename: string) { 68 | try { 69 | const webdav = await this.plugin.webDAVService.createWebDAVClient() 70 | const filePath = join(this.remoteCacheDir, filename) 71 | 72 | const fileExists = await webdav.exists(filePath).catch(() => false) 73 | if (!fileExists) { 74 | new Notice(i18n.t('settings.cache.restoreModal.fileNotFound')) 75 | return Promise.reject(new Error('File not found')) 76 | } 77 | 78 | const fileContent = (await webdav.getFileContents(filePath, { 79 | format: 'binary', 80 | })) as BufferLike 81 | const inflatedFileContent = inflate(new Uint8Array(fileContent)) 82 | const decoder = new TextDecoder() 83 | const exportedStorage: ExportedStorage = JSON.parse( 84 | decoder.decode(inflatedFileContent), 85 | ) 86 | const { deltaCache } = exportedStorage 87 | await deltaCacheKV.set(this.key, superjson.parse(deltaCache)) 88 | 89 | new Notice(i18n.t('settings.cache.restoreModal.success')) 90 | return Promise.resolve() 91 | } catch (error) { 92 | logger.error('Error restoring cache:', error) 93 | new Notice( 94 | i18n.t('settings.cache.restoreModal.error', { 95 | message: error.message, 96 | }), 97 | ) 98 | return Promise.reject(error) 99 | } 100 | } 101 | 102 | /** 103 | * Delete a cache file from the remote cache directory 104 | */ 105 | async deleteCache(filename: string): Promise { 106 | try { 107 | const webdav = await this.plugin.webDAVService.createWebDAVClient() 108 | const filePath = join(this.remoteCacheDir, filename) 109 | 110 | await webdav.deleteFile(filePath) 111 | 112 | new Notice(i18n.t('settings.cache.restoreModal.deleteSuccess')) 113 | return Promise.resolve() 114 | } catch (error) { 115 | logger.error('Error deleting cache file:', error) 116 | new Notice( 117 | i18n.t('settings.cache.restoreModal.deleteError', { 118 | message: error.message, 119 | }), 120 | ) 121 | return Promise.reject(error) 122 | } 123 | } 124 | 125 | /** 126 | * Load the list of cache files from the remote cache directory 127 | */ 128 | async loadCacheFileList() { 129 | try { 130 | const webdav = await this.plugin.webDAVService.createWebDAVClient() 131 | const dirExists = await webdav 132 | .exists(this.remoteCacheDir) 133 | .catch(() => false) 134 | if (!dirExists) { 135 | await webdav.createDirectory(this.remoteCacheDir, { recursive: true }) 136 | return [] 137 | } 138 | const files = await getDirectoryContents( 139 | await this.plugin.getToken(), 140 | this.remoteCacheDir, 141 | ) 142 | return files.map(fileStatToStatModel) 143 | } catch (error) { 144 | logger.error('Error loading cache file list:', error) 145 | throw error 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/services/command.service.ts: -------------------------------------------------------------------------------- 1 | import SyncConfirmModal from '~/components/SyncConfirmModal' 2 | import { emitCancelSync } from '~/events' 3 | import i18n from '~/i18n' 4 | import { NutstoreSync } from '~/sync' 5 | import NutstorePlugin from '..' 6 | 7 | export default class CommandService { 8 | constructor(plugin: NutstorePlugin) { 9 | plugin.addCommand({ 10 | id: 'start-sync', 11 | name: i18n.t('sync.startButton'), 12 | callback: async () => { 13 | if (plugin.isSyncing) { 14 | return 15 | } 16 | const startSync = async () => { 17 | const sync = new NutstoreSync(plugin, { 18 | webdav: await plugin.webDAVService.createWebDAVClient(), 19 | vault: plugin.app.vault, 20 | token: await plugin.getToken(), 21 | remoteBaseDir: plugin.remoteBaseDir, 22 | }) 23 | await sync.start({ 24 | showNotice: true, 25 | }) 26 | } 27 | new SyncConfirmModal(plugin.app, startSync).open() 28 | }, 29 | }) 30 | 31 | plugin.addCommand({ 32 | id: 'stop-sync', 33 | name: i18n.t('sync.stopButton'), 34 | checkCallback: (checking) => { 35 | if (plugin.isSyncing) { 36 | if (!checking) { 37 | emitCancelSync() 38 | } 39 | return true 40 | } 41 | return false 42 | }, 43 | }) 44 | 45 | plugin.addCommand({ 46 | id: 'show-sync-progress', 47 | name: i18n.t('sync.showProgressButton'), 48 | callback: () => { 49 | plugin.progressService.showProgressModal() 50 | }, 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/services/events.service.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian' 2 | import { Subscription } from 'rxjs' 3 | import { onEndSync, onStartSync, onSyncError, onSyncProgress } from '~/events' 4 | import i18n from '~/i18n' 5 | import { is503Error } from '~/utils/is-503-error' 6 | import NutstorePlugin from '..' 7 | 8 | export default class EventsService { 9 | subscriptions: Subscription[] 10 | 11 | constructor(private plugin: NutstorePlugin) { 12 | this.subscriptions = [ 13 | onStartSync().subscribe(({ showNotice }) => { 14 | plugin.toggleSyncUI(true) 15 | plugin.statusService.updateSyncStatus({ 16 | text: i18n.t('sync.start'), 17 | showNotice, 18 | }) 19 | }), 20 | 21 | onSyncProgress().subscribe((progress) => { 22 | const percent = 23 | Math.round((progress.completed.length / progress.total) * 10000) / 100 24 | plugin.statusService.updateSyncStatus({ 25 | text: i18n.t('sync.progress', { percent }), 26 | }) 27 | }), 28 | 29 | onEndSync().subscribe(async ({ failedCount, showNotice }) => { 30 | plugin.toggleSyncUI(false) 31 | plugin.statusService.updateSyncStatus({ 32 | text: 33 | failedCount > 0 34 | ? i18n.t('sync.completeWithFailed', { failedCount }) 35 | : i18n.t('sync.complete'), 36 | showNotice, 37 | }) 38 | }), 39 | 40 | onSyncError().subscribe((error) => { 41 | plugin.toggleSyncUI(false) 42 | plugin.statusService.updateSyncStatus({ 43 | text: i18n.t('sync.failedStatus'), 44 | isError: true, 45 | showNotice: false, 46 | }) 47 | new Notice( 48 | i18n.t('sync.failedWithError', { 49 | error: is503Error(error) 50 | ? i18n.t('sync.error.requestsTooFrequent') 51 | : error.message, 52 | }), 53 | ) 54 | }), 55 | ] 56 | } 57 | 58 | unload() { 59 | this.subscriptions.forEach((subscription) => subscription.unsubscribe()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/services/i18n.service.ts: -------------------------------------------------------------------------------- 1 | import { getLanguage } from 'obsidian' 2 | import i18n from '~/i18n' 3 | import NutstorePlugin from '..' 4 | 5 | export default class I18nService { 6 | constructor(private plugin: NutstorePlugin) { 7 | this.update() 8 | this.plugin.registerInterval(window.setInterval(this.update, 60000)) 9 | } 10 | 11 | update = () => { 12 | let code = navigator.language.split('-')[0] 13 | try { 14 | code = getLanguage().split('-')[0] 15 | } finally { 16 | i18n.changeLanguage(code.toLowerCase()) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { moment } from 'obsidian' 2 | import { isNotNil } from 'ramda' 3 | import { IN_DEV } from '~/consts' 4 | import { useLogsStorage } from '~/storage/logs' 5 | import logger from '~/utils/logger' 6 | import logsStringify from '~/utils/logs-stringify' 7 | import NutstorePlugin from '..' 8 | 9 | export default class LoggerService { 10 | logs: any[] = [] 11 | logsFileName = moment().format('YYYY-MM-DD_HH-mm-ss') + '.log' 12 | logsStorage = useLogsStorage(this.plugin) 13 | 14 | constructor(private plugin: NutstorePlugin) { 15 | if (IN_DEV) { 16 | logger.addReporter({ 17 | log: (logObj) => { 18 | const log = [ 19 | moment(logObj.date).format('YYYY-MM-DD HH:mm:ss'), 20 | logObj.type, 21 | logObj.args, 22 | ] 23 | this.logs.push(log) 24 | }, 25 | }) 26 | } else { 27 | logger.setReporters([ 28 | { 29 | log: (logObj) => { 30 | this.logs.push(logObj) 31 | }, 32 | }, 33 | ]) 34 | } 35 | } 36 | 37 | async saveLogs() { 38 | try { 39 | const logs = this.logs.map(logsStringify).filter(isNotNil) 40 | this.logs = logs 41 | await this.logsStorage.set(this.logsFileName, logs.join('\n\n')) 42 | } catch (e) { 43 | logger.error('Error saving logs:', e) 44 | } 45 | } 46 | 47 | clear() { 48 | this.logs = [] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/services/progress.service.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from 'lodash-es' 2 | import { Notice } from 'obsidian' 3 | import SyncProgressModal from '../components/SyncProgressModal' 4 | import { 5 | onEndSync, 6 | onStartSync, 7 | onSyncProgress, 8 | UpdateSyncProgress, 9 | } from '../events' 10 | import i18n from '../i18n' 11 | import NutstorePlugin from '../index' 12 | 13 | export class ProgressService { 14 | private progressModal: SyncProgressModal | null = null 15 | 16 | public syncProgress: UpdateSyncProgress = { 17 | total: 0, 18 | completed: [], 19 | } 20 | 21 | syncEnd = false 22 | 23 | private subscriptions = [ 24 | onStartSync().subscribe(() => { 25 | this.syncEnd = false 26 | this.resetProgress() 27 | }), 28 | onEndSync().subscribe(() => { 29 | this.syncEnd = true 30 | this.updateModal() 31 | }), 32 | onSyncProgress().subscribe((p) => { 33 | this.syncProgress = p 34 | this.updateModal() 35 | }), 36 | ] 37 | 38 | constructor(private plugin: NutstorePlugin) {} 39 | 40 | updateModal = throttle(() => { 41 | if (this.progressModal) { 42 | this.progressModal.update() 43 | } 44 | }, 200) 45 | 46 | public resetProgress() { 47 | this.syncProgress = { 48 | total: 0, 49 | completed: [], 50 | } 51 | } 52 | 53 | public showProgressModal() { 54 | if (!this.plugin.isSyncing) { 55 | new Notice(i18n.t('sync.notSyncing')) 56 | return 57 | } 58 | this.closeProgressModal() 59 | this.progressModal = new SyncProgressModal(this.plugin) 60 | this.progressModal.open() 61 | } 62 | 63 | public closeProgressModal() { 64 | if (this.progressModal) { 65 | this.progressModal.close() 66 | this.progressModal = null 67 | } 68 | } 69 | 70 | public unload() { 71 | this.subscriptions.forEach((sub) => sub.unsubscribe()) 72 | this.closeProgressModal() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/services/realtime-sync.service.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from 'lodash-es' 2 | import { useSettings } from '~/settings' 3 | import { NutstoreSync } from '~/sync' 4 | import waitUntil from '~/utils/wait-until' 5 | import NutstorePlugin from '..' 6 | 7 | export default class RealtimeSyncService { 8 | async realtimeSync() { 9 | const settings = await useSettings() 10 | if (!settings.realtimeSync) { 11 | return 12 | } 13 | const sync = new NutstoreSync(this.plugin, { 14 | vault: this.vault, 15 | token: await this.plugin.getToken(), 16 | remoteBaseDir: this.plugin.remoteBaseDir, 17 | webdav: await this.plugin.webDAVService.createWebDAVClient(), 18 | }) 19 | await sync.start({ 20 | showNotice: false, 21 | }) 22 | } 23 | 24 | submitSyncRequest = new (class { 25 | waiting = false 26 | 27 | constructor(public realtimeSyncService: RealtimeSyncService) {} 28 | 29 | submitDirectly = async () => { 30 | if (this.waiting) { 31 | return 32 | } 33 | this.waiting = true 34 | await waitUntil( 35 | () => this.realtimeSyncService.plugin.isSyncing === false, 36 | 500, 37 | ) 38 | this.waiting = false 39 | await this.realtimeSyncService.realtimeSync() 40 | } 41 | 42 | submit = debounce(this.submitDirectly, 8000) 43 | })(this) 44 | 45 | constructor(private plugin: NutstorePlugin) { 46 | this.plugin.registerEvent( 47 | this.vault.on('create', async () => { 48 | await this.submitSyncRequest.submit() 49 | }), 50 | ) 51 | this.plugin.registerEvent( 52 | this.vault.on('delete', async () => { 53 | await this.submitSyncRequest.submit() 54 | }), 55 | ) 56 | this.plugin.registerEvent( 57 | this.vault.on('modify', async () => { 58 | await this.submitSyncRequest.submit() 59 | }), 60 | ) 61 | this.plugin.registerEvent( 62 | this.vault.on('rename', async () => { 63 | await this.submitSyncRequest.submit() 64 | }), 65 | ) 66 | 67 | useSettings().then(({ startupSyncDelaySeconds }) => { 68 | if (startupSyncDelaySeconds > 0) { 69 | window.setTimeout(() => { 70 | this.submitSyncRequest.submitDirectly() 71 | }, startupSyncDelaySeconds * 1000) 72 | } 73 | }) 74 | } 75 | 76 | get vault() { 77 | return this.plugin.app.vault 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/services/status.service.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian' 2 | import NutstorePlugin from '../index' 3 | 4 | export class StatusService { 5 | public syncStatusBar: HTMLElement 6 | 7 | constructor(private plugin: NutstorePlugin) { 8 | this.syncStatusBar = plugin.addStatusBarItem() 9 | } 10 | 11 | /** 12 | * Updates the sync status display in the status bar 13 | */ 14 | public updateSyncStatus(status: { 15 | text: string 16 | isError?: boolean 17 | showNotice?: boolean 18 | }): void { 19 | this.syncStatusBar.setText(status.text) 20 | 21 | if (status.showNotice) { 22 | new Notice(status.text) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/services/webdav.service.ts: -------------------------------------------------------------------------------- 1 | import { createClient, WebDAVClient } from 'webdav' 2 | import { NS_DAV_ENDPOINT } from '../consts' 3 | import NutstorePlugin from '../index' 4 | import { createRateLimitedWebDAVClient } from '../utils/rate-limited-client' 5 | 6 | export class WebDAVService { 7 | constructor(private plugin: NutstorePlugin) {} 8 | 9 | async createWebDAVClient(): Promise { 10 | let client: WebDAVClient 11 | if (this.plugin.settings.loginMode === 'manual') { 12 | client = createClient(NS_DAV_ENDPOINT, { 13 | username: this.plugin.settings.account, 14 | password: this.plugin.settings.credential, 15 | }) 16 | } else { 17 | const oauth = await this.plugin.getDecryptedOAuthInfo() 18 | client = createClient(NS_DAV_ENDPOINT, { 19 | username: oauth.username, 20 | password: oauth.access_token, 21 | }) 22 | } 23 | return createRateLimitedWebDAVClient(client) 24 | } 25 | 26 | async checkWebDAVConnection(): Promise<{ error?: Error; success: boolean }> { 27 | try { 28 | const client = await this.createWebDAVClient() 29 | return { success: await client.exists('/') } 30 | } catch (error) { 31 | return { 32 | error, 33 | success: false, 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/settings/account.ts: -------------------------------------------------------------------------------- 1 | import { createOAuthUrl } from '@nutstore/sso-js' 2 | import { Notice, Setting } from 'obsidian' 3 | import LogoutConfirmModal from '~/components/LogoutConfirmModal' 4 | import i18n from '~/i18n' 5 | import { OAuthResponse } from '~/utils/decrypt-ticket-response' 6 | import { is503Error } from '~/utils/is-503-error' 7 | import logger from '~/utils/logger' 8 | import BaseSettings from './settings.base' 9 | 10 | export default class AccountSettings extends BaseSettings { 11 | private updateOAuthUrlTimer: number | null = null 12 | 13 | async display() { 14 | this.containerEl.empty() 15 | this.containerEl.createEl('h2', { 16 | text: i18n.t('settings.sections.account'), 17 | }) 18 | 19 | new Setting(this.containerEl) 20 | .setName(i18n.t('settings.loginMode.name')) 21 | .addDropdown((dropdown) => 22 | dropdown 23 | .addOption('manual', i18n.t('settings.loginMode.manual')) 24 | .addOption('sso', i18n.t('settings.loginMode.sso')) 25 | .setValue(this.plugin.settings.loginMode) 26 | .onChange(async (value: 'manual' | 'sso') => { 27 | this.plugin.settings.loginMode = value 28 | await this.plugin.saveSettings() 29 | this.display() 30 | }), 31 | ) 32 | 33 | if (this.settings.isSSO) { 34 | await this.displaySSOLoginSettings() 35 | } else { 36 | await this.displayManualLoginSettings() 37 | } 38 | } 39 | 40 | async hide() { 41 | if (this.updateOAuthUrlTimer !== null) { 42 | clearInterval(this.updateOAuthUrlTimer) 43 | this.updateOAuthUrlTimer = null 44 | } 45 | } 46 | 47 | private displayManualLoginSettings(): void { 48 | const helper = new Setting(this.containerEl) 49 | const anchor = helper.descEl.createEl('a', { 50 | href: 'https://help.jianguoyun.com/?p=2064', 51 | cls: 'no-underline', 52 | text: i18n.t('settings.help.name'), 53 | }) 54 | anchor.target = '_blank' 55 | 56 | new Setting(this.containerEl) 57 | .setName(i18n.t('settings.account.name')) 58 | .setDesc(i18n.t('settings.account.desc')) 59 | .addText((text) => 60 | text 61 | .setPlaceholder(i18n.t('settings.account.placeholder')) 62 | .setValue(this.plugin.settings.account) 63 | .onChange(async (value) => { 64 | this.plugin.settings.account = value 65 | await this.plugin.saveSettings() 66 | }), 67 | ) 68 | 69 | new Setting(this.containerEl) 70 | .setName(i18n.t('settings.credential.name')) 71 | .setDesc(i18n.t('settings.credential.desc')) 72 | .addText((text) => { 73 | text 74 | .setPlaceholder(i18n.t('settings.credential.placeholder')) 75 | .setValue(this.plugin.settings.credential) 76 | .onChange(async (value) => { 77 | this.plugin.settings.credential = value 78 | await this.plugin.saveSettings() 79 | }) 80 | text.inputEl.type = 'password' 81 | }) 82 | 83 | this.displayCheckConnection() 84 | } 85 | 86 | private async displaySSOLoginSettings() { 87 | let isLoggedIn = this.plugin.settings.oauthResponseText.length > 0 88 | let oauth: OAuthResponse | undefined 89 | if (isLoggedIn) { 90 | try { 91 | oauth = await this.plugin.getDecryptedOAuthInfo() 92 | } catch (e) { 93 | logger.error(e) 94 | isLoggedIn = false 95 | } 96 | } 97 | if (isLoggedIn && oauth?.username) { 98 | const el = new Setting(this.containerEl) 99 | .setName(i18n.t('settings.ssoStatus.loggedIn')) 100 | .setDesc(oauth.username) 101 | .addButton((button) => { 102 | button 103 | .setWarning() 104 | .setButtonText(i18n.t('settings.ssoStatus.logout')) 105 | .onClick(() => { 106 | new LogoutConfirmModal(this.app, async () => { 107 | this.plugin.settings.oauthResponseText = '' 108 | await this.plugin.saveSettings() 109 | new Notice(i18n.t('settings.ssoStatus.logoutSuccess')) 110 | this.display() 111 | }).open() 112 | }) 113 | }) 114 | el.descEl.classList.add('max-w-full', 'truncate') 115 | el.infoEl.classList.add('max-w-full') 116 | this.displayCheckConnection() 117 | } else { 118 | new Setting(this.containerEl) 119 | .setName(i18n.t('settings.ssoStatus.notLoggedIn')) 120 | .addButton(async (button) => { 121 | button.setButtonText(i18n.t('settings.login.name')) 122 | const anchor = document.createElement('a') 123 | anchor.target = '_blank' 124 | button.buttonEl.parentElement?.appendChild(anchor) 125 | anchor.appendChild(button.buttonEl) 126 | anchor.href = await createOAuthUrl({ 127 | app: 'obsidian', 128 | }) 129 | this.updateOAuthUrlTimer = window.setInterval(async () => { 130 | const stillInDoc = document.contains(anchor) 131 | if (stillInDoc) { 132 | anchor.href = await createOAuthUrl({ 133 | app: 'obsidian', 134 | }) 135 | } else { 136 | clearInterval(this.updateOAuthUrlTimer!) 137 | this.updateOAuthUrlTimer = null 138 | } 139 | }, 60 * 1000) 140 | }) 141 | } 142 | } 143 | 144 | private displayCheckConnection() { 145 | new Setting(this.containerEl) 146 | .setName(i18n.t('settings.checkConnection.name')) 147 | .setDesc(i18n.t('settings.checkConnection.desc')) 148 | .addButton((button) => { 149 | button 150 | .setButtonText(i18n.t('settings.checkConnection.name')) 151 | .onClick(async (e) => { 152 | const buttonEl = e.target as HTMLElement 153 | buttonEl.classList.add('connection-button', 'loading') 154 | buttonEl.classList.remove('success', 'error') 155 | buttonEl.textContent = i18n.t('settings.checkConnection.name') 156 | try { 157 | const { success, error } = 158 | await this.plugin.webDAVService.checkWebDAVConnection() 159 | buttonEl.classList.remove('loading') 160 | if (success) { 161 | buttonEl.classList.add('success') 162 | buttonEl.textContent = i18n.t( 163 | 'settings.checkConnection.successButton', 164 | ) 165 | new Notice(i18n.t('settings.checkConnection.success')) 166 | } else if (error && is503Error(error)) { 167 | buttonEl.classList.add('error') 168 | buttonEl.textContent = i18n.t('sync.error.requestsTooFrequent') 169 | new Notice(i18n.t('sync.error.requestsTooFrequent')) 170 | } else { 171 | buttonEl.classList.add('error') 172 | buttonEl.textContent = i18n.t( 173 | 'settings.checkConnection.failureButton', 174 | ) 175 | new Notice(i18n.t('settings.checkConnection.failure')) 176 | } 177 | } catch { 178 | buttonEl.classList.remove('loading') 179 | buttonEl.classList.add('error') 180 | buttonEl.textContent = i18n.t( 181 | 'settings.checkConnection.failureButton', 182 | ) 183 | new Notice(i18n.t('settings.checkConnection.failure')) 184 | } 185 | }) 186 | }) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/settings/cache.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from 'obsidian' 2 | import { join } from 'path' 3 | import CacheClearModal from '~/components/CacheClearModal' 4 | import CacheRestoreModal from '~/components/CacheRestoreModal' 5 | import CacheSaveModal from '~/components/CacheSaveModal' 6 | import SelectRemoteBaseDirModal from '~/components/SelectRemoteBaseDirModal' 7 | import i18n from '~/i18n' 8 | import { blobKV, deltaCacheKV, syncRecordKV } from '~/storage/kv' 9 | import { getDBKey } from '~/utils/get-db-key' 10 | import logger from '~/utils/logger' 11 | import { stdRemotePath } from '~/utils/std-remote-path' 12 | import BaseSettings from './settings.base' 13 | 14 | export interface ExportedStorage { 15 | deltaCache: string 16 | exportedAt: string 17 | } 18 | 19 | export default class CacheSettings extends BaseSettings { 20 | async display() { 21 | this.containerEl.empty() 22 | this.containerEl.createEl('h2', { text: i18n.t('settings.cache.title') }) 23 | 24 | // set remote cache directory 25 | new Setting(this.containerEl) 26 | .setName(i18n.t('settings.cache.remoteCacheDir.name')) 27 | .setDesc(i18n.t('settings.cache.remoteCacheDir.desc')) 28 | .addText((text) => { 29 | text 30 | .setPlaceholder(i18n.t('settings.cache.remoteCacheDir.placeholder')) 31 | .setValue(this.remoteCacheDir) 32 | .onChange(async (value) => { 33 | this.plugin.settings.remoteCacheDir = value 34 | await this.plugin.saveSettings() 35 | }) 36 | text.inputEl.addEventListener('blur', async () => { 37 | this.plugin.settings.remoteCacheDir = this.remoteCacheDir 38 | await this.plugin.saveSettings() 39 | this.display() 40 | }) 41 | }) 42 | .addButton((button) => { 43 | button.setIcon('folder').onClick(() => { 44 | new SelectRemoteBaseDirModal(this.app, this.plugin, async (path) => { 45 | this.plugin.settings.remoteCacheDir = path 46 | await this.plugin.saveSettings() 47 | this.display() 48 | }).open() 49 | }) 50 | }) 51 | 52 | // Save and restore cache 53 | new Setting(this.containerEl) 54 | .setName(i18n.t('settings.cache.dumpName')) 55 | .setDesc(i18n.t('settings.cache.dumpDesc')) 56 | .addButton((button) => { 57 | button.setButtonText(i18n.t('settings.cache.dump')).onClick(() => { 58 | new CacheSaveModal(this.plugin, this.remoteCacheDir, () => 59 | this.display(), 60 | ).open() 61 | }) 62 | }) 63 | .addButton((button) => { 64 | button.setButtonText(i18n.t('settings.cache.restore')).onClick(() => { 65 | new CacheRestoreModal(this.plugin, this.remoteCacheDir, () => 66 | this.display(), 67 | ).open() 68 | }) 69 | }) 70 | 71 | // clear 72 | new Setting(this.containerEl) 73 | .setName(i18n.t('settings.cache.clearName')) 74 | .setDesc(i18n.t('settings.cache.clearDesc')) 75 | .addButton((button) => { 76 | button 77 | .setButtonText(i18n.t('settings.cache.clear')) 78 | .onClick(async () => { 79 | new CacheClearModal(this.plugin, async (options) => { 80 | try { 81 | const cleared = 82 | await CacheClearModal.clearSelectedCaches(options) 83 | if (cleared.length > 0) { 84 | new Notice(i18n.t('settings.cache.cleared')) 85 | } else { 86 | new Notice( 87 | i18n.t('settings.cache.clearModal.nothingSelected'), 88 | ) 89 | } 90 | } catch (error) { 91 | logger.error('Error clearing cache:', error) 92 | new Notice(`Error clearing cache: ${error.message}`) 93 | } 94 | }).open() 95 | }) 96 | }) 97 | } 98 | 99 | get remoteCacheDir() { 100 | return stdRemotePath( 101 | this.plugin.settings.remoteCacheDir?.trim() || 102 | this.plugin.manifest.name.trim(), 103 | ) 104 | } 105 | 106 | get remoteCachePath() { 107 | const filename = getDBKey( 108 | this.app.vault.getName(), 109 | this.plugin.settings.remoteDir, 110 | ) 111 | return join(this.remoteCacheDir, filename + '.json') 112 | } 113 | 114 | async createRemoteCacheDir() { 115 | const webdav = await this.plugin.webDAVService.createWebDAVClient() 116 | return await webdav.createDirectory(this.remoteCacheDir, { 117 | recursive: true, 118 | }) 119 | } 120 | 121 | /** 122 | * Clear the local cache 123 | * @param options Options specifying which caches to clear 124 | */ 125 | async clearCache({ 126 | deltaCacheEnabled = true, 127 | syncRecordEnabled = true, 128 | blobEnabled = true, 129 | } = {}) { 130 | if (deltaCacheEnabled) { 131 | await deltaCacheKV.clear() 132 | } 133 | 134 | if (syncRecordEnabled) { 135 | await syncRecordKV.clear() 136 | } 137 | 138 | if (blobEnabled) { 139 | await blobKV.clear() 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/settings/common.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian' 2 | import SelectRemoteBaseDirModal from '~/components/SelectRemoteBaseDirModal' 3 | import i18n from '~/i18n' 4 | import { SyncMode } from './index' 5 | import BaseSettings from './settings.base' 6 | 7 | export default class CommonSettings extends BaseSettings { 8 | async display() { 9 | this.containerEl.empty() 10 | this.containerEl.createEl('h2', { 11 | text: i18n.t('settings.sections.common'), 12 | }) 13 | 14 | new Setting(this.containerEl) 15 | .setName(i18n.t('settings.remoteDir.name')) 16 | .setDesc(i18n.t('settings.remoteDir.desc')) 17 | .addText((text) => { 18 | text 19 | .setPlaceholder(i18n.t('settings.remoteDir.placeholder')) 20 | .setValue(this.plugin.remoteBaseDir) 21 | .onChange(async (value) => { 22 | this.plugin.settings.remoteDir = value 23 | await this.plugin.saveSettings() 24 | }) 25 | text.inputEl.addEventListener('blur', () => { 26 | this.plugin.settings.remoteDir = this.plugin.remoteBaseDir 27 | this.display() 28 | }) 29 | }) 30 | .addButton((button) => { 31 | button.setIcon('folder').onClick(() => { 32 | new SelectRemoteBaseDirModal(this.app, this.plugin, async (path) => { 33 | this.plugin.settings.remoteDir = path 34 | await this.plugin.saveSettings() 35 | this.display() 36 | }).open() 37 | }) 38 | }) 39 | 40 | new Setting(this.containerEl) 41 | .setName(i18n.t('settings.skipLargeFiles.name')) 42 | .setDesc(i18n.t('settings.skipLargeFiles.desc')) 43 | .addText((text) => { 44 | const currentValue = this.plugin.settings.skipLargeFiles.maxSize.trim() 45 | text 46 | .setPlaceholder(i18n.t('settings.skipLargeFiles.placeholder')) 47 | .setValue(currentValue) 48 | .onChange(async (value) => { 49 | this.plugin.settings.skipLargeFiles.maxSize = value.trim() 50 | await this.plugin.saveSettings() 51 | }) 52 | }) 53 | 54 | new Setting(this.containerEl) 55 | .setName(i18n.t('settings.conflictStrategy.name')) 56 | .setDesc(i18n.t('settings.conflictStrategy.desc')) 57 | .addDropdown((dropdown) => 58 | dropdown 59 | .addOption( 60 | 'diff-match-patch', 61 | i18n.t('settings.conflictStrategy.diffMatchPatch'), 62 | ) 63 | .addOption( 64 | 'latest-timestamp', 65 | i18n.t('settings.conflictStrategy.latestTimestamp'), 66 | ) 67 | .setValue(this.plugin.settings.conflictStrategy) 68 | .onChange(async (value: 'diff-match-patch' | 'latest-timestamp') => { 69 | this.plugin.settings.conflictStrategy = value 70 | await this.plugin.saveSettings() 71 | }), 72 | ) 73 | 74 | new Setting(this.containerEl) 75 | .setName(i18n.t('settings.useGitStyle.name')) 76 | .setDesc(i18n.t('settings.useGitStyle.desc')) 77 | .addToggle((toggle) => 78 | toggle 79 | .setValue(this.plugin.settings.useGitStyle) 80 | .onChange(async (value) => { 81 | this.plugin.settings.useGitStyle = value 82 | await this.plugin.saveSettings() 83 | }), 84 | ) 85 | 86 | new Setting(this.containerEl) 87 | .setName(i18n.t('settings.confirmBeforeSync.name')) 88 | .setDesc(i18n.t('settings.confirmBeforeSync.desc')) 89 | .addToggle((toggle) => 90 | toggle 91 | .setValue(this.plugin.settings.confirmBeforeSync) 92 | .onChange(async (value) => { 93 | this.plugin.settings.confirmBeforeSync = value 94 | await this.plugin.saveSettings() 95 | }), 96 | ) 97 | 98 | new Setting(this.containerEl) 99 | .setName(i18n.t('settings.realtimeSync.name')) 100 | .setDesc(i18n.t('settings.realtimeSync.desc')) 101 | .addToggle((toggle) => 102 | toggle 103 | .setValue(this.plugin.settings.realtimeSync) 104 | .onChange(async (value) => { 105 | this.plugin.settings.realtimeSync = value 106 | await this.plugin.saveSettings() 107 | }), 108 | ) 109 | 110 | new Setting(this.containerEl) 111 | .setName(i18n.t('settings.startupSyncDelay.name')) 112 | .setDesc(i18n.t('settings.startupSyncDelay.desc')) 113 | .addText((text) => { 114 | text 115 | .setPlaceholder(i18n.t('settings.startupSyncDelay.placeholder')) 116 | .setValue(this.plugin.settings.startupSyncDelaySeconds.toString()) 117 | .onChange(async (value) => { 118 | const numValue = parseFloat(value) 119 | if (!isNaN(numValue)) { 120 | this.plugin.settings.startupSyncDelaySeconds = numValue 121 | await this.plugin.saveSettings() 122 | } 123 | }) 124 | text.inputEl.addEventListener('blur', () => { 125 | const value = text.getValue() 126 | const numValue = parseFloat(value) 127 | if (Number.isNaN(numValue) || numValue < 0) { 128 | text.setValue('0') 129 | } else { 130 | text.setValue(numValue.toString()) 131 | } 132 | }) 133 | text.inputEl.type = 'number' 134 | text.inputEl.min = '0' 135 | }) 136 | 137 | new Setting(this.containerEl) 138 | .setName(i18n.t('settings.syncMode.name')) 139 | .setDesc(i18n.t('settings.syncMode.desc')) 140 | .addDropdown((dropdown) => 141 | dropdown 142 | .addOption(SyncMode.STRICT, i18n.t('settings.syncMode.strict')) 143 | .addOption(SyncMode.LOOSE, i18n.t('settings.syncMode.loose')) 144 | .setValue(this.plugin.settings.syncMode) 145 | .onChange(async (value: string) => { 146 | this.plugin.settings.syncMode = value as SyncMode 147 | await this.plugin.saveSettings() 148 | }), 149 | ) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/settings/filter.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian' 2 | import FilterEditorModal from '~/components/FilterEditorModal' 3 | import i18n from '~/i18n' 4 | import BaseSettings from './settings.base' 5 | 6 | export default class FilterSettings extends BaseSettings { 7 | async display() { 8 | this.containerEl.empty() 9 | this.containerEl.createEl('h2', { 10 | text: i18n.t('settings.sections.filters'), 11 | }) 12 | 13 | // Inclusion 14 | new Setting(this.containerEl) 15 | .setName(i18n.t('settings.filters.include.name')) 16 | .setDesc(i18n.t('settings.filters.include.desc')) 17 | .addButton((button) => { 18 | button.setButtonText(i18n.t('settings.filters.edit')).onClick(() => { 19 | new FilterEditorModal( 20 | this.plugin, 21 | this.plugin.settings.filterRules.inclusionRules, 22 | async (filters) => { 23 | this.plugin.settings.filterRules.inclusionRules = filters 24 | await this.plugin.saveSettings() 25 | this.display() 26 | }, 27 | ).open() 28 | }) 29 | }) 30 | 31 | // Exclusion 32 | new Setting(this.containerEl) 33 | .setName(i18n.t('settings.filters.exclude.name')) 34 | .setDesc(i18n.t('settings.filters.exclude.desc')) 35 | .addButton((button) => { 36 | button.setButtonText(i18n.t('settings.filters.edit')).onClick(() => { 37 | new FilterEditorModal( 38 | this.plugin, 39 | this.plugin.settings.filterRules.exclusionRules, 40 | async (filters) => { 41 | this.plugin.settings.filterRules.exclusionRules = filters 42 | await this.plugin.saveSettings() 43 | this.display() 44 | }, 45 | ).open() 46 | }) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from 'obsidian' 2 | import { onSsoReceive } from '~/events/sso-receive' 3 | import i18n from '~/i18n' 4 | import type NutstorePlugin from '~/index' 5 | import { GlobMatchOptions } from '~/utils/glob-match' 6 | import waitUntil from '~/utils/wait-until' 7 | import AccountSettings from './account' 8 | import CacheSettings from './cache' 9 | import CommonSettings from './common' 10 | import FilterSettings from './filter' 11 | import LogSettings from './log' 12 | 13 | export enum SyncMode { 14 | STRICT = 'strict', 15 | LOOSE = 'loose', 16 | } 17 | 18 | export interface NutstoreSettings { 19 | account: string 20 | credential: string 21 | remoteDir: string 22 | remoteCacheDir?: string 23 | useGitStyle: boolean 24 | conflictStrategy: 'diff-match-patch' | 'latest-timestamp' 25 | oauthResponseText: string 26 | loginMode: 'manual' | 'sso' 27 | confirmBeforeSync: boolean 28 | syncMode: SyncMode 29 | filterRules: { 30 | exclusionRules: GlobMatchOptions[] 31 | inclusionRules: GlobMatchOptions[] 32 | } 33 | skipLargeFiles: { 34 | maxSize: string 35 | } 36 | realtimeSync: boolean 37 | startupSyncDelaySeconds: number 38 | } 39 | 40 | let pluginInstance: NutstorePlugin | null = null 41 | 42 | export function setPluginInstance(plugin: NutstorePlugin | null) { 43 | pluginInstance = plugin 44 | } 45 | 46 | export function waitUntilPluginInstance() { 47 | return waitUntil(() => !!pluginInstance, 100) 48 | } 49 | 50 | export async function useSettings() { 51 | await waitUntilPluginInstance() 52 | return pluginInstance!.settings 53 | } 54 | 55 | export class NutstoreSettingTab extends PluginSettingTab { 56 | plugin: NutstorePlugin 57 | accountSettings: AccountSettings 58 | commonSettings: CommonSettings 59 | filterSettings: FilterSettings 60 | logSettings: LogSettings 61 | cacheSettings: CacheSettings 62 | 63 | subSso = onSsoReceive().subscribe(() => { 64 | this.display() 65 | }) 66 | 67 | constructor(app: App, plugin: NutstorePlugin) { 68 | super(app, plugin) 69 | this.plugin = plugin 70 | new Setting(this.containerEl) 71 | .setName(i18n.t('settings.backupWarning.name')) 72 | .setDesc(i18n.t('settings.backupWarning.desc')) 73 | this.accountSettings = new AccountSettings( 74 | this.app, 75 | this.plugin, 76 | this, 77 | this.containerEl.createDiv(), 78 | ) 79 | this.commonSettings = new CommonSettings( 80 | this.app, 81 | this.plugin, 82 | this, 83 | this.containerEl.createDiv(), 84 | ) 85 | this.filterSettings = new FilterSettings( 86 | this.app, 87 | this.plugin, 88 | this, 89 | this.containerEl.createDiv(), 90 | ) 91 | this.cacheSettings = new CacheSettings( 92 | this.app, 93 | this.plugin, 94 | this, 95 | this.containerEl.createDiv(), 96 | ) 97 | this.logSettings = new LogSettings( 98 | this.app, 99 | this.plugin, 100 | this, 101 | this.containerEl.createDiv(), 102 | ) 103 | } 104 | 105 | async display() { 106 | await this.accountSettings.display() 107 | await this.commonSettings.display() 108 | await this.filterSettings.display() 109 | await this.cacheSettings.display() 110 | await this.logSettings.display() 111 | } 112 | 113 | get isSSO() { 114 | return this.plugin.settings.loginMode === 'sso' 115 | } 116 | 117 | async hide() { 118 | await this.accountSettings.hide() 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/settings/log.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from 'obsidian' 2 | import { isNotNil } from 'ramda' 3 | import TextAreaModal from '~/components/TextAreaModal' 4 | import i18n from '~/i18n' 5 | import logsStringify from '~/utils/logs-stringify' 6 | import BaseSettings from './settings.base' 7 | 8 | export default class LogSettings extends BaseSettings { 9 | async display() { 10 | this.containerEl.empty() 11 | this.containerEl.createEl('h2', { text: i18n.t('settings.log.title') }) 12 | new Setting(this.containerEl) 13 | .setName(i18n.t('settings.log.name')) 14 | .setDesc(i18n.t('settings.log.desc')) 15 | .addButton((button) => { 16 | button 17 | .setButtonText(i18n.t('settings.log.button')) 18 | .onClick(async () => { 19 | const textareaModal = new TextAreaModal(this.app, this.logs) 20 | textareaModal.open() 21 | }) 22 | }) 23 | new Setting(this.containerEl) 24 | .setName(i18n.t('settings.log.clearName')) 25 | .setDesc(i18n.t('settings.log.clearDesc')) 26 | .addButton((button) => { 27 | button.setButtonText(i18n.t('settings.log.clear')).onClick(() => { 28 | this.plugin.loggerService.clear() 29 | new Notice(i18n.t('settings.log.cleared')) 30 | }) 31 | }) 32 | } 33 | 34 | get logs() { 35 | return this.plugin.loggerService.logs 36 | .map(logsStringify) 37 | .filter(isNotNil) 38 | .join('\n\n') 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/settings/settings.base.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian' 2 | import { NutstoreSettingTab } from '.' 3 | import NutstorePlugin from '..' 4 | 5 | export default abstract class BaseSettings { 6 | constructor( 7 | protected app: App, 8 | protected plugin: NutstorePlugin, 9 | protected settings: NutstoreSettingTab, 10 | protected containerEl: HTMLElement, 11 | ) {} 12 | 13 | abstract display(): void 14 | } 15 | -------------------------------------------------------------------------------- /src/storage/blob.ts: -------------------------------------------------------------------------------- 1 | import { sha256Base64 } from '~/utils/sha256' 2 | import { blobKV } from './kv' 3 | 4 | export function useBlobStore() { 5 | function get(key: string) { 6 | return blobKV.get(key) 7 | } 8 | async function store(value: Blob | ArrayBuffer) { 9 | let key: string 10 | let blob: Blob 11 | if (value instanceof Blob) { 12 | key = await sha256Base64(await value.arrayBuffer()) 13 | blob = value 14 | } else { 15 | key = await sha256Base64(value) 16 | blob = new Blob([value]) 17 | } 18 | return { 19 | key, 20 | value: await blobKV.set(key, blob), 21 | } 22 | } 23 | return { 24 | get, 25 | store, 26 | } 27 | } 28 | 29 | export const blobStore = useBlobStore() 30 | -------------------------------------------------------------------------------- /src/storage/helper.ts: -------------------------------------------------------------------------------- 1 | import { Vault } from 'obsidian' 2 | import { SyncRecordModel } from '~/model/sync-record.model' 3 | import { getDBKey } from '~/utils/get-db-key' 4 | import { syncRecordKV } from './kv' 5 | 6 | export class SyncRecord { 7 | constructor( 8 | public vault: Vault, 9 | public remoteBaseDir: string, 10 | ) {} 11 | 12 | private get key() { 13 | return getDBKey(this.vault.getName(), this.remoteBaseDir) 14 | } 15 | 16 | async updateFileRecord(path: string, record: SyncRecordModel) { 17 | const map = (await syncRecordKV.get(this.key)) ?? new Map() 18 | map?.set(path, record) 19 | syncRecordKV.set(this.key, map) 20 | } 21 | 22 | async getRecords() { 23 | const map = 24 | (await syncRecordKV.get(this.key)) ?? new Map() 25 | return map 26 | } 27 | 28 | setRecords(records: Map) { 29 | return syncRecordKV.set(this.key, records) 30 | } 31 | 32 | async getRecord(path: string) { 33 | const map = await syncRecordKV.get(this.key) 34 | return map?.get(path) 35 | } 36 | 37 | async drop() { 38 | return await syncRecordKV.unset(this.key) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './kv' 2 | -------------------------------------------------------------------------------- /src/storage/kv.ts: -------------------------------------------------------------------------------- 1 | import localforage from 'localforage' 2 | import { DeltaResponse } from '~/api/delta' 3 | import { StatModel } from '~/model/stat.model' 4 | import { SyncRecordModel } from '~/model/sync-record.model' 5 | import useStorage from './use-storage' 6 | 7 | const DB_NAME = 'Nutstore_Plugin_Cache' 8 | 9 | interface DeltaCache { 10 | files: StatModel[] 11 | originCursor: string 12 | deltas: DeltaResponse[] 13 | } 14 | 15 | export const deltaCacheKV = useStorage( 16 | localforage.createInstance({ 17 | name: DB_NAME, 18 | storeName: 'delta_cache', 19 | }), 20 | ) 21 | 22 | export const syncRecordKV = useStorage>( 23 | localforage.createInstance({ 24 | name: DB_NAME, 25 | storeName: 'sync_record', 26 | }), 27 | ) 28 | 29 | export const blobKV = useStorage( 30 | localforage.createInstance({ 31 | name: DB_NAME, 32 | storeName: 'base_blob_store', 33 | }), 34 | ) 35 | -------------------------------------------------------------------------------- /src/storage/logs.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian' 2 | import { join } from 'path' 3 | import { isNil } from 'ramda' 4 | import useStorage, { StorageInterface } from './use-storage' 5 | 6 | export class LogsStorage extends StorageInterface { 7 | constructor(private plugin: Plugin) { 8 | super() 9 | } 10 | 11 | get logsDir() { 12 | const pluginDir = this.plugin.manifest.dir 13 | if (isNil(pluginDir)) { 14 | return 15 | } 16 | return join(pluginDir, 'logs') 17 | } 18 | 19 | private async checkLogsDir() { 20 | if (isNil(this.logsDir)) { 21 | return false 22 | } 23 | const logsDir = this.logsDir 24 | if (await this.plugin.app.vault.adapter.exists(logsDir)) { 25 | const stat = await this.plugin.app.vault.adapter.stat(logsDir) 26 | if (!stat || stat.type === 'file') { 27 | return false 28 | } 29 | } else { 30 | await this.plugin.app.vault.adapter.mkdir(logsDir) 31 | } 32 | return true 33 | } 34 | 35 | async setItem(key: string, value: string): Promise { 36 | if (!(await this.checkLogsDir())) { 37 | throw new Error('Failed to access logs directory') 38 | } 39 | await this.plugin.app.vault.adapter.write(join(this.logsDir!, key), value) 40 | return value 41 | } 42 | 43 | async getItem(key: string): Promise { 44 | if (!(await this.checkLogsDir())) { 45 | return null 46 | } 47 | const filePath = join(this.logsDir!, key) 48 | 49 | if (!(await this.plugin.app.vault.adapter.exists(filePath))) { 50 | return null 51 | } 52 | 53 | try { 54 | const content = await this.plugin.app.vault.adapter.read(filePath) 55 | return content 56 | } catch { 57 | return null 58 | } 59 | } 60 | 61 | async removeItem(key: string): Promise { 62 | if (!(await this.checkLogsDir())) { 63 | return 64 | } 65 | const filePath = join(this.logsDir!, key) 66 | if (await this.plugin.app.vault.adapter.exists(filePath)) { 67 | await this.plugin.app.vault.adapter.remove(filePath) 68 | } 69 | } 70 | 71 | async keys(): Promise { 72 | if (!(await this.checkLogsDir())) { 73 | return [] 74 | } 75 | 76 | try { 77 | const files = await this.plugin.app.vault.adapter.list(this.logsDir!) 78 | return files.files 79 | } catch { 80 | return [] 81 | } 82 | } 83 | 84 | async clear(): Promise { 85 | if (!(await this.checkLogsDir())) { 86 | return 87 | } 88 | const allKeys = await this.keys() 89 | for (const key of allKeys) { 90 | await this.removeItem(key) 91 | } 92 | } 93 | } 94 | 95 | export function useLogsStorage(plugin: Plugin) { 96 | return useStorage(new LogsStorage(plugin)) 97 | } 98 | -------------------------------------------------------------------------------- /src/storage/use-storage.ts: -------------------------------------------------------------------------------- 1 | export abstract class StorageInterface { 2 | abstract setItem(key: string, value: T): Promise 3 | abstract getItem(key: string): Promise 4 | abstract removeItem(key: string): Promise 5 | abstract keys(): Promise 6 | abstract clear(): Promise 7 | } 8 | 9 | export default function useStorage(instance: StorageInterface) { 10 | function set(key: string, value: T) { 11 | return instance.setItem(key, value) 12 | } 13 | 14 | function get(key: string) { 15 | return instance.getItem(key) 16 | } 17 | 18 | function unset(key: string) { 19 | return instance.removeItem(key) 20 | } 21 | 22 | function clear() { 23 | return instance.clear() 24 | } 25 | 26 | async function dump() { 27 | const keys = await instance.keys() 28 | const data: Record = {} 29 | for (const key of keys) { 30 | const val = await instance.getItem(key) 31 | if (val) { 32 | data[key] = val 33 | } 34 | } 35 | return data 36 | } 37 | 38 | async function restore(data: Record) { 39 | if (!data || typeof data !== 'object') { 40 | throw new Error('Invalid data format for restore') 41 | } 42 | const temp = await dump() 43 | try { 44 | await instance.clear() 45 | for (const key in data) { 46 | await instance.setItem(key, data[key]) 47 | } 48 | } catch { 49 | await instance.clear() 50 | for (const key in temp) { 51 | await instance.setItem(key, temp[key]) 52 | } 53 | } 54 | } 55 | 56 | return { 57 | set, 58 | get, 59 | unset, 60 | clear, 61 | dump, 62 | restore, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/sync/core/merge-utils.ts: -------------------------------------------------------------------------------- 1 | import { diff_match_patch } from 'diff-match-patch' 2 | import { isEqual } from 'lodash-es' 3 | import { diff3Merge as nodeDiff3Merge } from 'node-diff3' 4 | import { BufferLike } from 'webdav' 5 | 6 | // --- Logic for Latest Timestamp Resolution --- 7 | 8 | export enum LatestTimestampResolution { 9 | NoChange, 10 | UseRemote, 11 | UseLocal, 12 | } 13 | 14 | export interface LatestTimestampParams { 15 | localMtime: number 16 | remoteMtime: number 17 | localContent: BufferLike 18 | remoteContent: BufferLike 19 | } 20 | 21 | export type LatestTimestampResult = 22 | | { status: LatestTimestampResolution.NoChange } 23 | | { status: LatestTimestampResolution.UseRemote; content: BufferLike } 24 | | { status: LatestTimestampResolution.UseLocal; content: BufferLike } 25 | 26 | export function resolveByLatestTimestamp( 27 | params: LatestTimestampParams, 28 | ): LatestTimestampResult { 29 | const { localMtime, remoteMtime, localContent, remoteContent } = params 30 | 31 | if (remoteMtime === localMtime) { 32 | return { status: LatestTimestampResolution.NoChange } 33 | } 34 | 35 | const useRemote = remoteMtime > localMtime 36 | 37 | if (useRemote) { 38 | // Only return UseRemote if content is actually different 39 | if (!isEqual(localContent, remoteContent)) { 40 | return { 41 | status: LatestTimestampResolution.UseRemote, 42 | content: remoteContent, 43 | } 44 | } 45 | return { status: LatestTimestampResolution.NoChange } 46 | } else { 47 | // Local is newer (or same age but remote wasn't newer) 48 | // Only return UseLocal if content is actually different 49 | if (!isEqual(localContent, remoteContent)) { 50 | return { 51 | status: LatestTimestampResolution.UseLocal, 52 | content: localContent, 53 | } 54 | } 55 | return { status: LatestTimestampResolution.NoChange } 56 | } 57 | } 58 | 59 | // --- Logic for Intelligent Merge Resolution --- 60 | 61 | export interface IntelligentMergeParams { 62 | localContentText: string 63 | remoteContentText: string 64 | baseContentText: string 65 | } 66 | 67 | export interface IntelligentMergeResult { 68 | success: boolean 69 | mergedText?: string 70 | error?: string // Generic error message 71 | isIdentical?: boolean // Flag if contents were already identical 72 | } 73 | 74 | // Helper for diff3Merge logic, adapted from the original class method 75 | function diff3MergeStrings( 76 | base: string | string[], 77 | local: string | string[], 78 | remote: string | string[], 79 | ): string | false { 80 | const regions = nodeDiff3Merge(local, base, remote, { 81 | excludeFalseConflicts: true, 82 | stringSeparator: '\n', 83 | }) 84 | 85 | if (regions.some((region) => !region.ok)) { 86 | return false 87 | } 88 | const result: string[][] = [] 89 | for (const region of regions) { 90 | if (region.ok) { 91 | result.push(region.ok as string[]) 92 | } 93 | } 94 | return result.flat().join('\n') 95 | } 96 | 97 | export async function resolveByIntelligentMerge( 98 | params: IntelligentMergeParams, 99 | ): Promise { 100 | const { localContentText, remoteContentText, baseContentText } = params 101 | 102 | if (localContentText === remoteContentText) { 103 | return { success: true, isIdentical: true } 104 | } 105 | 106 | const diff3MergedText = diff3MergeStrings( 107 | baseContentText, 108 | localContentText, 109 | remoteContentText, 110 | ) 111 | 112 | if (diff3MergedText !== false) { 113 | return { success: true, mergedText: diff3MergedText } 114 | } 115 | 116 | const dmp = new diff_match_patch() 117 | dmp.Match_Threshold = 0.2 118 | dmp.Patch_Margin = 2 119 | 120 | const diffs = dmp.diff_main(baseContentText, remoteContentText) 121 | const patches = dmp.patch_make(baseContentText, diffs) 122 | let [mergedDmpText, solveResult] = dmp.patch_apply(patches, localContentText) 123 | 124 | if (solveResult.includes(false)) { 125 | return { success: false } 126 | } 127 | 128 | return { success: true, mergedText: mergedDmpText } 129 | } 130 | -------------------------------------------------------------------------------- /src/sync/decision/base.decision.ts: -------------------------------------------------------------------------------- 1 | import { MaybePromise } from '~/utils/types' 2 | import { NutstoreSync } from '..' 3 | import { BaseTask } from '../tasks/task.interface' 4 | 5 | export default abstract class BaseSyncDecision { 6 | constructor(protected sync: NutstoreSync) {} 7 | 8 | abstract decide(): MaybePromise 9 | 10 | get webdav() { 11 | return this.sync.webdav 12 | } 13 | 14 | get settings() { 15 | return this.sync.settings 16 | } 17 | 18 | get vault() { 19 | return this.sync.vault 20 | } 21 | 22 | get remoteBaseDir() { 23 | return this.sync.remoteBaseDir 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/sync/tasks/conflict-resolve.task.ts: -------------------------------------------------------------------------------- 1 | import { isEqual, noop } from 'lodash-es' 2 | import { isNotNil } from 'ramda' 3 | import { BufferLike } from 'webdav' 4 | import i18n from '~/i18n' 5 | import { StatModel } from '~/model/stat.model' 6 | import { SyncRecordModel } from '~/model/sync-record.model' 7 | import { blobStore } from '~/storage/blob' 8 | import { isBinaryFile } from '~/utils/is-binary-file' 9 | import logger from '~/utils/logger' 10 | import { mergeDigIn } from '~/utils/merge-dig-in' 11 | import { statVaultItem } from '~/utils/stat-vault-item' 12 | import { statWebDAVItem } from '~/utils/stat-webdav-item' 13 | import { 14 | LatestTimestampResolution, 15 | resolveByIntelligentMerge, 16 | resolveByLatestTimestamp, 17 | } from '../core/merge-utils' 18 | import { BaseTask, BaseTaskOptions, toTaskError } from './task.interface' 19 | 20 | export enum ConflictStrategy { 21 | IntelligentMerge, 22 | LatestTimeStamp, 23 | } 24 | 25 | export default class ConflictResolveTask extends BaseTask { 26 | constructor( 27 | public readonly options: BaseTaskOptions & { 28 | record?: SyncRecordModel 29 | strategy: ConflictStrategy 30 | remoteStat?: StatModel 31 | localStat?: StatModel 32 | useGitStyle: boolean 33 | }, 34 | ) { 35 | super(options) 36 | } 37 | 38 | async exec() { 39 | try { 40 | const local = 41 | this.options.localStat ?? 42 | (await statVaultItem(this.vault, this.localPath)) 43 | 44 | if (!local) { 45 | throw new Error('Local file not found: ' + this.localPath) 46 | } 47 | 48 | const remote = 49 | this.options.remoteStat ?? 50 | (await statWebDAVItem(this.webdav, this.remotePath)) 51 | 52 | if (remote.isDir) { 53 | throw new Error('Remote path is a directory: ' + this.remotePath) 54 | } 55 | 56 | if (local.size === 0 && remote.size === 0) { 57 | return { success: true } 58 | } 59 | 60 | switch (this.options.strategy) { 61 | case ConflictStrategy.IntelligentMerge: 62 | return await this.execIntelligentMerge() 63 | case ConflictStrategy.LatestTimeStamp: 64 | return await this.execLatestTimeStamp(local, remote) 65 | } 66 | } catch (e) { 67 | logger.error(this, e) 68 | return { 69 | success: false, 70 | error: toTaskError(e, this), 71 | } 72 | } 73 | } 74 | 75 | async execLatestTimeStamp(local: StatModel, remote: StatModel) { 76 | try { 77 | const localMtime = local.mtime 78 | const remoteMtime = remote.mtime 79 | 80 | if (remoteMtime === localMtime) { 81 | return { success: true } 82 | } 83 | 84 | const localContent = await this.vault.adapter.readBinary(this.localPath) 85 | const remoteContent = (await this.webdav.getFileContents( 86 | this.remotePath, 87 | { 88 | details: false, 89 | format: 'binary', 90 | }, 91 | )) as BufferLike 92 | 93 | const result = resolveByLatestTimestamp({ 94 | localMtime, 95 | remoteMtime, 96 | localContent, 97 | remoteContent, 98 | }) 99 | 100 | switch (result.status) { 101 | case LatestTimestampResolution.UseRemote: 102 | await this.vault.adapter.writeBinary(this.localPath, result.content) 103 | break 104 | case LatestTimestampResolution.UseLocal: 105 | await this.webdav.putFileContents(this.remotePath, result.content, { 106 | overwrite: true, 107 | }) 108 | break 109 | case LatestTimestampResolution.NoChange: 110 | noop() 111 | break 112 | } 113 | 114 | return { success: true } 115 | } catch (e) { 116 | logger.error(this, e) 117 | return { success: false, error: toTaskError(e, this) } 118 | } 119 | } 120 | 121 | async execIntelligentMerge() { 122 | try { 123 | const localBuffer = await this.vault.adapter.readBinary(this.localPath) 124 | const remoteBuffer = (await this.webdav.getFileContents(this.remotePath, { 125 | format: 'binary', 126 | details: false, 127 | })) as BufferLike 128 | 129 | if (isEqual(localBuffer, remoteBuffer)) { 130 | return { success: true } 131 | } 132 | 133 | const { record } = this.options 134 | let baseBlob: Blob | null = null 135 | const baseKey = record?.base?.key 136 | if (baseKey) { 137 | baseBlob = await blobStore.get(baseKey) 138 | } 139 | 140 | const hasBinaryFile = await Promise.all( 141 | [localBuffer, remoteBuffer, await baseBlob?.arrayBuffer()] 142 | .filter(isNotNil) 143 | .map((buf) => isBinaryFile(buf)), 144 | ).then((res) => res.includes(true)) 145 | 146 | if (hasBinaryFile) { 147 | throw new Error(i18n.t('sync.error.cannotMergeBinary')) 148 | } 149 | 150 | const localText = await new Blob([localBuffer]).text() 151 | const remoteText = await new Blob([remoteBuffer]).text() 152 | const baseText = (await baseBlob?.text()) ?? localText 153 | 154 | const mergeResult = await resolveByIntelligentMerge({ 155 | localContentText: localText, 156 | remoteContentText: remoteText, 157 | baseContentText: baseText, 158 | }) 159 | 160 | if (!mergeResult.success) { 161 | // If patch_apply fails to resolve all, use mergeDigIn as a further fallback 162 | const mergeDigInResult = mergeDigIn(localText, baseText, remoteText, { 163 | stringSeparator: '\n', 164 | useGitStyle: this.options.useGitStyle, 165 | }) 166 | // mergeDigIn itself might produce conflict markers if it can't fully resolve. 167 | // The task should handle this merged text (which might contain markers). 168 | const mergedDmpText = mergeDigInResult.result.join('\n') 169 | 170 | const putResult = await this.webdav.putFileContents( 171 | this.remotePath, 172 | mergedDmpText, 173 | { overwrite: true }, 174 | ) 175 | 176 | if (putResult) { 177 | await this.vault.adapter.write(this.localPath, mergedDmpText) 178 | return { success: true } 179 | } else { 180 | throw new Error(i18n.t('sync.error.failedToUploadMerged')) 181 | } 182 | } 183 | 184 | if (mergeResult.isIdentical) { 185 | // This case should be caught by the isEqual(localBuffer, remoteBuffer) check earlier, 186 | // but resolveByIntelligentMerge also returns it. 187 | return { success: true } 188 | } 189 | 190 | const mergedText = mergeResult.mergedText! 191 | 192 | // If mergedText is the same as remoteText, we only need to update localText if it's different. 193 | if (mergedText === remoteText) { 194 | if (mergedText !== localText) { 195 | await this.vault.adapter.write(this.localPath, mergedText) 196 | } 197 | return { success: true } 198 | } 199 | 200 | // If mergedText is different from remoteText, then both remote and local need to be updated. 201 | const putResult = await this.webdav.putFileContents( 202 | this.remotePath, 203 | mergedText, 204 | { overwrite: true }, 205 | ) 206 | 207 | if (!putResult) { 208 | throw new Error(i18n.t('sync.error.failedToUploadMerged')) 209 | } 210 | 211 | if (localText !== mergedText) { 212 | await this.vault.adapter.write(this.localPath, mergedText) 213 | } 214 | 215 | return { success: true } 216 | } catch (e) { 217 | logger.error(this, e) 218 | return { success: false, error: toTaskError(e, this) } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/sync/tasks/filename-error.task.ts: -------------------------------------------------------------------------------- 1 | import { BaseTask } from './task.interface' 2 | 3 | /** 4 | * 如果文件名里存在坚果云不支持的特殊字符, 将无法上传. 5 | * 6 | * 此时可以创建该任务, 不做任何操作. 只在任务列表里告诉用户文件名有问题. 7 | */ 8 | export default class FilenameErrorTask extends BaseTask { 9 | exec() { 10 | return { 11 | success: false, 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/sync/tasks/mkdir-local.task.ts: -------------------------------------------------------------------------------- 1 | import logger from '~/utils/logger' 2 | import { mkdirsVault } from '~/utils/mkdirs-vault' 3 | import { BaseTask, toTaskError } from './task.interface' 4 | 5 | export default class MkdirLocalTask extends BaseTask { 6 | async exec() { 7 | try { 8 | await mkdirsVault(this.vault, this.localPath) 9 | return { success: true } 10 | } catch (e) { 11 | logger.error(this, e) 12 | return { success: false, error: toTaskError(e, this) } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/sync/tasks/mkdir-remote.task.ts: -------------------------------------------------------------------------------- 1 | import i18n from '~/i18n' 2 | import logger from '~/utils/logger' 3 | import { statVaultItem } from '~/utils/stat-vault-item' 4 | import { BaseTask, toTaskError } from './task.interface' 5 | 6 | export default class MkdirRemoteTask extends BaseTask { 7 | async exec() { 8 | try { 9 | const localStat = await statVaultItem(this.vault, this.localPath) 10 | if (!localStat) { 11 | logger.debug('PullTask: local path:', this.localPath) 12 | logger.debug('PullTask: local stat is null') 13 | throw new Error( 14 | i18n.t('sync.error.localPathNotFound', { path: this.localPath }), 15 | ) 16 | } 17 | await this.webdav.createDirectory(this.remotePath, { 18 | recursive: true, 19 | }) 20 | return { success: true } 21 | } catch (e) { 22 | logger.error(this, e) 23 | return { success: false, error: toTaskError(e, this) } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/sync/tasks/noop.task.ts: -------------------------------------------------------------------------------- 1 | import { BaseTask } from './task.interface' 2 | 3 | export default class NoopTask extends BaseTask { 4 | exec() { 5 | return { 6 | success: true, 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/sync/tasks/pull.task.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path' 2 | import { BufferLike } from 'webdav' 3 | import logger from '~/utils/logger' 4 | import { mkdirsVault } from '~/utils/mkdirs-vault' 5 | import { BaseTask, toTaskError } from './task.interface' 6 | 7 | export default class PullTask extends BaseTask { 8 | async exec() { 9 | try { 10 | await mkdirsVault(this.vault, dirname(this.localPath)) 11 | const file = (await this.webdav.getFileContents(this.remotePath, { 12 | format: 'binary', 13 | details: false, 14 | })) as BufferLike 15 | await this.vault.adapter.writeBinary(this.localPath, file) 16 | return { success: true } 17 | } catch (e) { 18 | logger.error(this, e) 19 | return { success: false, error: toTaskError(e, this) } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/sync/tasks/push.task.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath } from 'obsidian' 2 | import logger from '~/utils/logger' 3 | import { BaseTask, BaseTaskOptions, toTaskError } from './task.interface' 4 | 5 | export default class PushTask extends BaseTask { 6 | constructor( 7 | readonly options: BaseTaskOptions & { 8 | overwrite?: boolean 9 | }, 10 | ) { 11 | super(options) 12 | } 13 | 14 | async exec() { 15 | try { 16 | const content = await this.vault.adapter.readBinary( 17 | normalizePath(this.localPath), 18 | ) 19 | const res = await this.webdav.putFileContents(this.remotePath, content, { 20 | overwrite: this.options.overwrite ?? false, 21 | }) 22 | return { success: res } 23 | } catch (e) { 24 | logger.error(this, e) 25 | return { success: false, error: toTaskError(e, this) } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/sync/tasks/remove-local.task.ts: -------------------------------------------------------------------------------- 1 | import i18n from '~/i18n' 2 | import logger from '~/utils/logger' 3 | import { statVaultItem } from '~/utils/stat-vault-item' 4 | import { BaseTask, BaseTaskOptions, toTaskError } from './task.interface' 5 | 6 | export default class RemoveLocalTask extends BaseTask { 7 | constructor( 8 | public readonly options: BaseTaskOptions & { 9 | recursive?: boolean 10 | }, 11 | ) { 12 | super(options) 13 | } 14 | 15 | async exec() { 16 | try { 17 | const stat = await statVaultItem(this.vault, this.localPath) 18 | if (!stat) { 19 | throw new Error(i18n.t('sync.error.notFound', { path: this.localPath })) 20 | } 21 | if (stat.isDir) { 22 | await this.vault.adapter.rmdir( 23 | this.localPath, 24 | this.options.recursive ?? false, 25 | ) 26 | } else { 27 | await this.vault.adapter.remove(this.localPath) 28 | } 29 | return { success: true } 30 | } catch (e) { 31 | logger.error(e) 32 | return { success: false, error: toTaskError(e, this) } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/sync/tasks/remove-remote.task.ts: -------------------------------------------------------------------------------- 1 | import logger from '~/utils/logger' 2 | import { BaseTask, toTaskError } from './task.interface' 3 | 4 | export default class RemoveRemoteTask extends BaseTask { 5 | async exec() { 6 | try { 7 | await this.webdav.deleteFile(this.remotePath) 8 | return { success: true } 9 | } catch (e) { 10 | logger.error(e) 11 | return { success: false, error: toTaskError(e, this) } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/sync/tasks/task.interface.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath, Vault } from 'obsidian' 2 | import { isAbsolute, join } from 'path' 3 | import { WebDAVClient } from 'webdav' 4 | import { SyncRecord } from '~/storage/helper' 5 | import getTaskName from '~/utils/get-task-name' 6 | import { MaybePromise } from '~/utils/types' 7 | 8 | export interface BaseTaskOptions { 9 | vault: Vault 10 | webdav: WebDAVClient 11 | remoteBaseDir: string 12 | remotePath: string 13 | localPath: string 14 | } 15 | 16 | export interface TaskResult { 17 | success: boolean 18 | error?: TaskError 19 | } 20 | 21 | export abstract class BaseTask { 22 | constructor(readonly options: BaseTaskOptions) {} 23 | 24 | get vault() { 25 | return this.options.vault 26 | } 27 | 28 | get webdav() { 29 | return this.options.webdav 30 | } 31 | 32 | get remoteBaseDir() { 33 | return this.options.remoteBaseDir 34 | } 35 | 36 | get remotePath() { 37 | return isAbsolute(this.options.remotePath) 38 | ? this.options.remotePath 39 | : join(this.remoteBaseDir, this.options.remotePath) 40 | } 41 | 42 | get localPath() { 43 | return normalizePath(this.options.localPath) 44 | } 45 | 46 | get syncRecord() { 47 | return new SyncRecord(this.vault, this.options.remoteBaseDir) 48 | } 49 | 50 | abstract exec(): MaybePromise 51 | 52 | toJSON() { 53 | const { localPath, remoteBaseDir, remotePath } = this 54 | const taskName = getTaskName(this) 55 | return { 56 | taskName, 57 | localPath, 58 | remoteBaseDir, 59 | remotePath, 60 | } 61 | } 62 | } 63 | 64 | export class TaskError extends Error { 65 | constructor( 66 | message: string, 67 | readonly task: BaseTask, 68 | readonly cause?: Error, 69 | ) { 70 | super(message) 71 | this.name = 'TaskError' 72 | } 73 | } 74 | 75 | export function toTaskError(e: unknown, task: BaseTask): TaskError { 76 | if (e instanceof TaskError) { 77 | return e 78 | } 79 | const message = e instanceof Error ? e.message : String(e) 80 | return new TaskError(message, task, e instanceof Error ? e : undefined) 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/api-limiter.ts: -------------------------------------------------------------------------------- 1 | import Bottleneck from 'bottleneck' 2 | 3 | export const apiLimiter = new Bottleneck({ 4 | maxConcurrent: 1, 5 | minTime: 200, 6 | }) 7 | -------------------------------------------------------------------------------- /src/utils/breakable-sleep.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | 3 | export default function (ob: Observable, ms: number) { 4 | return new Promise((resolve, reject) => { 5 | const sub = ob.subscribe({ 6 | next: () => finish(), 7 | error: (err) => { 8 | clearTimeout(timer) 9 | sub.unsubscribe() 10 | reject(err) 11 | }, 12 | }) 13 | 14 | function finish() { 15 | clearTimeout(timer) 16 | sub.unsubscribe() 17 | resolve() 18 | } 19 | 20 | const timer = setTimeout(() => { 21 | finish() 22 | }, ms) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/decrypt-ticket-response.ts: -------------------------------------------------------------------------------- 1 | import { decryptSecret } from '@nutstore/sso-js' 2 | 3 | export interface OAuthResponse { 4 | username: string 5 | userid: string 6 | access_token: string 7 | } 8 | 9 | export async function decryptOAuthResponse(cipherText: string) { 10 | const json = await decryptSecret({ 11 | app: 'obsidian', 12 | s: cipherText, 13 | }) 14 | return JSON.parse(json) as OAuthResponse 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/deep-stringify.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isArray, 3 | isBoolean, 4 | isDate, 5 | isFinite, 6 | isFunction, 7 | isNull, 8 | isNumber, 9 | isRegExp, 10 | isString, 11 | isSymbol, 12 | isUndefined, 13 | map, 14 | } from 'lodash-es' 15 | // No need to import Set, use built-in TS Set type 16 | 17 | /** 18 | * Deeply stringifies a JavaScript value into a JSON string, similar to JSON.stringify, 19 | * leveraging lodash-es functions and written in TypeScript. 20 | * - Handles circular references by throwing an error. 21 | * - Handles getter errors by stringifying the error message as the property's value. 22 | * - Uses native JSON.stringify for robust string escaping. 23 | * - Handles Date objects by calling toISOString(), mimicking native behavior. 24 | * 25 | * @param value The value to stringify (typed as unknown for flexibility). 26 | * @param visited Used internally to track visited objects/arrays for circular reference detection. 27 | * @returns The JSON string representation, or undefined if the root value is invalid (function, symbol, undefined). 28 | */ 29 | export default function deepStringify( 30 | value: unknown, 31 | visited: Set = new Set(), 32 | ): string | undefined { 33 | // 1. Handle primitives, null, and unsupported types first 34 | if (isNull(value)) { 35 | return 'null' 36 | } 37 | if (isBoolean(value)) { 38 | return String(value) // 'true' or 'false' 39 | } 40 | if (isString(value)) { 41 | // Use native JSON.stringify for robust escaping AND quoting 42 | return JSON.stringify(value) 43 | } 44 | if (isNumber(value)) { 45 | return isFinite(value) ? String(value) : 'null' // Handle NaN/Infinity 46 | } 47 | if (isUndefined(value) || isFunction(value) || isSymbol(value)) { 48 | return undefined // Omitted in objects, null in arrays (handled by caller) 49 | } 50 | if (typeof value === 'bigint') { 51 | throw new TypeError('Do not know how to serialize a BigInt') 52 | } 53 | if (isRegExp(value)) { 54 | return JSON.stringify(String(value)) 55 | } 56 | // Handle Date objects explicitly 57 | if (isDate(value)) { 58 | if (isFinite(value.getTime())) { 59 | // Stringify the ISO string to get the required quotes 60 | return JSON.stringify(value.toISOString()) 61 | } else { 62 | return 'null' // Invalid date becomes null 63 | } 64 | } 65 | 66 | // --- Value should be an Array or an Object-like entity --- 67 | 68 | // Ensure value is object type before circular check / adding to Set 69 | if (typeof value !== 'object' || value === null) { 70 | throw new Error( 71 | `Internal error: Unexpected non-object type: ${typeof value}`, 72 | ) 73 | } 74 | 75 | // 2. Circular reference check 76 | if (visited.has(value)) { 77 | throw new TypeError('Converting circular structure to JSON') 78 | } 79 | visited.add(value) // Add current object/array *before* recursive calls 80 | 81 | let result: string | undefined 82 | 83 | try { 84 | // 3. Handle Arrays using _.map 85 | if (isArray(value)) { 86 | const elements = map(value, (element: unknown): string => { 87 | const stringifiedElement = deepStringify(element, visited) 88 | // JSON spec: undefined/function/symbol array elements become null 89 | return stringifiedElement === undefined ? 'null' : stringifiedElement 90 | }) 91 | result = `[${elements.join(',')}]` 92 | } 93 | // 4. Handle Objects using Object.keys().forEach() 94 | else { 95 | // Should be an object type here 96 | const keys = Object.keys(value) // Get own enumerable string keys 97 | const properties: string[] = [] // Array to hold "key:value" strings 98 | 99 | keys.forEach((key) => { 100 | let stringifiedValue: string | undefined 101 | try { 102 | // *** Access the property inside try block *** 103 | // Use `any` assertion as TS doesn't know about arbitrary keys/getters 104 | const currentValue = (value as any)[key] 105 | stringifiedValue = deepStringify(currentValue, visited) // Recurse 106 | } catch (error: unknown) { 107 | // *** Handle getter error: stringify the error message *** 108 | let errorMessage = 'Error accessing property' 109 | if (error instanceof Error) { 110 | errorMessage = error.message 111 | } else if ( 112 | typeof error === 'object' && 113 | error !== null && 114 | 'message' in error && 115 | typeof error.message === 'string' 116 | ) { 117 | // Handle plain objects thrown with a message property 118 | errorMessage = error.message 119 | } else { 120 | try { 121 | errorMessage = String(error) 122 | } catch { 123 | /* ignore */ 124 | } 125 | } 126 | // Use native stringify to quote and escape the error message string 127 | stringifiedValue = JSON.stringify(errorMessage) 128 | } 129 | 130 | // Omit properties whose values stringify to undefined 131 | if (stringifiedValue !== undefined) { 132 | // Keys in JSON objects must be strings. Stringify ensures quotes. 133 | const stringifiedKey = JSON.stringify(key) 134 | properties.push(`${stringifiedKey}:${stringifiedValue}`) 135 | } 136 | }) // end forEach key 137 | 138 | result = `{${properties.join(',')}}` 139 | } 140 | } finally { 141 | // 5. Crucial: Remove from visited set *after* processing children/throwing errors 142 | visited.delete(value) 143 | } 144 | 145 | return result 146 | } 147 | -------------------------------------------------------------------------------- /src/utils/file-stat-to-stat-model.ts: -------------------------------------------------------------------------------- 1 | import { FileStat } from 'webdav' 2 | import { StatModel } from '~/model/stat.model' 3 | 4 | export function fileStatToStatModel(from: FileStat): StatModel { 5 | return { 6 | path: from.filename, 7 | basename: from.basename, 8 | isDir: from.type === 'directory', 9 | isDeleted: false, 10 | mtime: new Date(from.lastmod).valueOf(), 11 | size: from.size, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/get-db-key.ts: -------------------------------------------------------------------------------- 1 | import { objectHash } from 'ohash' 2 | import { stdRemotePath } from './std-remote-path' 3 | 4 | export function getDBKey(vaultName: string, remoteBaseDir: string) { 5 | return objectHash({ 6 | vaultName, 7 | remoteBaseDir: stdRemotePath(remoteBaseDir), 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/get-root-folder-name.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from 'path' 2 | 3 | export function getRootFolderName(path: string) { 4 | path = normalize(path) 5 | if (path.startsWith('/')) { 6 | path = path.slice(1) 7 | } 8 | return path.split('/')[0] 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/get-task-name.ts: -------------------------------------------------------------------------------- 1 | import i18n from '~/i18n' 2 | import ConflictResolveTask from '~/sync/tasks/conflict-resolve.task' 3 | import FilenameErrorTask from '~/sync/tasks/filename-error.task' 4 | import MkdirLocalTask from '~/sync/tasks/mkdir-local.task' 5 | import MkdirRemoteTask from '~/sync/tasks/mkdir-remote.task' 6 | import PullTask from '~/sync/tasks/pull.task' 7 | import PushTask from '~/sync/tasks/push.task' 8 | import RemoveLocalTask from '~/sync/tasks/remove-local.task' 9 | import RemoveRemoteTask from '~/sync/tasks/remove-remote.task' 10 | import { BaseTask } from '~/sync/tasks/task.interface' 11 | 12 | export default function getTaskName(task: BaseTask) { 13 | if (task instanceof ConflictResolveTask) { 14 | return i18n.t('sync.fileOp.merge') 15 | } 16 | if (task instanceof FilenameErrorTask) { 17 | return i18n.t('sync.fileOp.filenameError') 18 | } 19 | if (task instanceof MkdirLocalTask) { 20 | return i18n.t('sync.fileOp.createLocalDir') 21 | } 22 | if (task instanceof MkdirRemoteTask) { 23 | return i18n.t('sync.fileOp.createRemoteDir') 24 | } 25 | if (task instanceof PullTask) { 26 | return i18n.t('sync.fileOp.download') 27 | } 28 | if (task instanceof PushTask) { 29 | return i18n.t('sync.fileOp.upload') 30 | } 31 | if (task instanceof RemoveLocalTask) { 32 | return i18n.t('sync.fileOp.removeLocal') 33 | } 34 | if (task instanceof RemoveRemoteTask) { 35 | return i18n.t('sync.fileOp.removeRemote') 36 | } 37 | return i18n.t('sync.fileOp.sync') 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/glob-match.ts: -------------------------------------------------------------------------------- 1 | import GlobToRegExp from 'glob-to-regexp' 2 | import { cloneDeep } from 'lodash-es' 3 | import { basename } from 'path' 4 | 5 | export interface GlobMatchUserOptions { 6 | caseSensitive: boolean 7 | } 8 | 9 | export interface GlobMatchOptions { 10 | expr: string 11 | options: GlobMatchUserOptions 12 | } 13 | 14 | const DEFAULT_USER_OPTIONS: GlobMatchUserOptions = { 15 | caseSensitive: false, 16 | } 17 | 18 | export function isVoidGlobMatchOptions(options: GlobMatchOptions): boolean { 19 | return options.expr.trim() === '' 20 | } 21 | 22 | function generateFlags(options: GlobMatchUserOptions) { 23 | let flags = '' 24 | if (!options.caseSensitive) { 25 | flags += 'i' 26 | } 27 | return flags 28 | } 29 | 30 | export default class GlobMatch { 31 | re: RegExp 32 | 33 | constructor( 34 | public expr: string, 35 | public options: GlobMatchUserOptions, 36 | ) { 37 | this.re = GlobToRegExp(this.expr, { 38 | flags: generateFlags(options), 39 | extended: true, 40 | }) 41 | } 42 | 43 | test(path: string) { 44 | if (path === this.expr) { 45 | return true 46 | } 47 | const name = basename(path) 48 | if (name === this.expr) { 49 | return true 50 | } 51 | if (this.re.test(path) || this.re.test(name)) { 52 | return true 53 | } 54 | return false 55 | } 56 | } 57 | 58 | export function getUserOptions(opt: GlobMatchOptions): GlobMatchUserOptions { 59 | if (typeof opt === 'string') { 60 | return cloneDeep(DEFAULT_USER_OPTIONS) 61 | } 62 | return opt.options ?? cloneDeep(DEFAULT_USER_OPTIONS) 63 | } 64 | 65 | export function needIncludeFromGlobRules( 66 | path: string, 67 | inclusion: GlobMatch[], 68 | exclusion: GlobMatch[], 69 | ) { 70 | for (const rule of inclusion) { 71 | if (rule.test(path)) { 72 | return true 73 | } 74 | } 75 | for (const rule of exclusion) { 76 | if (rule.test(path)) { 77 | return false 78 | } 79 | } 80 | return true 81 | } 82 | 83 | /** 84 | * 如果忽略/包含了某文件夹,例如:.git,那也应该忽略/包含里面的所有文件。 85 | * 86 | * 即: .git = .git + .git/* 87 | */ 88 | export function extendRules(rules: GlobMatch[]) { 89 | rules = [...rules] 90 | for (const { expr, options } of rules) { 91 | if (expr.endsWith('*')) { 92 | continue 93 | } 94 | const newRule = new GlobMatch( 95 | `${expr.endsWith('/') ? expr : expr + '/'}*`, 96 | options, 97 | ) 98 | rules.push(newRule) 99 | } 100 | return rules 101 | } 102 | -------------------------------------------------------------------------------- /src/utils/has-invalid-char.ts: -------------------------------------------------------------------------------- 1 | export function hasInvalidChar(str: string) { 2 | return ':*?"<>|'.split('').some((c) => str.includes(c)) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/is-503-error.ts: -------------------------------------------------------------------------------- 1 | const ERROR_MESSAGE = 'Invalid response: 503 Service Unavailable' 2 | 3 | export function is503Error(err: Error | string) { 4 | if (err instanceof Error) { 5 | return err.message === ERROR_MESSAGE 6 | } 7 | return err === ERROR_MESSAGE 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/is-binary-file.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer' 2 | 3 | /** 4 | * fork: https://github.com/gjtorikian/isBinaryFile/blob/main/src/index.ts 5 | * 6 | * remove `node:fs` dep 7 | */ 8 | 9 | const MAX_BYTES: number = 512 10 | 11 | // A very basic non-exception raising reader. Read bytes and 12 | // at the end use hasError() to check whether this worked. 13 | class Reader { 14 | public fileBuffer: Buffer 15 | public size: number 16 | public offset: number 17 | public error: boolean 18 | 19 | constructor(fileBuffer: Buffer, size: number) { 20 | this.fileBuffer = fileBuffer 21 | this.size = size 22 | this.offset = 0 23 | this.error = false 24 | } 25 | 26 | public hasError(): boolean { 27 | return this.error 28 | } 29 | 30 | public nextByte(): number { 31 | if (this.offset === this.size || this.hasError()) { 32 | this.error = true 33 | return 0xff 34 | } 35 | return this.fileBuffer[this.offset++] 36 | } 37 | 38 | public next(len: number): number[] { 39 | const n = new Array() 40 | for (let i = 0; i < len; i++) { 41 | n[i] = this.nextByte() 42 | } 43 | return n 44 | } 45 | } 46 | 47 | // Read a Google Protobuf var(iable)int from the buffer. 48 | function readProtoVarInt(reader: Reader): number { 49 | let idx = 0 50 | let varInt = 0 51 | 52 | while (!reader.hasError()) { 53 | const b = reader.nextByte() 54 | varInt = varInt | ((b & 0x7f) << (7 * idx)) 55 | if ((b & 0x80) === 0) { 56 | break 57 | } 58 | idx++ 59 | } 60 | 61 | return varInt 62 | } 63 | 64 | // Attempt to taste a full Google Protobuf message. 65 | function readProtoMessage(reader: Reader): boolean { 66 | const varInt = readProtoVarInt(reader) 67 | const wireType = varInt & 0x7 68 | 69 | switch (wireType) { 70 | case 0: 71 | readProtoVarInt(reader) 72 | return true 73 | case 1: 74 | reader.next(8) 75 | return true 76 | case 2: 77 | const len = readProtoVarInt(reader) 78 | reader.next(len) 79 | return true 80 | case 5: 81 | reader.next(4) 82 | return true 83 | } 84 | return false 85 | } 86 | 87 | // Check whether this seems to be a valid protobuf file. 88 | function isBinaryProto(fileBuffer: Buffer, totalBytes: number): boolean { 89 | const reader = new Reader(fileBuffer, totalBytes) 90 | let numMessages = 0 91 | 92 | while (true) { 93 | // Definitely not a valid protobuf 94 | if (!readProtoMessage(reader) && !reader.hasError()) { 95 | return false 96 | } 97 | // Short read? 98 | if (reader.hasError()) { 99 | break 100 | } 101 | numMessages++ 102 | } 103 | 104 | return numMessages > 0 105 | } 106 | 107 | export async function isBinaryFile( 108 | file: Buffer | ArrayBuffer, 109 | size?: number, 110 | ): Promise { 111 | let fileBuffer: Buffer 112 | if (file instanceof ArrayBuffer) { 113 | fileBuffer = Buffer.from(file) 114 | } else { 115 | fileBuffer = file 116 | } 117 | if (size === undefined) { 118 | size = fileBuffer.length 119 | } 120 | return isBinaryCheck(fileBuffer, size) 121 | } 122 | 123 | function isBinaryCheck(fileBuffer: Buffer, bytesRead: number): boolean { 124 | // empty file. no clue what it is. 125 | if (bytesRead === 0) { 126 | return false 127 | } 128 | 129 | let suspiciousBytes = 0 130 | const totalBytes = Math.min(bytesRead, MAX_BYTES) 131 | 132 | // UTF-8 BOM 133 | if ( 134 | bytesRead >= 3 && 135 | fileBuffer[0] === 0xef && 136 | fileBuffer[1] === 0xbb && 137 | fileBuffer[2] === 0xbf 138 | ) { 139 | return false 140 | } 141 | 142 | // UTF-32 BOM 143 | if ( 144 | bytesRead >= 4 && 145 | fileBuffer[0] === 0x00 && 146 | fileBuffer[1] === 0x00 && 147 | fileBuffer[2] === 0xfe && 148 | fileBuffer[3] === 0xff 149 | ) { 150 | return false 151 | } 152 | 153 | // UTF-32 LE BOM 154 | if ( 155 | bytesRead >= 4 && 156 | fileBuffer[0] === 0xff && 157 | fileBuffer[1] === 0xfe && 158 | fileBuffer[2] === 0x00 && 159 | fileBuffer[3] === 0x00 160 | ) { 161 | return false 162 | } 163 | 164 | // GB BOM 165 | if ( 166 | bytesRead >= 4 && 167 | fileBuffer[0] === 0x84 && 168 | fileBuffer[1] === 0x31 && 169 | fileBuffer[2] === 0x95 && 170 | fileBuffer[3] === 0x33 171 | ) { 172 | return false 173 | } 174 | 175 | if (totalBytes >= 5 && fileBuffer.slice(0, 5).toString() === '%PDF-') { 176 | /* PDF. This is binary. */ 177 | return true 178 | } 179 | 180 | // UTF-16 BE BOM 181 | if (bytesRead >= 2 && fileBuffer[0] === 0xfe && fileBuffer[1] === 0xff) { 182 | return false 183 | } 184 | 185 | // UTF-16 LE BOM 186 | if (bytesRead >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe) { 187 | return false 188 | } 189 | 190 | for (let i = 0; i < totalBytes; i++) { 191 | if (fileBuffer[i] === 0) { 192 | // NULL byte--it's binary! 193 | return true 194 | } else if ( 195 | (fileBuffer[i] < 7 || fileBuffer[i] > 14) && 196 | (fileBuffer[i] < 32 || fileBuffer[i] > 127) 197 | ) { 198 | // UTF-8 detection 199 | if ( 200 | fileBuffer[i] >= 0xc0 && 201 | fileBuffer[i] <= 0xdf && 202 | i + 1 < totalBytes 203 | ) { 204 | i++ 205 | if (fileBuffer[i] >= 0x80 && fileBuffer[i] <= 0xbf) { 206 | continue 207 | } 208 | } else if ( 209 | fileBuffer[i] >= 0xe0 && 210 | fileBuffer[i] <= 0xef && 211 | i + 2 < totalBytes 212 | ) { 213 | i++ 214 | if ( 215 | fileBuffer[i] >= 0x80 && 216 | fileBuffer[i] <= 0xbf && 217 | fileBuffer[i + 1] >= 0x80 && 218 | fileBuffer[i + 1] <= 0xbf 219 | ) { 220 | i++ 221 | continue 222 | } 223 | } else if ( 224 | fileBuffer[i] >= 0xf0 && 225 | fileBuffer[i] <= 0xf7 && 226 | i + 3 < totalBytes 227 | ) { 228 | i++ 229 | if ( 230 | fileBuffer[i] >= 0x80 && 231 | fileBuffer[i] <= 0xbf && 232 | fileBuffer[i + 1] >= 0x80 && 233 | fileBuffer[i + 1] <= 0xbf && 234 | fileBuffer[i + 2] >= 0x80 && 235 | fileBuffer[i + 2] <= 0xbf 236 | ) { 237 | i += 2 238 | continue 239 | } 240 | } 241 | 242 | suspiciousBytes++ 243 | // Read at least 32 fileBuffer before making a decision 244 | if (i >= 32 && (suspiciousBytes * 100) / totalBytes > 10) { 245 | return true 246 | } 247 | } 248 | } 249 | 250 | if ((suspiciousBytes * 100) / totalBytes > 10) { 251 | return true 252 | } 253 | 254 | if (suspiciousBytes > 1 && isBinaryProto(fileBuffer, totalBytes)) { 255 | return true 256 | } 257 | 258 | return false 259 | } 260 | 261 | function isString(x: any): x is string { 262 | return typeof x === 'string' 263 | } 264 | -------------------------------------------------------------------------------- /src/utils/is-sub.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from 'path' 2 | 3 | export function isSub(parent: string, sub: string) { 4 | parent = normalize(parent) 5 | sub = normalize(sub) 6 | if (!parent.endsWith('/')) { 7 | parent += '/' 8 | } 9 | if (!sub.endsWith('/')) { 10 | sub += '/' 11 | } 12 | if (sub === parent) { 13 | return false 14 | } 15 | return sub.startsWith(parent) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { createConsola, LogLevels } from 'consola' 2 | 3 | const logger = createConsola({ 4 | level: LogLevels.verbose, 5 | formatOptions: { 6 | date: true, 7 | colors: false, 8 | }, 9 | }) 10 | 11 | export default logger 12 | -------------------------------------------------------------------------------- /src/utils/logs-stringify.ts: -------------------------------------------------------------------------------- 1 | import deepStringify from './deep-stringify' 2 | 3 | export default function (logs: any) { 4 | if (typeof logs === 'string') { 5 | return logs 6 | } 7 | try { 8 | return JSON.stringify(logs) 9 | } catch { 10 | try { 11 | return deepStringify(logs) 12 | } catch {} 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/merge-dig-in.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import { diff3Merge, diffComm } from 'node-diff3' 4 | 5 | /** 6 | * https://github.com/bhousel/node-diff3/blob/39c04c024620d3971010abf4ba3e2cbdba2f3f81/index.mjs#L464 7 | */ 8 | export function mergeDigIn( 9 | a: string[] | string, 10 | o: string[] | string, 11 | b: string[] | string, 12 | options: { 13 | excludeFalseConflicts?: boolean 14 | stringSeparator?: string | RegExp 15 | useGitStyle?: boolean 16 | }, 17 | ) { 18 | const defaults = { 19 | excludeFalseConflicts: true, 20 | stringSeparator: /\s+/, 21 | label: {}, 22 | useGitStyle: false, 23 | } 24 | options = Object.assign(defaults, options) 25 | 26 | const aSection = options.useGitStyle 27 | ? '<<<<<<<' 28 | : `` 29 | const xSection = options.useGitStyle 30 | ? '=======' 31 | : '' 32 | const bSection = options.useGitStyle ? '>>>>>>>' : `` 33 | 34 | const regions = diff3Merge(a, o, b, options) 35 | let conflict = false 36 | let result: string[] = [] 37 | 38 | regions.forEach((region) => { 39 | if (region.ok) { 40 | result = result.concat(region.ok) 41 | } else { 42 | const c = diffComm(region.conflict!.a, region.conflict!.b) 43 | for (let j = 0; j < c.length; j++) { 44 | let inner = c[j] 45 | if (inner.common) { 46 | result = result.concat(inner.common) 47 | } else { 48 | conflict = true 49 | result = result.concat( 50 | [aSection], 51 | inner.buffer1, 52 | [xSection], 53 | inner.buffer2, 54 | [bSection], 55 | ) 56 | } 57 | } 58 | } 59 | }) 60 | 61 | return { 62 | conflict: conflict, 63 | result: result, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/mkdirs-vault.ts: -------------------------------------------------------------------------------- 1 | import { Vault } from 'obsidian' 2 | import { dirname, normalize } from 'path' 3 | 4 | export async function mkdirsVault(vault: Vault, path: string) { 5 | const stack: string[] = [] 6 | let currentPath = normalize(path) 7 | if (currentPath === '/' || currentPath === '.') { 8 | return 9 | } 10 | if (await vault.adapter.exists(currentPath)) { 11 | return 12 | } 13 | while (true) { 14 | if (await vault.adapter.exists(currentPath)) { 15 | break 16 | } 17 | stack.push(currentPath) 18 | currentPath = dirname(currentPath) 19 | } 20 | while (stack.length) { 21 | await vault.adapter.mkdir(stack.pop()!) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/mkdirs-webdav.ts: -------------------------------------------------------------------------------- 1 | import { WebDAVClient } from 'webdav' 2 | 3 | export function mkdirsWebDAV(client: WebDAVClient, path: string) { 4 | return client.createDirectory(path, { 5 | recursive: true, 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/ns-api.ts: -------------------------------------------------------------------------------- 1 | import { NS_NSDAV_ENDPOINT } from '~/consts' 2 | 3 | export function NSAPI(name: 'delta' | 'latestDeltaCursor') { 4 | return `${NS_NSDAV_ENDPOINT}/${name}` 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/rate-limited-client.ts: -------------------------------------------------------------------------------- 1 | import { WebDAVClient } from 'webdav' 2 | import { apiLimiter } from './api-limiter' 3 | 4 | export function createRateLimitedWebDAVClient( 5 | client: WebDAVClient, 6 | ): WebDAVClient { 7 | return new Proxy(client, { 8 | get(target, prop, receiver) { 9 | const value = Reflect.get(target, prop, receiver) 10 | if (typeof value === 'function') { 11 | return (...args: any[]) => { 12 | return apiLimiter.schedule(() => value.apply(target, args)) 13 | } 14 | } 15 | return value 16 | }, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/remote-path-to-absolute.ts: -------------------------------------------------------------------------------- 1 | import { isAbsolute, join } from 'path' 2 | 3 | export default function remotePathToAbsolute( 4 | remoteBaseDir: string, 5 | remotePath: string, 6 | ): string { 7 | return isAbsolute(remotePath) ? remotePath : join(remoteBaseDir, remotePath) 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/remote-path-to-local-path.ts: -------------------------------------------------------------------------------- 1 | import { isAbsolute, normalize } from 'path' 2 | 3 | export function remotePathToLocalPath( 4 | remoteBaseDir: string, 5 | remotePath: string, 6 | ) { 7 | remoteBaseDir = normalize(remoteBaseDir) 8 | remotePath = normalize(remotePath) 9 | remotePath = 10 | isAbsolute(remotePath) && remotePath.startsWith(remoteBaseDir) 11 | ? remotePath.replace(remoteBaseDir, '') 12 | : remotePath 13 | return normalize(remotePath) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/request-url.ts: -------------------------------------------------------------------------------- 1 | import { 2 | requestUrl as req, 3 | RequestUrlParam, 4 | RequestUrlResponse, 5 | } from 'obsidian' 6 | import logger from './logger' 7 | 8 | class RequestUrlError extends Error { 9 | constructor(public res: RequestUrlResponse) { 10 | super(`${res.status}: ${res.text}`) 11 | } 12 | } 13 | 14 | export default async function requestUrl(p: RequestUrlParam | string) { 15 | let res: RequestUrlResponse 16 | let throwError = true 17 | if (typeof p === 'string') { 18 | res = await req({ 19 | url: p, 20 | throw: false, 21 | }) 22 | } else if (p.throw !== false) { 23 | res = await req({ 24 | ...p, 25 | throw: false, 26 | }) 27 | } else { 28 | res = await req(p) 29 | throwError = false 30 | } 31 | if (res.status >= 400) { 32 | logger.error(res) 33 | if (throwError) { 34 | throw new RequestUrlError(res) 35 | } 36 | } 37 | return res 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/sha256.ts: -------------------------------------------------------------------------------- 1 | import { fromUint8Array } from 'js-base64' 2 | 3 | export async function sha256(data: ArrayBuffer) { 4 | return crypto.subtle.digest('SHA-256', data) 5 | } 6 | 7 | export async function sha256Hex(data: ArrayBuffer) { 8 | const hashBuffer = await sha256(data) 9 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 10 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') 11 | return hashHex 12 | } 13 | 14 | export async function sha256Base64(data: ArrayBuffer) { 15 | const hashBuffer = await sha256(data) 16 | const hashBase64 = fromUint8Array(new Uint8Array(hashBuffer), false) 17 | return hashBase64 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export default async function sleep(ms: number) { 2 | await new Promise((resolve) => setTimeout(resolve, ms)) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/stat-vault-item.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath, Vault } from 'obsidian' 2 | import { basename } from 'path' 3 | import { StatModel } from '~/model/stat.model' 4 | 5 | export async function statVaultItem( 6 | vault: Vault, 7 | path: string, 8 | ): Promise { 9 | const stat = await vault.adapter.stat(normalizePath(path)) 10 | if (!stat) { 11 | return undefined 12 | } 13 | return { 14 | path, 15 | basename: basename(path), 16 | isDir: stat.type === 'folder', 17 | isDeleted: false, 18 | mtime: new Date(stat.mtime).valueOf(), 19 | size: stat.size, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/stat-webdav-item.ts: -------------------------------------------------------------------------------- 1 | import { isAbsolute } from 'path' 2 | import { FileStat, WebDAVClient } from 'webdav' 3 | import { fileStatToStatModel } from './file-stat-to-stat-model' 4 | 5 | export async function statWebDAVItem(client: WebDAVClient, path: string) { 6 | if (!isAbsolute(path)) { 7 | throw new Error('stat WebDAV item, path must be absolute: ' + path) 8 | } 9 | const stat = (await client.stat(path, { 10 | details: false, 11 | })) as FileStat 12 | return fileStatToStatModel(stat) 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/std-remote-path.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from 'path' 2 | 3 | export function stdRemotePath(remotePath: string): `/${string}/` { 4 | if (!remotePath.startsWith('/')) { 5 | remotePath = `/${remotePath}` 6 | } 7 | if (!remotePath.endsWith('/')) { 8 | remotePath = `${remotePath}/` 9 | } 10 | return normalize(remotePath) as `/${string}/` 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/traverse-local-vault.ts: -------------------------------------------------------------------------------- 1 | import { isNil, partial } from 'lodash-es' 2 | import { normalizePath, Vault } from 'obsidian' 3 | import { isNotNil } from 'ramda' 4 | import { StatModel } from '~/model/stat.model' 5 | import GlobMatch from './glob-match' 6 | import { statVaultItem } from './stat-vault-item' 7 | 8 | export async function traverseLocalVault(vault: Vault, from: string) { 9 | const res: StatModel[] = [] 10 | const q = [from] 11 | const ignores = [ 12 | new GlobMatch(`${vault.configDir}/plugins/*/node_modules`, { 13 | caseSensitive: true, 14 | }), 15 | ] 16 | function folderFilter(path: string) { 17 | path = normalizePath(path) 18 | if (ignores.some((rule) => rule.test(path))) { 19 | return false 20 | } 21 | return true 22 | } 23 | 24 | while (q.length > 0) { 25 | const from = q.shift() 26 | if (isNil(from)) { 27 | continue 28 | } 29 | let { files, folders } = await vault.adapter.list(from) 30 | folders = folders.filter(folderFilter) 31 | q.push(...folders) 32 | const contents = await Promise.all( 33 | [...files, ...folders].map(partial(statVaultItem, vault)), 34 | ).then((arr) => arr.filter(isNotNil)) 35 | res.push(...contents) 36 | } 37 | return res 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/traverse-webdav.ts: -------------------------------------------------------------------------------- 1 | import { getDirectoryContents } from '~/api/webdav' 2 | import { StatModel } from '~/model/stat.model' 3 | import { apiLimiter } from './api-limiter' 4 | import { fileStatToStatModel } from './file-stat-to-stat-model' 5 | 6 | const getContents = apiLimiter.wrap(getDirectoryContents) 7 | 8 | export async function traverseWebDAV( 9 | token: string, 10 | from: string = '', 11 | ): Promise { 12 | const contents = await getContents(token, from) 13 | return [ 14 | contents.map(fileStatToStatModel), 15 | await Promise.all( 16 | contents 17 | .filter((item) => item.type === 'directory') 18 | .map((item) => traverseWebDAV(token, item.filename)), 19 | ), 20 | ].flat(2) 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type MaybePromise = Promise | T 2 | -------------------------------------------------------------------------------- /src/utils/wait-until.ts: -------------------------------------------------------------------------------- 1 | import sleep from './sleep' 2 | 3 | export default async function waitUntil(condition: () => T, duration = 100) { 4 | while (true) { 5 | const result = await Promise.resolve(condition()) 6 | if (result) { 7 | return result 8 | } 9 | await sleep(duration) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/webdav-patch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Patch webdav request to use obsidian's requestUrl 3 | * 4 | * reference: https://github.com/remotely-save/remotely-save/blob/34db181af002f8d71ea0a87e7965abc57b294914/src/fsWebdav.ts#L25 5 | */ 6 | import { getReasonPhrase } from 'http-status-codes/build/cjs/utils-functions' 7 | import { Platform, RequestUrlParam } from 'obsidian' 8 | import { RequestOptionsWithState } from 'webdav' 9 | import requestUrl from './utils/request-url' 10 | // @ts-ignore 11 | import { getPatcher } from 'webdav/dist/web/index.js' 12 | import { VALID_REQURL } from '~/consts' 13 | 14 | /** 15 | * https://stackoverflow.com/questions/12539574/ 16 | * @param obj 17 | * @returns 18 | */ 19 | function objKeyToLower(obj: Record) { 20 | return Object.fromEntries( 21 | Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v]), 22 | ) 23 | } 24 | 25 | /** 26 | * https://stackoverflow.com/questions/32850898/how-to-check-if-a-string-has-any-non-iso-8859-1-characters-with-javascript 27 | * @param str 28 | * @returns true if all are iso 8859 1 chars 29 | */ 30 | function onlyAscii(str: string) { 31 | return !/[^\u0000-\u00ff]/g.test(str) 32 | } 33 | 34 | if (VALID_REQURL) { 35 | getPatcher().patch( 36 | 'request', 37 | async (options: RequestOptionsWithState): Promise => { 38 | const transformedHeaders = objKeyToLower({ ...options.headers }) 39 | delete transformedHeaders['host'] 40 | delete transformedHeaders['content-length'] 41 | 42 | const reqContentType = 43 | transformedHeaders['accept'] ?? transformedHeaders['content-type'] 44 | 45 | const retractedHeaders = { ...transformedHeaders } 46 | if (retractedHeaders.hasOwnProperty('authorization')) { 47 | retractedHeaders['authorization'] = '' 48 | } 49 | 50 | const p: RequestUrlParam = { 51 | url: options.url, 52 | method: options.method, 53 | body: options.data as string | ArrayBuffer, 54 | headers: transformedHeaders, 55 | contentType: reqContentType, 56 | throw: false, 57 | } 58 | 59 | let r = await requestUrl(p) 60 | 61 | if ( 62 | r.status === 401 && 63 | Platform.isIosApp && 64 | !options.url.endsWith('/') && 65 | !options.url.endsWith('.md') && 66 | options.method.toUpperCase() === 'PROPFIND' 67 | ) { 68 | p.url = `${options.url}/` 69 | r = await requestUrl(p) 70 | } 71 | const rspHeaders = objKeyToLower({ ...r.headers }) 72 | for (const key in rspHeaders) { 73 | if (rspHeaders.hasOwnProperty(key)) { 74 | if (!onlyAscii(rspHeaders[key])) { 75 | rspHeaders[key] = encodeURIComponent(rspHeaders[key]) 76 | } 77 | } 78 | } 79 | 80 | let r2: Response | undefined = undefined 81 | const statusText = getReasonPhrase(r.status) 82 | if ([101, 103, 204, 205, 304].includes(r.status)) { 83 | r2 = new Response(null, { 84 | status: r.status, 85 | statusText: statusText, 86 | headers: rspHeaders, 87 | }) 88 | } else { 89 | r2 = new Response(r.arrayBuffer, { 90 | status: r.status, 91 | statusText: statusText, 92 | headers: rspHeaders, 93 | }) 94 | } 95 | 96 | return r2 97 | }, 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "~/*": ["./src/*"], 6 | "~": ["./src"] 7 | }, 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "module": "ESNext", 11 | "target": "ES6", 12 | "allowJs": true, 13 | "noImplicitAny": true, 14 | "moduleResolution": "bundler", 15 | "importHelpers": true, 16 | "isolatedModules": true, 17 | "strictNullChecks": true, 18 | "allowSyntheticDefaultImports": true, 19 | "lib": ["DOM", "ES5", "ES6", "ES7"] 20 | }, 21 | "include": ["**/*.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetUno } from 'unocss' 2 | 3 | export default defineConfig({ 4 | content: { 5 | filesystem: ['src/**/*.{html,js,ts,jsx,tsx,vue,svelte,astro}'], 6 | }, 7 | rules: [[/^background-none$/, () => ({ background: 'none' })]], 8 | presets: [presetUno()], 9 | }) 10 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | --------------------------------------------------------------------------------