Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions docs/115-indexing-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# 115 Cloud Storage Indexing API

This document describes the HTTP API endpoints for the 115 cloud storage indexing feature.

## Endpoints

### 1. Import Batch - POST /api/fs/115/import-batch

Import multiple 115 cloud storage nodes into the search index.

**Authentication:** Admin only

**Request Body:**
```json
{
"nodes": [
{
"path": "/movies/action/movie1.mp4",
"name": "movie1.mp4",
"size": 1073741824,
"is_dir": false,
"modified": "2024-01-01T00:00:00Z",
"parent_path": "/movies/action",
"depth": 2,
"child_count": 0
}
]
}
```

**Response:**
```json
{
"success": true,
"imported_count": 1,
"failed_count": 0,
"message": "Successfully imported 1 nodes"
}
```

### 2. Search - GET/POST /api/fs/115/search

Search indexed 115 cloud storage nodes.

**Authentication:** Authenticated users

**Request Body:**
```json
{
"query": "movie",
"page": 1,
"per_page": 20,
"scope": 0
}
```

**Parameters:**
- `query` (string, required): Search query
- `page` (int, optional): Page number (default: 1)
- `per_page` (int, optional): Results per page (default: 20, max: 100)
- `scope` (int, optional): Search scope
- `0`: All (files and folders) - default
- `1`: Folders only
- `2`: Files only

**Response:**
```json
{
"query": "movie",
"total": 100,
"results": [
{
"path": "/movies/action/movie1.mp4",
"name": "movie1.mp4",
"size": 1073741824,
"is_dir": false
}
]
}
```

### 3. Clear Index - DELETE /api/fs/115/clear

Clear the entire 115 index.

**Authentication:** Admin only

**Response:**
```json
{
"message": "Successfully cleared 115 index"
}
```

## Implementation Details

- Index location: `{dataDir}/indexes/115`
- Batch processing: Maximum 10,000 nodes per batch
- Path mapping: Emoji prefixes are automatically removed
- Document ID: Generated from SHA-256 hash of path (idempotent updates)
- Search sorting: Results sorted by indexed_at descending (most recent first)

## Usage Example

```bash
# Import nodes
curl -X POST http://localhost:5244/api/fs/115/import-batch \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"nodes":[{"path":"/test.txt","name":"test.txt","size":100,"is_dir":false}]}'

# Search
curl -X POST http://localhost:5244/api/fs/115/search \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"test","page":1,"per_page":20,"scope":0}'

# Clear index
curl -X DELETE http://localhost:5244/api/fs/115/clear \
-H "Authorization: Bearer YOUR_TOKEN"
```
21 changes: 21 additions & 0 deletions internal/bootstrap/alist115.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package bootstrap

import (
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/server/handles"
log "github.com/sirupsen/logrus"
)

func InitAlist115() {
dataDir := conf.Conf.Database.DBFile
if dataDir == "" {
dataDir = "data"
}

if err := handles.InitAlist115(dataDir); err != nil {
log.Errorf("init alist115 error: %+v", err)
return
}

log.Info("alist115 service initialized successfully")
}
1 change: 1 addition & 0 deletions internal/bootstrap/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func Init() {
data.InitData()
InitStreamLimit()
InitIndex()
InitAlist115()
InitUpgradePatch()
}

Expand Down
54 changes: 54 additions & 0 deletions internal/search/alist115/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Package alist115 provides data models and indexing support for 115 cloud storage.
// It enables full-text search across 115 cloud files and directories through
// bleve indexing and provides API structures for import and search operations.
package alist115

import "time"

// IndexNode represents a file or directory node in the 115 index
type IndexNode struct {
Path string `json:"path"` // Full path
Name string `json:"name"` // File/directory name
Size int64 `json:"size"` // File size in bytes
IsDir bool `json:"is_dir"` // Whether this is a directory
Modified time.Time `json:"modified"` // Last modified time
ParentPath string `json:"parent_path"` // Parent directory path
Depth int `json:"depth"` // Directory depth (0 for root)
ChildCount int `json:"child_count"` // Number of children (for directories)
}

// ImportBatchRequest represents a batch import request
type ImportBatchRequest struct {
Nodes []IndexNode `json:"nodes"` // Nodes to import
}

// ImportBatchResponse represents a batch import response
type ImportBatchResponse struct {
Success bool `json:"success"` // Whether import succeeded
ImportedCount int `json:"imported_count"` // Number of nodes imported
FailedCount int `json:"failed_count"` // Number of nodes that failed
Message string `json:"message"` // Status message
}

// SearchRequest represents a search request
type SearchRequest struct {
Query string `json:"query"` // Search query
Page int `json:"page"` // Page number (1-based)
PerPage int `json:"per_page"` // Results per page
Scope int `json:"scope"` // Search scope: 0=all, 1=folder only, 2=file only
}

// SearchResponse represents a search response
type SearchResponse struct {
Query string `json:"query"` // Original query
Total int `json:"total"` // Total number of results
Results []SearchNode `json:"results"` // Search results
}

// SearchNode represents a search result node
type SearchNode struct {
Path string `json:"path"` // Full path
Name string `json:"name"` // File/directory name
Size int64 `json:"size"` // File size in bytes
IsDir bool `json:"is_dir"` // Whether this is a directory
}
33 changes: 33 additions & 0 deletions internal/search/alist115/path_mapper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package alist115

import (
"strings"
)

// MapPath removes the specific emoji prefix added by webdavsim to 115 cloud storage paths.
// webdavsim prepends 🏷️ to mounted 115 share paths, and this function normalizes them
// for indexing and search.
//
// Supported transformation:
//
// /🏷️我的115分享/ → /我的115分享/
//
// Note: Only the specific emoji 🏷️ from webdavsim is handled. Other emojis in paths
// are intentionally left unchanged as they may be legitimate user content.
//
// Example:
//
// MapPath("/🏷️我的115分享/folder/file.txt") → "/我的115分享/folder/file.txt"
// MapPath("/other-emoji😀/file.txt") → "/other-emoji😀/file.txt" (unchanged)
func MapPath(path string) string {
if path == "" || path == "/" {
return path
}

// Remove the emoji prefix 🏷️ (keeping the leading /)
if strings.HasPrefix(path, "/🏷️") {
return "/" + strings.TrimPrefix(path, "/🏷️")
}

return path
}
68 changes: 68 additions & 0 deletions internal/search/alist115/path_mapper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package alist115

import (
"testing"
)

func TestMapPath(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "removes emoji prefix",
input: "/🏷️我的115分享/folder/file.txt",
expected: "/我的115分享/folder/file.txt",
},
{
name: "handles path without emoji",
input: "/我的115分享/folder/file.txt",
expected: "/我的115分享/folder/file.txt",
},
{
name: "handles root path with emoji",
input: "/🏷️我的115分享/",
expected: "/我的115分享/",
},
{
name: "handles root path without emoji",
input: "/我的115分享/",
expected: "/我的115分享/",
},
{
name: "handles nested paths with emoji",
input: "/🏷️我的115分享/深层/目录/文件.mp4",
expected: "/我的115分享/深层/目录/文件.mp4",
},
{
name: "handles empty path",
input: "",
expected: "",
},
{
name: "handles single slash",
input: "/",
expected: "/",
},
{
name: "other emojis pass through unchanged",
input: "/folder😀/file🎉.txt",
expected: "/folder😀/file🎉.txt",
},
{
name: "other emoji at start passes through",
input: "/📁my-folder/file.txt",
expected: "/📁my-folder/file.txt",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MapPath(tt.input)
if result != tt.expected {
t.Errorf("MapPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
Loading
Loading