向前兼容memos到v0.18

Introduce compatibility layers and runtime detection for older Memos API variants (v0.18/v0.19/v0.20/v0.21/v0.23 and older). Added js/compat/memosApi.v023.js and js/compat/memosApi.v1.js to provide filter building, listing, upload and resource helpers for legacy endpoints. Updated js/oper.js to track apiFlavor, probe/detect flavor on save, and to adapt uploads, deletes, tag listing, search and preview image hydration to the appropriate API flavor (including buildV1ResourceStreamUrl, normalizeUnixTimeToMs, getMemoUid and other helpers). Also updated user-facing strings and README to reflect broader compatibility (locales and README changes) and minor UI/manifest popup adjustments. These changes enable the extension to work with a wider range of Memos server versions while preserving existing behavior for modern endpoints.
This commit is contained in:
jonny
2026-03-06 18:51:12 +08:00
parent 9a90c2e2bf
commit 30b7cf2491
11 changed files with 1067 additions and 77 deletions
+222
View File
@@ -0,0 +1,222 @@
(function (global) {
'use strict'
function buildFilter(parts) {
const p = parts || {}
const exprs = []
if (p.creator) {
// v0.23 expects a CEL string variable `creator`.
exprs.push('creator == ' + JSON.stringify(String(p.creator)))
}
if (Array.isArray(p.visibilities) && p.visibilities.length > 0) {
const list = p.visibilities.map(function (v) {
return JSON.stringify(String(v))
})
exprs.push('visibilities == [' + list.join(',') + ']')
}
if (typeof p.contentSearch === 'string' && p.contentSearch.length > 0) {
exprs.push('content_search == [' + JSON.stringify(String(p.contentSearch)) + ']')
}
if (typeof p.rowStatus === 'string' && p.rowStatus.length > 0) {
exprs.push('row_status == ' + JSON.stringify(String(p.rowStatus)))
}
if (Array.isArray(p.tagSearch) && p.tagSearch.length > 0) {
const list = p.tagSearch.map(function (t) {
return JSON.stringify(String(t).replace(/^#/, ''))
})
exprs.push('tag_search == [' + list.join(',') + ']')
}
if (typeof p.random === 'boolean') {
exprs.push('random == ' + (p.random ? 'true' : 'false'))
}
if (typeof p.limit === 'number' && Number.isFinite(p.limit) && p.limit > 0) {
exprs.push('limit == ' + String(Math.floor(p.limit)))
}
return exprs.join(' && ')
}
function extractTagsFromMemo(memo) {
if (!memo) return []
// v0.23: tags live in memo.property.tags
if (memo.property && Array.isArray(memo.property.tags)) return memo.property.tags
// Defensive: some versions/serializers may use `properties` instead of `property`.
if (memo.properties && Array.isArray(memo.properties.tags)) return memo.properties.tags
// Defensive: some JSON serializers may wrap repeated fields.
if (memo.property && memo.property.tags && Array.isArray(memo.property.tags.values)) {
return memo.property.tags.values
}
if (memo.properties && memo.properties.tags && Array.isArray(memo.properties.tags.values)) {
return memo.properties.tags.values
}
// Fallback: parse tags from content, e.g. "#tag".
const content = typeof memo.content === 'string' ? memo.content : ''
if (!content) return []
const found = []
// Match any hashtag token; server-side parser is stricter, but we want a lenient UI fallback.
const re = /#([^\s#]+)/g
let m
while ((m = re.exec(content))) {
let tag = m[1] || ''
// Trim trailing punctuation/brackets commonly attached in markdown.
tag = tag.replace(/[\]\[\)\(\}\{"'.,;:!?]+$/g, '')
tag = tag.replace(/^#+/, '')
tag = tag.trim()
if (!tag) continue
if (tag.length > 64) tag = tag.slice(0, 64)
found.push(tag)
}
return Array.from(new Set(found))
}
function listMemos(info, options, success, fail) {
const opt = options || {}
const pageSize = opt.pageSize && Number.isFinite(opt.pageSize) ? Math.max(1, Math.floor(opt.pageSize)) : 1000
const filterExpr = typeof opt.filterExpr === 'string' ? opt.filterExpr : ''
const qs =
'?pageSize=' +
encodeURIComponent(String(pageSize)) +
(filterExpr ? '&filter=' + encodeURIComponent(filterExpr) : '')
// v0.23 removed the user-scoped memos endpoint: `/api/v1/users/{id}/memos`.
// Don't reuse fetchMemosWithFallback() because it will always emit an extra 404 first.
global.$
.ajax({
url: info.apiUrl + 'api/v1/memos' + qs,
type: 'GET',
contentType: 'application/json',
dataType: 'json',
headers: { Authorization: 'Bearer ' + info.apiTokens }
})
.done(function (data) {
success(data)
})
.fail(function (xhr) {
if (fail) fail(xhr)
})
}
function probeApiFlavor(apiUrl, apiTokens, callback) {
const headers = { Authorization: 'Bearer ' + apiTokens }
function looksLikeMemosListPayload(data) {
if (!data) return false
if (Array.isArray(data)) return true
if (Array.isArray(data.memos)) return true
if (data.data && Array.isArray(data.data.memos)) return true
if (Array.isArray(data.list)) return true
// Common JSON error shapes should not be treated as success.
if (typeof data.error === 'string' || typeof data.message === 'string') return false
return false
}
function isNotFoundLike(xhr) {
const status = xhr && xhr.status
return status === 404 || status === 405
}
// Modern-style filter probe.
const modernQ =
'api/v1/memos?pageSize=1&filter=' +
encodeURIComponent('visibility in ["PUBLIC","PROTECTED"]')
// v0.23-style filter probe.
const v023Q =
'api/v1/memos?pageSize=1&filter=' +
encodeURIComponent('visibilities == ["PUBLIC","PROTECTED"]')
// v0.20/v0.21 unified API v1 probe.
const v1Q = 'api/v1/memo?limit=1&rowStatus=NORMAL'
global.$
.ajax({
url: apiUrl + modernQ,
method: 'GET',
headers: headers,
dataType: 'json'
})
.done(function (data) {
if (looksLikeMemosListPayload(data)) {
callback({ flavor: 'modern' })
return
}
// Treat unexpected success payload as unknown and continue probing.
global.$
.ajax({
url: apiUrl + v023Q,
method: 'GET',
headers: headers,
dataType: 'json'
})
.done(function (data2) {
if (looksLikeMemosListPayload(data2)) callback({ flavor: 'v023' })
else callback({ flavor: 'unknown' })
})
.fail(function () {
callback({ flavor: 'unknown' })
})
})
.fail(function (xhr) {
if (xhr && xhr.status === 400) {
global.$
.ajax({
url: apiUrl + v023Q,
method: 'GET',
headers: headers,
dataType: 'json'
})
.done(function (data2) {
if (looksLikeMemosListPayload(data2)) callback({ flavor: 'v023' })
else callback({ flavor: 'unknown' })
})
.fail(function () {
callback({ flavor: 'unknown' })
})
return
}
// If /api/v1/memos is missing, check /api/v1/memo (v0.20/v0.21 unified).
if (isNotFoundLike(xhr)) {
global.$
.ajax({
url: apiUrl + v1Q,
method: 'GET',
headers: headers,
dataType: 'json'
})
.done(function (data2) {
if (looksLikeMemosListPayload(data2)) callback({ flavor: 'v1' })
else callback({ flavor: 'unknown' })
})
.fail(function () {
callback({ flavor: 'unknown' })
})
return
}
callback({ flavor: 'unknown' })
})
}
global.MemosApiV023 = {
buildFilter: buildFilter,
listMemos: listMemos,
extractTagsFromMemo: extractTagsFromMemo,
probeApiFlavor: probeApiFlavor
}
})(window)
+286
View File
@@ -0,0 +1,286 @@
(function (global) {
'use strict'
function isNotFoundLikeXhr(jqXhr) {
const status = jqXhr && jqXhr.status
return status === 404 || status === 405
}
function extractMemoListFromResponse(data) {
if (!data) return []
if (Array.isArray(data)) return data
if (Array.isArray(data.memos)) return data.memos
if (data.data && Array.isArray(data.data.memos)) return data.data.memos
if (Array.isArray(data.list)) return data.list
return []
}
function extractMemoEntityFromResponse(data) {
if (!data) return data
if (data.memo) return data.memo
if (data.data && data.data.memo) return data.data.memo
if (data.data && (data.data.id != null || data.data.name || data.data.content)) return data.data
return data
}
function extractResourceEntityFromResponse(data) {
if (!data) return data
if (data.resource) return data.resource
if (data.data && data.data.resource) return data.data.resource
if (data.data && (data.data.id != null || data.data.name || data.data.filename)) return data.data
return data
}
function requestGet(url, headers, success, fail) {
global.$
.ajax({
url: url,
type: 'GET',
contentType: 'application/json',
dataType: 'json',
headers: headers
})
.done(function (data) {
if (success) success(data)
})
.fail(function (xhr) {
if (fail) fail(xhr)
})
}
function requestPostJson(url, headers, body, success, fail) {
global.$
.ajax({
url: url,
type: 'POST',
contentType: 'application/json',
dataType: 'json',
data: body != null ? JSON.stringify(body) : null,
headers: headers
})
.done(function (data) {
if (success) success(data)
})
.fail(function (xhr) {
if (fail) fail(xhr)
})
}
function requestPatchJson(url, headers, body, success, fail) {
global.$
.ajax({
url: url,
type: 'PATCH',
contentType: 'application/json',
dataType: 'json',
data: body != null ? JSON.stringify(body) : null,
headers: headers
})
.done(function (data) {
if (success) success(data)
})
.fail(function (xhr) {
if (fail) fail(xhr)
})
}
// v1 memo list: GET /api/v1/memo
// Query params (v0.20/v0.21): limit/offset/rowStatus/content/tag (best-effort)
function listMemos(info, options, success, fail) {
const opt = options || {}
const headers = { Authorization: 'Bearer ' + info.apiTokens }
const limit = opt.limit && Number.isFinite(opt.limit) ? Math.max(1, Math.floor(opt.limit)) : 1000
const offset = opt.offset && Number.isFinite(opt.offset) ? Math.max(0, Math.floor(opt.offset)) : null
const rowStatus = typeof opt.rowStatus === 'string' && opt.rowStatus ? opt.rowStatus : 'NORMAL'
const content = typeof opt.contentSearch === 'string' ? opt.contentSearch : ''
const tag = typeof opt.tagSearch === 'string' ? opt.tagSearch : ''
let qs = '?limit=' + encodeURIComponent(String(limit))
if (offset != null) qs += '&offset=' + encodeURIComponent(String(offset))
if (rowStatus) qs += '&rowStatus=' + encodeURIComponent(String(rowStatus))
if (content) qs += '&content=' + encodeURIComponent(String(content))
if (tag) qs += '&tag=' + encodeURIComponent(String(tag).replace(/^#/, ''))
requestGet(
info.apiUrl + 'api/v1/memo' + qs,
headers,
function (data) {
if (success) success({ memos: extractMemoListFromResponse(data) })
},
function (xhr) {
// Some builds might expose plural `/api/v1/memos`; try as a last resort (still v1).
if (isNotFoundLikeXhr(xhr)) {
requestGet(
info.apiUrl + 'api/v1/memos' + qs,
headers,
function (data2) {
if (success) success({ memos: extractMemoListFromResponse(data2) })
},
fail
)
return
}
if (fail) fail(xhr)
}
)
}
function createMemo(info, body, success, fail) {
const headers = { Authorization: 'Bearer ' + info.apiTokens }
requestPostJson(
info.apiUrl + 'api/v1/memo',
headers,
body,
function (data) {
if (success) success(extractMemoEntityFromResponse(data))
},
function (xhr) {
// Last resort: plural route.
if (isNotFoundLikeXhr(xhr)) {
requestPostJson(
info.apiUrl + 'api/v1/memos',
headers,
body,
function (data2) {
if (success) success(extractMemoEntityFromResponse(data2))
},
fail
)
return
}
if (fail) fail(xhr)
}
)
}
function patchMemo(info, memoId, patch, success, fail) {
const headers = { Authorization: 'Bearer ' + info.apiTokens }
const id = memoId != null ? String(memoId) : ''
if (!id) {
if (fail) fail({ status: 400 })
return
}
requestPatchJson(
info.apiUrl + 'api/v1/memo/' + encodeURIComponent(id),
headers,
patch,
function (data) {
if (success) success(extractMemoEntityFromResponse(data))
},
fail
)
}
function getTagList(info, success, fail) {
const headers = { Authorization: 'Bearer ' + info.apiTokens }
requestGet(
info.apiUrl + 'api/v1/tag',
headers,
function (data) {
const list = Array.isArray(data) ? data : Array.isArray(data.tags) ? data.tags : []
const out = list
.map(function (t) {
if (!t) return ''
if (typeof t === 'string') return t
if (typeof t.name === 'string') return t.name
if (typeof t.tag === 'string') return t.tag
return ''
})
.map(function (s) {
return String(s).replace(/^#/, '').trim()
})
.filter(Boolean)
if (success) success(out)
},
fail
)
}
function getTagSuggestion(info, success, fail) {
const headers = { Authorization: 'Bearer ' + info.apiTokens }
requestGet(
info.apiUrl + 'api/v1/tag/suggestion',
headers,
function (data) {
const list = Array.isArray(data) ? data : []
const out = list
.map(function (s) {
return String(s).replace(/^#/, '').trim()
})
.filter(Boolean)
if (success) success(out)
},
function (xhr) {
// Some forks might only expose list.
if (isNotFoundLikeXhr(xhr)) {
getTagList(info, success, fail)
return
}
if (fail) fail(xhr)
}
)
}
function uploadResourceBlob(info, file, meta, success, fail) {
const headers = { Authorization: 'Bearer ' + info.apiTokens }
const url = info.apiUrl + 'api/v1/resource/blob'
const m = meta || {}
const filename = String(m.filename || (file && file.name) || 'upload')
const form = new FormData()
if (file) form.append('file', file, filename)
global.$
.ajax({
url: url,
type: 'POST',
data: form,
processData: false,
contentType: false,
dataType: 'json',
headers: headers
})
.done(function (data) {
if (success) success(extractResourceEntityFromResponse(data))
})
.fail(function (xhr) {
if (fail) fail(xhr)
})
}
function deleteResource(info, resourceId, success, fail) {
const headers = { Authorization: 'Bearer ' + info.apiTokens }
const id = resourceId != null ? String(resourceId) : ''
if (!id) {
if (fail) fail({ status: 400 })
return
}
global.$
.ajax({
url: info.apiUrl + 'api/v1/resource/' + encodeURIComponent(id),
type: 'DELETE',
headers: headers
})
.done(function (data) {
if (success) success(data)
})
.fail(function (xhr) {
if (fail) fail(xhr)
})
}
global.MemosApiV1 = {
listMemos: listMemos,
createMemo: createMemo,
patchMemo: patchMemo,
getTagList: getTagList,
getTagSuggestion: getTagSuggestion,
uploadResourceBlob: uploadResourceBlob,
deleteResource: deleteResource
}
})(window)