diff --git a/README.md b/README.md index cfce393..67ae34f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Chrome 应用商店: 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) diff --git a/js/compat/memosApi.v1.js b/js/compat/memosApi.v1.js new file mode 100644 index 0000000..0a8f900 --- /dev/null +++ b/js/compat/memosApi.v1.js @@ -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) diff --git a/js/memosApi.js b/js/memosApi.js index a583baa..3a51fc2 100644 --- a/js/memosApi.js +++ b/js/memosApi.js @@ -45,6 +45,8 @@ { 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' }, + // v0.20: current user endpoint. + { method: 'GET', path: 'api/v1/user/me', uiPath: 'm' }, { method: 'POST', path: 'api/v1/auth/status', uiPath: 'm' }, { method: 'GET', path: 'api/v1/auth/status', uiPath: 'm' } ] diff --git a/js/oper.js b/js/oper.js index 3a31e17..bde96d8 100644 --- a/js/oper.js +++ b/js/oper.js @@ -70,6 +70,7 @@ function get_info(callback) { { apiUrl: '', apiTokens: '', + apiFlavor: '', hidetag: '', showtag: '', memo_lock: '', @@ -90,6 +91,7 @@ function get_info(callback) { returnObject.status = flag returnObject.apiUrl = items.apiUrl returnObject.apiTokens = items.apiTokens + returnObject.apiFlavor = items.apiFlavor returnObject.hidetag = items.hidetag returnObject.showtag = items.showtag returnObject.memo_lock = items.memo_lock @@ -104,6 +106,21 @@ function get_info(callback) { ) } +function isV023Flavor(info) { + return info && info.apiFlavor === 'v023' && window.MemosApiV023 +} + +function isV1Flavor(info) { + return info && info.apiFlavor === 'v1' && window.MemosApiV1 +} + +function getMemoUid(memo) { + if (!memo) return '' + if (memo.uid != null && memo.uid !== '') return String(memo.uid) + if (typeof memo.name === 'string' && memo.name) return memo.name.split('/').pop() + return '' +} + get_info(function (info) { if (info.status) { //已经有绑定信息了,折叠 @@ -222,6 +239,210 @@ function escapeHtml(input) { .replace(/'/g, ''') } +function buildV1ResourceStreamUrl(info, resource) { + if (!info || !info.apiUrl || !resource) return '' + // Use the configured apiUrl as the base (may include a reverse-proxy subpath). + // Do NOT reduce to origin-only, otherwise deployments like https://host/memos/ will break. + let root = String(info.apiUrl) + try { + const u = new URL(root) + u.hash = '' + u.search = '' + root = u.toString() + } catch (_) { + // keep as-is + } + if (root && !root.endsWith('/')) root += '/' + + function isImageResource(r) { + if (!r) return false + const t = typeof r.type === 'string' ? r.type.toLowerCase() : '' + if (t.startsWith('image/')) return true + const fn = typeof r.filename === 'string' ? r.filename.toLowerCase() : '' + return /\.(png|jpe?g|gif|webp|bmp|svg|avif|heic)$/.test(fn) + } + + function isProbablyUid(s) { + if (typeof s !== 'string') return false + const v = s.trim() + if (!v) return false + if (v.indexOf('/') !== -1) return false + if (/^\d+$/.test(v)) return false + // shortuuid v4 typically uses URL-safe base57-ish; allow a conservative charset. + return /^[A-Za-z0-9_-]{8,}$/.test(v) + } + + function buildStreamUrl(uid) { + const base = root + 'o/r/' + encodeURIComponent(uid) + return isImageResource(resource) ? base + '?thumbnail=1' : base + } + + const uidRaw = resource.uid != null ? resource.uid : resource.UID != null ? resource.UID : resource.Uid + const uid = typeof uidRaw === 'string' ? uidRaw : uidRaw != null ? String(uidRaw) : '' + if (uid.trim() !== '') return buildStreamUrl(uid.trim()) + + // Legacy versions (e.g. v0.18) may only expose numeric `id` without `uid/name`. + const idRaw = resource.id != null ? resource.id : resource.ID != null ? resource.ID : resource.Id + const id = typeof idRaw === 'number' && Number.isFinite(idRaw) + ? String(Math.floor(idRaw)) + : typeof idRaw === 'string' && idRaw.trim() !== '' && !Number.isNaN(Number(idRaw)) + ? String(Math.floor(Number(idRaw))) + : '' + if (id) return buildStreamUrl(id) + + // Fallback for older resource shapes. + const name = typeof resource.name === 'string' ? resource.name : '' + + // In some memo payloads, the uid may appear as `name` directly. + // Example: name="ETU6hjuR..." should map to /o/r/:uid, not /file/:name/:filename. + if (isProbablyUid(name)) return buildStreamUrl(name.trim()) + + const fileId = resource.publicId || resource.filename + if (name && fileId) return root + 'file/' + name + '/' + fileId + return '' +} + +function normalizeUnixTimeToMs(input) { + if (input == null) return null + if (typeof input === 'number' && Number.isFinite(input)) { + // Heuristic: seconds are typically 10 digits; milliseconds are 13 digits. + if (input > 0 && input < 1e12) return input * 1000 + return input + } + if (typeof input === 'string') { + const s = input.trim() + if (/^\d+$/.test(s)) { + const n = Number(s) + if (!Number.isFinite(n)) return null + if (n > 0 && n < 1e12) return n * 1000 + return n + } + // ISO/RFC3339 etc. + return s + } + return null +} + +function memoFromNow(memo) { + if (!memo) return '' + const raw = memo.createTime || memo.createdAt || memo.createdTs + const normalized = normalizeUnixTimeToMs(raw) + if (!normalized) return '' + return dayjs(normalized).fromNow() +} + +function hydrateV1PreviewImages(info) { + if (!isV1Flavor(info)) return + if (!info || !info.apiUrl || !info.apiTokens) return + + const token = String(info.apiTokens) + let root = String(info.apiUrl) + let apiOrigin = '' + try { + const u = new URL(root) + u.hash = '' + u.search = '' + root = u.toString() + apiOrigin = u.origin + } catch (_) { + // keep as-is + } + if (root && !root.endsWith('/')) root += '/' + const nodes = document.querySelectorAll('img.random-image') + if (!nodes || nodes.length === 0) return + + // Revoke blob URLs on popup unload to avoid leaking memory. + if (!window.__memosBberObjectUrls) { + window.__memosBberObjectUrls = [] + window.addEventListener('unload', function () { + const list = window.__memosBberObjectUrls || [] + for (let i = 0; i < list.length; i++) { + try { URL.revokeObjectURL(list[i]) } catch (_) {} + } + window.__memosBberObjectUrls = [] + }) + } + + const transparentPixel = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' + + function resolveToAbsoluteUrl(url) { + const u = String(url || '').trim() + if (!u) return '' + if (u.startsWith('data:') || u.startsWith('blob:') || u.startsWith('chrome-extension:')) return '' + if (u.startsWith('#')) return '' + try { + return new URL(u, root).toString() + } catch (_) { + return '' + } + } + + function isSameOrigin(url) { + if (!apiOrigin) return false + try { + return new URL(url).origin === apiOrigin + } catch (_) { + return false + } + } + + function looksLikeMemosResourceUrl(absUrl) { + const s = String(absUrl || '') + return s.indexOf('/o/r/') !== -1 || s.indexOf('/file/') !== -1 + } + + nodes.forEach(function (img) { + const hasAuthAttr = img.hasAttribute('data-auth-src') + const url = img.getAttribute('data-auth-src') || img.getAttribute('src') + if (!url) return + if (img.getAttribute('data-auth-loaded') === '1') return + + const abs = resolveToAbsoluteUrl(url) + if (!abs) return + // Only hydrate same-origin resources that require Authorization. + if (!isSameOrigin(abs)) return + + // Reduce unnecessary fetches: only hydrate known resource endpoints, + // or images explicitly marked as auth-required. + if (!hasAuthAttr && !looksLikeMemosResourceUrl(abs)) return + + img.setAttribute('data-auth-loaded', '1') + + // Prevent a broken-image icon before hydration completes. + // Only do this for images explicitly marked as auth-required. + if (hasAuthAttr) { + const currentSrc = img.getAttribute('src') + if (!currentSrc || currentSrc === abs) { + img.setAttribute('src', transparentPixel) + } + } + + fetch(abs, { + method: 'GET', + headers: { + Authorization: 'Bearer ' + token + } + }) + .then(function (res) { + if (!res || !res.ok) throw new Error('HTTP ' + (res ? res.status : '0')) + const ct = (res.headers && typeof res.headers.get === 'function') ? (res.headers.get('content-type') || '') : '' + if (ct && !ct.toLowerCase().startsWith('image/')) throw new Error('Not an image') + return res.blob() + }) + .then(function (blob) { + const objectUrl = URL.createObjectURL(blob) + window.__memosBberObjectUrls.push(objectUrl) + img.src = objectUrl + }) + .catch(function () { + // Don't break previews for modern versions where plain may already work. + if (hasAuthAttr) { + try { img.removeAttribute('src') } catch (_) {} + } + }) + }) +} + function renderUploadList(list) { const $wrapper = $('.upload-list-wrapper') const $list = $('#uploadlist') @@ -243,12 +464,15 @@ function renderUploadList(list) { for (let i = 0; i < items.length; i++) { const att = items[i] || {} const name = att.name || '' + const id = att.id != null ? String(att.id) : '' const filename = att.filename || name html += '
' + '
' + '×' + @@ -327,6 +553,7 @@ $(document).on('drop', '.upload-item', function (e) { $(document).on('click', '.upload-del', function () { const name = $(this).data('name') + const rid = $(this).data('id') if (!name) return get_info(function (info) { @@ -335,11 +562,33 @@ $(document).on('click', '.upload-del', function () { return } - $.ajax({ - url: info.apiUrl + 'api/v1/' + name, - type: 'DELETE', - headers: { Authorization: 'Bearer ' + info.apiTokens }, - success: function () { + const inferredId = (function () { + if (rid != null && String(rid).trim() !== '' && !Number.isNaN(Number(rid))) return Math.floor(Number(rid)) + const tail = String(name).split('/').pop() + if (tail && !Number.isNaN(Number(tail))) return Math.floor(Number(tail)) + return null + })() + + const doDelete = isV1Flavor(info) && window.MemosApiV1 && typeof window.MemosApiV1.deleteResource === 'function' && inferredId != null + ? function (onOk, onFail) { + window.MemosApiV1.deleteResource(info, inferredId, onOk, onFail) + } + : function (onOk, onFail) { + $.ajax({ + url: info.apiUrl + 'api/v1/' + name, + type: 'DELETE', + headers: { Authorization: 'Bearer ' + info.apiTokens }, + success: function (data) { + onOk(data) + }, + error: function (xhr) { + onFail(xhr) + } + }) + } + + doDelete( + function () { const next = (Array.isArray(relistNow) ? relistNow : []).filter(function (x) { return x && x.name !== name }) @@ -348,10 +597,10 @@ $(document).on('click', '.upload-del', function () { renderUploadList(relistNow) }) }, - error: function () { + function () { $.message({ message: msg('attachmentDeleteFailed') }) } - }) + ) }) }) function uploadImage(file) { @@ -359,6 +608,12 @@ function uploadImage(file) { message: msg('picUploading'), autoClose: false }); + get_info(function (info) { + if (isV1Flavor(info)) { + uploadImageNowV1(file) + return + } + const reader = new FileReader(); reader.onload = function(e) { const base64String = e.target.result.split(',')[1]; @@ -368,8 +623,62 @@ function uploadImage(file) { console.error('Error reading file:', error); }; reader.readAsDataURL(file); + }) }; +function uploadImageNowV1(file) { + get_info(function (info) { + if (!info.status) { + $.message({ message: msg('placeApiUrl') }) + return + } + + let old_name = file.name.split('.') + let file_ext = file.name.split('.').pop() + let now = dayjs().format('YYYYMMDDHHmmss') + let new_name = old_name[0] + '_' + now + '.' + file_ext + + window.MemosApiV1.uploadResourceBlob( + info, + file, + { filename: new_name, type: file.type }, + function (entity) { + const inferredId = (function () { + if (!entity) return null + const v = entity.id != null ? entity.id : entity.ID != null ? entity.ID : entity.Id + if (typeof v === 'number' && Number.isFinite(v)) return Math.floor(v) + if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Math.floor(Number(v)) + return null + })() + + // v0.18: resource entity has no `name`, only `id/filename/type/...`. + // Treat having an id as a successful upload. + if (entity && (entity.name || inferredId != null)) { + const name = entity.name || (inferredId != null ? 'resources/' + String(inferredId) : '') + relistNow.push({ + id: inferredId != null ? inferredId : entity.id, + name: name, + filename: entity.filename || new_name, + createTime: entity.createTime || entity.createdTs || entity.createdAt, + type: entity.type + }) + chrome.storage.sync.set({ open_action: '', open_content: '', resourceIdList: relistNow }, function () { + $.message({ message: msg('picSuccess') }) + }) + return + } + + chrome.storage.sync.set({ open_action: '', open_content: '' }, function () { + $.message({ message: msg('picFailed') }) + }) + }, + function () { + $.message({ message: msg('picFailed') }) + } + ) + }) +} + function uploadImageNow(base64String, file) { get_info(function(info) { if (info.status) { @@ -397,13 +706,14 @@ function uploadImageNow(base64String, file) { window.MemosApi.uploadAttachmentOrResource( info, data, - function (resp, kind) { - if (resp && resp.name) { + function (resp) { + const entity = (resp && resp.resource) || resp + if (entity && entity.name) { relistNow.push({ - name: resp.name, - filename: resp.filename || new_name, - createTime: resp.createTime, - type: resp.type + name: entity.name, + filename: entity.filename || new_name, + createTime: entity.createTime, + type: entity.type }) chrome.storage.sync.set( { @@ -415,18 +725,19 @@ function uploadImageNow(base64String, file) { $.message({ message: msg('picSuccess') }) } ) - } else { - chrome.storage.sync.set( - { - open_action: '', - open_content: '', - resourceIdList: [] - }, - function () { - $.message({ message: msg('picFailed') }) - } - ) + return } + + chrome.storage.sync.set( + { + open_action: '', + open_content: '', + resourceIdList: [] + }, + function () { + $.message({ message: msg('picFailed') }) + } + ) }, function () { $.message({ message: msg('picFailed') }) @@ -458,11 +769,23 @@ $('#saveKey').click(function () { apiUrl: apiUrl, apiTokens: apiTokens, userid: auth.userId, - memoUiPath: auth.uiPath || 'memos' + memoUiPath: auth.uiPath || 'memos', + apiFlavor: '' }, function () { $.message({ message: msg('saveSuccess') }) $('#blog_info').hide() + + // Auto-detect API flavor once; keep default behavior when unknown. + if (window.MemosApiV023 && typeof window.MemosApiV023.probeApiFlavor === 'function') { + window.MemosApiV023.probeApiFlavor(apiUrl, apiTokens, function (res) { + const flavor = res && res.flavor ? res.flavor : '' + const normalized = flavor === 'v020' || flavor === 'v021' ? 'v1' : flavor + if (normalized === 'v1' || normalized === 'v023' || normalized === 'modern') { + chrome.storage.sync.set({ apiFlavor: normalized }) + } + }) + } } ) }) @@ -482,30 +805,71 @@ $('#tags').click(function () { // 从最近的1000条memo中获取tags,因此不保证获取能全部的 var tagDom = ""; - window.MemosApi.fetchMemosWithFallback( - info, - '?pageSize=1000', - function (data) { - const memos = window.MemosApi.extractMemosListFromResponse(data) + const renderTags = function (tags) { + const uniTags = [...new Set((Array.isArray(tags) ? tags : []).filter(Boolean))] + $.each(uniTags, function (_, tag) { + tagDom += '#' + tag + ''; + }); + tagDom += '' + $("#taglist").html(tagDom).slideToggle(500) + } - 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))] + const onTagsData = function (data) { + const memos = window.MemosApi.extractMemosListFromResponse(data) - $.each(uniTags, function (_, tag) { - tagDom += '#' + tag + ''; - }); - tagDom += '' - $("#taglist").html(tagDom).slideToggle(500) - }, - function () { - $.message({ message: msg('placeApiUrl') }) - } - ) + const allTags = memos.flatMap(function (memo) { + if (!memo) return [] + // v0.23 response may include `tags: []` while actual tags live in `memo.property.tags`. + // So when v0.23 flavor is detected, always use the compat extractor first. + if (isV023Flavor(info)) return window.MemosApiV023.extractTagsFromMemo(memo) + if (Array.isArray(memo.tags) && memo.tags.length > 0) return memo.tags + if (Array.isArray(memo.tagList) && memo.tagList.length > 0) return memo.tagList + if (memo.property && Array.isArray(memo.property.tags) && memo.property.tags.length > 0) { + return memo.property.tags + } + return [] + }) + const uniTags = [...new Set(allTags.filter(Boolean))] + + renderTags(uniTags) + } + + if (isV1Flavor(info)) { + window.MemosApiV1.getTagSuggestion( + info, + function (tags) { + renderTags(Array.isArray(tags) ? tags : []) + }, + function () { + $.message({ message: msg('placeApiUrl') }) + } + ) + } else if (isV023Flavor(info)) { + const filterExpr = window.MemosApiV023.buildFilter({ + rowStatus: 'NORMAL', + creator: 'users/' + info.userid + }) + window.MemosApiV023.listMemos( + info, + { + pageSize: 1000, + filterExpr: filterExpr + }, + onTagsData, + function () { + $.message({ message: msg('placeApiUrl') }) + } + ) + } else { + window.MemosApi.fetchMemosWithFallback( + info, + '?pageSize=1000', + onTagsData, + function () { + $.message({ message: msg('placeApiUrl') }) + } + ) + } } else { $.message({ message: msg('placeApiUrl') @@ -552,14 +916,29 @@ $('#search').click(function () { get_info(function (info) { const pattern = $("textarea[name=text]").val() var parent = `users/${info.userid}`; - var filter = "?filter=" + encodeURIComponent(`visibility in ["PUBLIC","PROTECTED"] && content.contains("${pattern}")`); + const patternLiteral = JSON.stringify(String(pattern || '')) + var filter = "?filter=" + encodeURIComponent(`visibility in ["PUBLIC","PROTECTED"] && content.contains(${patternLiteral})`); if (info.status) { $("#randomlist").html('').hide() var searchDom = "" if(pattern){ - window.MemosApi.fetchMemosWithFallback( - info, - filter, + const runSearch = isV023Flavor(info) + ? function (onOk, onFail) { + const filterExpr = window.MemosApiV023.buildFilter({ + visibilities: ['PUBLIC', 'PROTECTED'], + contentSearch: String(pattern) + }) + window.MemosApiV023.listMemos(info, { pageSize: 1000, filterExpr: filterExpr }, onOk, onFail) + } + : isV1Flavor(info) + ? function (onOk, onFail) { + window.MemosApiV1.listMemos(info, { limit: 1000, rowStatus: 'NORMAL', contentSearch: String(pattern) }, onOk, onFail) + } + : function (onOk, onFail) { + window.MemosApi.fetchMemosWithFallback(info, filter, onOk, onFail) + } + + runSearch( function (data) { let searchData = window.MemosApi.extractMemosListFromResponse(data) if(searchData.length == 0){ @@ -568,10 +947,10 @@ $('#search').click(function () { }) }else{ for(var i=0;i < searchData.length;i++){ - var memosID = searchData[i].name.split('/').pop(); - var memoTime = searchData[i].createTime || searchData[i].createdTs || searchData[i].createdAt - searchDom += '
'+(memoTime ? dayjs(memoTime).fromNow() : '')+'
'+(searchData[i].content || '').replace(/!\[.*?\]\((.*?)\)/g,' ').replace(/\[(.*?)\]\((.*?)\)/g,' $1 ')+'
' - var resources = (searchData[i].attachments && searchData[i].attachments.length > 0) ? searchData[i].attachments : (searchData[i].resources || []); + var memosID = getMemoUid(searchData[i]) + var timeText = memoFromNow(searchData[i]) + searchDom += '
'+timeText+'
'+(searchData[i].content || '').replace(/!\[.*?\]\((.*?)\)/g,' ').replace(/\[(.*?)\]\((.*?)\)/g,' $1 ')+'
' + var resources = (searchData[i].attachments && searchData[i].attachments.length > 0) ? searchData[i].attachments : ((searchData[i].resources && searchData[i].resources.length > 0) ? searchData[i].resources : (searchData[i].resourceList || [])); if(resources && resources.length > 0){ for(var j=0;j < resources.length;j++){ var restype = (resources[j].type || '').slice(0,5); @@ -580,11 +959,17 @@ $('#search').click(function () { if(resexlink){ resLink = resexlink }else{ - fileId = resources[j].publicId || resources[j].filename - resLink = info.apiUrl+'file/'+resources[j].name+'/'+fileId + resLink = buildV1ResourceStreamUrl(info, resources[j]) } + if (!resLink) { + continue + } if(restype == 'image'){ - searchDom += '' + if (isV1Flavor(info)) { + searchDom += '' + } else { + searchDom += '' + } } if(restype !== 'image'){ searchDom += ''+resources[j].filename+'' @@ -595,9 +980,10 @@ $('#search').click(function () { } window.ViewImage && ViewImage.init('.random-image') $("#randomlist").html(searchDom).slideDown(500); + hydrateV1PreviewImages(info) } }, - function () { + function (xhr) { $.message({ message: msg('searchNone') }) } ) @@ -617,12 +1003,23 @@ $('#search').click(function () { $('#random').click(function () { get_info(function (info) { var parent = `users/${info.userid}`; - var filter = "?filter=" + encodeURIComponent(`visibility in ["PUBLIC","PROTECTED"]`); if (info.status) { $("#randomlist").html('').hide() - window.MemosApi.fetchMemosWithFallback( - info, - filter, + const runRandom = isV023Flavor(info) + ? function (onOk, onFail) { + const filterExpr = window.MemosApiV023.buildFilter({ visibilities: ['PUBLIC', 'PROTECTED'] }) + window.MemosApiV023.listMemos(info, { pageSize: 1000, filterExpr: filterExpr }, onOk, onFail) + } + : isV1Flavor(info) + ? function (onOk, onFail) { + window.MemosApiV1.listMemos(info, { limit: 1000, rowStatus: 'NORMAL' }, onOk, onFail) + } + : function (onOk, onFail) { + const filter = "?filter=" + encodeURIComponent(`visibility in ["PUBLIC","PROTECTED"]`); + window.MemosApi.fetchMemosWithFallback(info, filter, onOk, onFail) + } + + runRandom( function (data) { const memos = window.MemosApi.extractMemosListFromResponse(data) let randomNum = Math.floor(Math.random() * (memos.length)); @@ -643,22 +1040,29 @@ $('#random').click(function () { function randDom(randomData){ get_info(function (info) { - var memosID = randomData.name.split('/').pop(); - var randomDom = '
'+dayjs(randomData.createTime).fromNow()+'
'+randomData.content.replace(/!\[.*?\]\((.*?)\)/g,' ').replace(/\[(.*?)\]\((.*?)\)/g,' $1 ')+'
' - var resources = (randomData.attachments && randomData.attachments.length > 0) ? randomData.attachments : (randomData.resources || []); + var memosID = getMemoUid(randomData) + var timeText = memoFromNow(randomData) + var randomDom = '
'+timeText+'
'+(randomData && randomData.content ? randomData.content : '').replace(/!\[.*?\]\((.*?)\)/g,' ').replace(/\[(.*?)\]\((.*?)\)/g,' $1 ')+'
' + var resources = (randomData.attachments && randomData.attachments.length > 0) ? randomData.attachments : ((randomData.resources && randomData.resources.length > 0) ? randomData.resources : (randomData.resourceList || [])); if(resources && resources.length > 0){ for(var j=0;j < resources.length;j++){ - var restype = resources[j].type.slice(0,5); + var restype = (resources[j].type || '').slice(0,5); var resexlink = resources[j].externalLink var resLink = '',fileId='' if(resexlink){ resLink = resexlink }else{ - fileId = resources[j].publicId || resources[j].filename - resLink = info.apiUrl+'file/'+resources[j].name+'/'+fileId + resLink = buildV1ResourceStreamUrl(info, resources[j]) + } + if (!resLink) { + continue } if(restype == 'image'){ - randomDom += '' + if (isV1Flavor(info)) { + randomDom += '' + } else { + randomDom += '' + } } if(restype !== 'image'){ randomDom += ''+resources[j].filename+'' @@ -668,6 +1072,7 @@ function randDom(randomData){ randomDom += '
' window.ViewImage && ViewImage.init('.random-image') $("#randomlist").html(randomDom).slideDown(500); + hydrateV1PreviewImages(info) }) } @@ -683,6 +1088,25 @@ $(document).on("click","#random-delete",function () { get_info(function (info) { // var memoUid = $("#random-delete").data('uid'); var memosName = $("#random-delete").data('name'); + var memoId = $("#random-delete").data('id'); + + // v0.20/v0.21: archive memo via API v1 PATCH /api/v1/memo/:id + if (isV1Flavor(info) && memoId) { + window.MemosApiV1.patchMemo( + info, + memoId, + { rowStatus: 'ARCHIVED' }, + function () { + $("#randomlist").html('').hide() + $.message({ message: msg('archiveSuccess') }) + }, + function () { + $.message({ message: msg('archiveFailed') }) + } + ) + return + } + var deleteUrl = info.apiUrl+'api/v1/'+memosName $.ajax({ url:deleteUrl, @@ -778,7 +1202,7 @@ function getOne(memosId){ get_info(function (info) { if (info.apiUrl) { $("#randomlist").html('').hide() - var getUrl = info.apiUrl+'api/v1/'+memosId + var getUrl = isV1Flavor(info) ? info.apiUrl+'api/v1/memo/'+memosId : info.apiUrl+'api/v1/'+memosId $.ajax({ url:getUrl, type:"GET", @@ -816,6 +1240,58 @@ function sendText() { sendvisi = 'PRIVATE' } } + + // Memos v0.20/v0.21: use /api/v1/memo and bind resources by numeric IDs. + if (isV1Flavor(info)) { + const items = Array.isArray(info.resourceIdList) ? info.resourceIdList : [] + const resourceIdList = items + .map(function (r) { + if (!r) return null + if (typeof r.id === 'number' && Number.isFinite(r.id)) return Math.floor(r.id) + if (typeof r.id === 'string' && r.id.trim() !== '' && !Number.isNaN(Number(r.id))) { + return Math.floor(Number(r.id)) + } + // Some versions store name as resources/{id}. + const n = typeof r.name === 'string' ? r.name : '' + const tail = n ? n.split('/').pop() : '' + if (tail && !Number.isNaN(Number(tail))) return Math.floor(Number(tail)) + return null + }) + .filter(function (x) { + return x != null && Number.isFinite(x) + }) + + window.MemosApiV1.createMemo( + info, + { + content: content, + visibility: sendvisi, + resourceIdList: resourceIdList + }, + function (data) { + chrome.storage.sync.set( + { open_action: '', open_content: '', resourceIdList: [] }, + function () { + $.message({ message: msg('memoSuccess') }) + $("textarea[name=text]").val('') + relistNow = [] + renderUploadList(relistNow) + randDom(data) + } + ) + }, + function () { + chrome.storage.sync.set( + { open_action: '', open_content: '' }, + function () { + $.message({ message: msg('memoFailed') }) + } + ) + } + ) + return + } + $.ajax({ url:info.apiUrl+'api/v1/memos', type:"POST", diff --git a/manifest.json b/manifest.json index 0e3354d..ac34184 100644 --- a/manifest.json +++ b/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "__MSG_extName__", "default_locale": "en", - "version": "2026.03.07", - "version_name": "Supports 0.24.0 to the latest version", + "version": "2026.03.08", + "version_name": "Supports 0.18.0 to the latest version", "action": { "default_popup": "popup.html", "default_icon": "assets/logo_24x24.png", diff --git a/popup.html b/popup.html index 40a3af1..b14623c 100644 --- a/popup.html +++ b/popup.html @@ -152,6 +152,8 @@ + +