diff --git a/js/memosApi.js b/js/memosApi.js new file mode 100644 index 0000000..a583baa --- /dev/null +++ b/js/memosApi.js @@ -0,0 +1,503 @@ +(function (global) { + 'use strict' + + function extractUserIdFromAuthResponse(response) { + if (!response) return null + + const user = response.user || response + + if (typeof user.id === 'number' && Number.isFinite(user.id)) return user.id + if (typeof user.id === 'string' && user.id.trim() !== '' && !Number.isNaN(Number(user.id))) { + return Number(user.id) + } + + const name = user.name || (user.user && user.user.name) + if (typeof name === 'string') { + const m = name.match(/\busers\/(\d+)\b/) + if (m) return Number(m[1]) + const last = name.split('/').pop() + if (last && !Number.isNaN(Number(last))) return Number(last) + } + + return null + } + + function extractMemosListFromResponse(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 isNotFoundLikeXhr(jqXhr) { + const status = jqXhr && jqXhr.status + return status === 404 || status === 405 + } + + function authWithFallback(apiUrl, apiTokens, callback) { + const headers = { Authorization: 'Bearer ' + apiTokens } + + // v0.26+: GET auth/me + // older: POST/GET auth/status + const tries = [ + { method: 'GET', path: 'api/v1/auth/me', uiPath: 'memos' }, + // v0.25: session-based auth service still accepts bearer tokens and returns { user: ... }. + { method: 'GET', path: 'api/v1/auth/sessions/current', uiPath: 'memos' }, + { method: 'POST', path: 'api/v1/auth/status', uiPath: 'm' }, + { method: 'GET', path: 'api/v1/auth/status', uiPath: 'm' } + ] + + function runAt(index) { + if (index >= tries.length) { + callback(null) + return + } + + const t = tries[index] + global.$ + .ajax({ + async: true, + crossDomain: true, + url: apiUrl + t.path, + method: t.method, + headers: headers + }) + .done(function (response) { + const userId = extractUserIdFromAuthResponse(response) + if (userId != null) callback({ userId: userId, uiPath: t.uiPath, raw: response }) + else runAt(index + 1) + }) + .fail(function () { + runAt(index + 1) + }) + } + + runAt(0) + } + + function fetchMemosWithFallback(info, query, success, fail) { + const qs = query || '' + const headers = { Authorization: 'Bearer ' + info.apiTokens } + + // v0.24: `GET /api/v1/memos` tends to behave like a public feed (private memos excluded). + // For an authenticated user, `GET /api/v1/users/{id}/memos` is the safe way to retrieve + // the full set (including private), which affects tag extraction. + // Newer versions may not expose the user-scoped endpoint, so we fallback by 404/405. + const urlUserScoped = info.userid + ? info.apiUrl + 'api/v1/users/' + info.userid + '/memos' + qs + : null + const urlGlobal = info.apiUrl + 'api/v1/memos' + qs + + const urlPrimary = urlUserScoped || urlGlobal + const urlFallback = urlUserScoped ? urlGlobal : null + + global.$ + .ajax({ + url: urlPrimary, + type: 'GET', + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + success(data) + }) + .fail(function (xhr) { + const status = xhr && xhr.status + const canFallback = Boolean(urlFallback) && (isNotFoundLikeXhr(xhr) || status === 400) + if (!canFallback) { + if (fail) fail(xhr) + return + } + + global.$ + .ajax({ + url: urlFallback, + type: 'GET', + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + success(data) + }) + .fail(function (xhr2) { + if (fail) fail(xhr2) + }) + }) + } + + function uploadAttachmentOrResource(info, payload, onSuccess, onFail) { + const headers = { Authorization: 'Bearer ' + info.apiTokens } + const urlAttachments = info.apiUrl + 'api/v1/attachments' + const urlResources = info.apiUrl + 'api/v1/resources' + + function stripVisibility(p) { + if (!p || typeof p !== 'object') return p + if (!Object.prototype.hasOwnProperty.call(p, 'visibility')) return p + const copy = Object.assign({}, p) + delete copy.visibility + return copy + } + + global.$ + .ajax({ + url: urlAttachments, + data: JSON.stringify(payload), + type: 'POST', + cache: false, + processData: false, + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'attachments') + }) + .fail(function (xhr) { + if (xhr && xhr.status === 400) { + global.$ + .ajax({ + url: urlAttachments, + data: JSON.stringify(stripVisibility(payload)), + type: 'POST', + cache: false, + processData: false, + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'attachments') + }) + .fail(function (xhrRetry) { + if (!isNotFoundLikeXhr(xhrRetry)) { + if (onFail) onFail(xhrRetry) + return + } + // fall through to resources below + xhr = xhrRetry + if (!isNotFoundLikeXhr(xhr)) { + if (onFail) onFail(xhr) + return + } + global.$ + .ajax({ + url: urlResources, + data: JSON.stringify(payload), + type: 'POST', + cache: false, + processData: false, + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'resources') + }) + .fail(function (xhr2) { + if (xhr2 && xhr2.status === 400) { + global.$ + .ajax({ + url: urlResources, + data: JSON.stringify(stripVisibility(payload)), + type: 'POST', + cache: false, + processData: false, + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'resources') + }) + .fail(function (xhr3) { + if (onFail) onFail(xhr3) + }) + return + } + if (onFail) onFail(xhr2) + }) + }) + return + } + + if (!isNotFoundLikeXhr(xhr)) { + if (onFail) onFail(xhr) + return + } + + global.$ + .ajax({ + url: urlResources, + data: JSON.stringify(payload), + type: 'POST', + cache: false, + processData: false, + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'resources') + }) + .fail(function (xhr2) { + if (xhr2 && xhr2.status === 400) { + global.$ + .ajax({ + url: urlResources, + data: JSON.stringify(stripVisibility(payload)), + type: 'POST', + cache: false, + processData: false, + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'resources') + }) + .fail(function (xhr3) { + if (onFail) onFail(xhr3) + }) + return + } + if (onFail) onFail(xhr2) + }) + }) + } + + function patchMemoWithAttachmentsOrResources(info, memoName, list, onSuccess, onFail) { + const headers = { Authorization: 'Bearer ' + info.apiTokens } + const url = info.apiUrl + 'api/v1/' + memoName + const items = Array.isArray(list) ? list : [] + + const hasResourceNames = items.some(function (x) { + return x && typeof x.name === 'string' && x.name.indexOf('resources/') === 0 + }) + const hasAttachmentNames = items.some(function (x) { + return x && typeof x.name === 'string' && x.name.indexOf('attachments/') === 0 + }) + + function doPatchAttachments() { + const attachments = items + .map(function (x) { + if (!x) return null + const n = x.name + if (!n) return null + if (hasAttachmentNames && typeof n === 'string' && n.indexOf('attachments/') !== 0) return null + return { name: n } + }) + .filter(Boolean) + + // Prefer the dedicated subresource endpoint when available. + global.$ + .ajax({ + url: url + '/attachments', + type: 'PATCH', + data: JSON.stringify({ name: memoName, attachments: attachments }), + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'attachments') + }) + .fail(function (xhr0) { + // If the endpoint doesn't exist, try UpdateMemo-style patching. + if (isNotFoundLikeXhr(xhr0)) { + // continue + } else if (xhr0 && xhr0.status && xhr0.status !== 400) { + // continue; some gateways may reject body shape here. + } + + // Some versions accept a loose patch, others require updateMask. + const attachmentsPayloadLoose = { + name: memoName, + attachments: attachments + } + + global.$ + .ajax({ + url: url, + type: 'PATCH', + data: JSON.stringify(attachmentsPayloadLoose), + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'attachments') + }) + .fail(function (xhr) { + // v0.25 requires update mask when updating attachments. + if (!isNotFoundLikeXhr(xhr) && xhr && xhr.status !== 400) { + if (onFail) onFail(xhr) + return + } + + // If the server doesn't support attachments at all, fallback to resources flow. + if (isNotFoundLikeXhr(xhr)) { + doPatchResources() + return + } + + const attachmentsPayloadV025 = { + name: memoName, + attachments: attachments + } + + const updateUrl1 = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'updateMask=attachments' + global.$ + .ajax({ + url: updateUrl1, + type: 'PATCH', + data: JSON.stringify(attachmentsPayloadV025), + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'attachments') + }) + .fail(function (xhr2) { + if (isNotFoundLikeXhr(xhr2)) { + doPatchResources() + return + } + // Some grpc-gateway setups prefer updateMask.paths. + if (xhr2 && xhr2.status === 400) { + const updateUrl2 = + url + (url.indexOf('?') >= 0 ? '&' : '?') + 'updateMask.paths=attachments' + global.$ + .ajax({ + url: updateUrl2, + type: 'PATCH', + data: JSON.stringify(attachmentsPayloadV025), + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'attachments') + }) + .fail(function (xhr3) { + if (isNotFoundLikeXhr(xhr3)) { + doPatchResources() + return + } + if (onFail) onFail(xhr3) + }) + return + } + if (onFail) onFail(xhr2) + }) + }) + }) + } + + function doPatchResources() { + const resources = items + .map(function (x) { + if (!x) return null + const n = x.name + if (!n) return null + if (hasResourceNames && typeof n === 'string' && n.indexOf('resources/') !== 0) return null + return { name: n } + }) + .filter(Boolean) + + // Prefer the dedicated subresource endpoint when available. + global.$ + .ajax({ + url: url + '/resources', + type: 'PATCH', + data: JSON.stringify({ name: memoName, resources: resources }), + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'resources') + }) + .fail(function (xhr0) { + if (!isNotFoundLikeXhr(xhr0) && xhr0 && xhr0.status && xhr0.status !== 400) { + // continue; try UpdateMemo flow below. + } + + // Try a loose PATCH first (some versions accept this). + const resourcesPayloadLoose = { resources: resources } + + global.$ + .ajax({ + url: url, + type: 'PATCH', + data: JSON.stringify(resourcesPayloadLoose), + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'resources') + }) + .fail(function (xhr2) { + // v0.24 expects UpdateMemo with an update mask when modifying resources. + // The gateway commonly accepts `updateMask=resources` as a query param and a + // Memo body containing `name` + `resources`. + if (!isNotFoundLikeXhr(xhr2) && xhr2 && xhr2.status !== 400) { + if (onFail) onFail(xhr2) + return + } + + const updateUrl = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'updateMask=resources' + const resourcesPayloadV024 = { + name: memoName, + resources: resources + } + + global.$ + .ajax({ + url: updateUrl, + type: 'PATCH', + data: JSON.stringify(resourcesPayloadV024), + contentType: 'application/json', + dataType: 'json', + headers: headers + }) + .done(function (data) { + onSuccess(data, 'resources') + }) + .fail(function (xhr3) { + if (onFail) onFail(xhr3) + }) + }) + }) + } + + // If the list clearly contains v0.24-style resource names, go directly to the + // resource linking flow. If it contains attachment names, go attachment flow. + if (hasResourceNames && !hasAttachmentNames) { + doPatchResources() + return + } + if (hasAttachmentNames && !hasResourceNames) { + doPatchAttachments() + return + } + + // Default to attachments first, then fallback to resources. + doPatchAttachments() + } + + global.MemosApi = { + extractUserIdFromAuthResponse: extractUserIdFromAuthResponse, + extractMemosListFromResponse: extractMemosListFromResponse, + isNotFoundLikeXhr: isNotFoundLikeXhr, + authWithFallback: authWithFallback, + fetchMemosWithFallback: fetchMemosWithFallback, + uploadAttachmentOrResource: uploadAttachmentOrResource, + patchMemoWithAttachmentsOrResources: patchMemoWithAttachmentsOrResources + } +})(window) diff --git a/js/oper.js b/js/oper.js index fbeb0df..b6bd78b 100644 --- a/js/oper.js +++ b/js/oper.js @@ -14,6 +14,7 @@ function get_info(callback) { open_action: '', open_content: '', userid: '', + memoUiPath: 'memos', resourceIdList: [] }, function (items) { @@ -33,6 +34,7 @@ function get_info(callback) { returnObject.open_content = items.open_content returnObject.open_action = items.open_action returnObject.userid = items.userid + returnObject.memoUiPath = items.memoUiPath returnObject.resourceIdList = items.resourceIdList if (callback) callback(returnObject) @@ -335,25 +337,16 @@ function uploadImageNow(base64String, file) { filename: new_name, type: file.type }; - var upAjaxUrl = info.apiUrl + 'api/v1/attachments'; - $.ajax({ - url: upAjaxUrl, - data: JSON.stringify(data), - type: 'post', - cache: false, - processData: false, - contentType: 'application/json', - dataType: 'json', - headers: { 'Authorization': 'Bearer ' + info.apiTokens }, - success: function (data) { - // 0.24 版本+ 返回体uid已合并到name字段 - if (data.name) { - // 更新上传的文件信息并暂存浏览器本地 + window.MemosApi.uploadAttachmentOrResource( + info, + data, + function (resp, kind) { + if (resp && resp.name) { relistNow.push({ - "name": data.name, - "filename": data.filename || new_name, - "createTime": data.createTime, - "type": data.type + name: resp.name, + filename: resp.filename || new_name, + createTime: resp.createTime, + type: resp.type }) chrome.storage.sync.set( { @@ -362,13 +355,10 @@ function uploadImageNow(base64String, file) { resourceIdList: relistNow }, function () { - $.message({ - message: chrome.i18n.getMessage("picSuccess") - }) + $.message({ message: chrome.i18n.getMessage('picSuccess') }) } ) } else { - //发送失败 清空open_action(打开时候进行的操作),同时清空open_content chrome.storage.sync.set( { open_action: '', @@ -376,14 +366,15 @@ function uploadImageNow(base64String, file) { resourceIdList: [] }, function () { - $.message({ - message: chrome.i18n.getMessage("picFailed") - }) + $.message({ message: chrome.i18n.getMessage('picFailed') }) } ) } + }, + function () { + $.message({ message: chrome.i18n.getMessage('picFailed') }) } - }); + ) }else { $.message({ message: chrome.i18n.getMessage("placeApiUrl") @@ -398,47 +389,26 @@ $('#saveKey').click(function () { apiUrl += '/'; } var apiTokens = $('#apiTokens').val() - // 设置请求参数 - const settings = { - async: true, - crossDomain: true, - url: apiUrl + 'api/v1/auth/me', - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + apiTokens - } - }; - $.ajax(settings).done(function (response) { - // 0.24 版本后无 id 字段,改为从 name 字段获取和判断认证是否成功 - if (response && response.user.name) { - // 如果响应包含用户name "users/{id}",存储 apiUrl 和 apiTokens - var userid = parseInt(response.user.name.split('/').pop(), 10) - chrome.storage.sync.set( - { - apiUrl: apiUrl, - apiTokens: apiTokens, - userid: userid - }, - function () { - $.message({ - message: chrome.i18n.getMessage("saveSuccess") - }); - $('#blog_info').hide(); - } - ); - } else { - // 如果响应不包含用户 ID,显示错误消息 - $.message({ - message: chrome.i18n.getMessage("invalidToken") - }); + window.MemosApi.authWithFallback(apiUrl, apiTokens, function (auth) { + if (!auth || auth.userId == null) { + $.message({ message: chrome.i18n.getMessage('invalidToken') }) + return } - }).fail(function () { - // 请求失败时显示错误消息 - $.message({ - message: chrome.i18n.getMessage("invalidToken") - }); - }); + + chrome.storage.sync.set( + { + apiUrl: apiUrl, + apiTokens: apiTokens, + userid: auth.userId, + memoUiPath: auth.uiPath || 'memos' + }, + function () { + $.message({ message: chrome.i18n.getMessage('saveSuccess') }) + $('#blog_info').hide() + } + ) + }) }); $('#opensite').click(function () { @@ -453,25 +423,32 @@ $('#tags').click(function () { if (info.apiUrl) { var parent = `users/${info.userid}`; // 从最近的1000条memo中获取tags,因此不保证获取能全部的 - var tagUrl = info.apiUrl + 'api/v1/memos?pageSize=1000'; var tagDom = ""; - $.ajax({ - url: tagUrl, - type: "GET", - contentType: "application/json", - dataType: "json", - headers: { 'Authorization': 'Bearer ' + info.apiTokens }, - success: function (data) { - // 提前并去重所有标签 - const allTags = data.memos.flatMap(memo => memo.tags); - const uniTags = [...new Set(allTags)]; + + window.MemosApi.fetchMemosWithFallback( + info, + '?pageSize=1000', + function (data) { + const memos = window.MemosApi.extractMemosListFromResponse(data) + + const allTags = memos.flatMap(function (memo) { + if (!memo) return [] + if (Array.isArray(memo.tags)) return memo.tags + if (Array.isArray(memo.tagList)) return memo.tagList + return [] + }) + const uniTags = [...new Set(allTags.filter(Boolean))] + $.each(uniTags, function (_, tag) { tagDom += '#' + tag + ''; }); tagDom += '' $("#taglist").html(tagDom).slideToggle(500) + }, + function () { + $.message({ message: chrome.i18n.getMessage('placeApiUrl') }) } - }) + ) } else { $.message({ message: chrome.i18n.getMessage("placeApiUrl") @@ -522,14 +499,11 @@ $('#search').click(function () { $("#randomlist").html('').hide() var searchDom = "" if(pattern){ - $.ajax({ - url:info.apiUrl+"api/v1/memos"+filter, - type:"GET", - contentType:"application/json", - dataType:"json", - headers : {'Authorization':'Bearer ' + info.apiTokens}, - success: function(data){ - let searchData = data.memos + window.MemosApi.fetchMemosWithFallback( + info, + filter, + function (data) { + let searchData = window.MemosApi.extractMemosListFromResponse(data) if(searchData.length == 0){ $.message({ message: chrome.i18n.getMessage("searchNone") @@ -537,11 +511,12 @@ $('#search').click(function () { }else{ for(var i=0;i < searchData.length;i++){ var memosID = searchData[i].name.split('/').pop(); - searchDom += '