Add packaging workflow and browser dist

Add a GitHub Actions workflow (package-extensions.yml) to build and upload Chrome/Firefox packages (store and offline artifacts) on manual trigger or when pushing v* tags. Update README with packaging instructions. Reorganize extension sources into chrome/ and firefox/ directories, add Firefox-specific files (manifest, locales, assets, CSS, LICENSE), and bump Chrome manifest version to 2026.04.23. Also modify js/oper.js (moved to chrome/js) to improve proportional editor resizing: add drag-to-resize, scale clamping/persistence (localStorage + chrome.storage.sync), pointer event handlers, and max-scale computation.
This commit is contained in:
jonny
2026-04-22 18:53:42 +08:00
parent 15b8493a2b
commit d307741f1f
52 changed files with 5081 additions and 22 deletions
+521
View File
@@ -0,0 +1,521 @@
(function (global) {
'use strict'
const FLAVOR_V020_V021 = 'v020-v021'
const KNOWN_FLAVORS = [FLAVOR_V020_V021, 'v023', 'modern']
function requestJson(options, success, fail) {
global.$
.ajax(options)
.done(function (data) {
if (success) success(data)
})
.fail(function (xhr) {
if (fail) fail(xhr)
})
}
function extractMemos(data) {
if (global.MemosApiModern && typeof global.MemosApiModern.extractMemosListFromResponse === 'function') {
return global.MemosApiModern.extractMemosListFromResponse(data)
}
return []
}
function getFlavor(info) {
if (!info) return 'legacy'
if (info.apiFlavor === 'modern' && global.MemosApiV023) return 'modern'
if (info.apiFlavor === 'v023' && global.MemosApiV023) return 'v023'
if ((info.apiFlavor === FLAVOR_V020_V021 || info.apiFlavor === 'v1') && global.MemosApiV020V021) {
return FLAVOR_V020_V021
}
return 'legacy'
}
function normalizeDetectedFlavor(flavor) {
const value = typeof flavor === 'string' ? flavor : ''
if (value === 'v020' || value === 'v021' || value === 'v1') return FLAVOR_V020_V021
return value
}
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
if (typeof data.error === 'string' || typeof data.message === 'string') return false
return false
}
function isNotFoundLikeProbeXhr(xhr) {
const status = xhr && xhr.status
return status === 404 || status === 405
}
function probeFlavor(apiUrl, apiTokens, callback) {
const headers = { Authorization: 'Bearer ' + apiTokens }
const modernQ =
'api/v1/memos?pageSize=1&filter=' +
encodeURIComponent('visibility in ["PUBLIC","PROTECTED"]')
const v023Q =
'api/v1/memos?pageSize=1&filter=' +
encodeURIComponent('visibilities == ["PUBLIC","PROTECTED"]')
const v020V021Q = 'api/v1/memo?limit=1&rowStatus=NORMAL'
function finish(flavor) {
const normalized = normalizeDetectedFlavor(flavor)
if (KNOWN_FLAVORS.indexOf(normalized) !== -1) {
if (callback) callback({ flavor: normalized })
return
}
if (callback) callback({ flavor: 'unknown' })
}
function probeV023() {
global.$
.ajax({
url: apiUrl + v023Q,
method: 'GET',
headers: headers,
dataType: 'json'
})
.done(function (data) {
if (looksLikeMemosListPayload(data)) finish('v023')
else finish('unknown')
})
.fail(function () {
finish('unknown')
})
}
global.$
.ajax({
url: apiUrl + modernQ,
method: 'GET',
headers: headers,
dataType: 'json'
})
.done(function (data) {
if (looksLikeMemosListPayload(data)) {
finish('modern')
return
}
probeV023()
})
.fail(function (xhr) {
if (xhr && xhr.status === 400) {
probeV023()
return
}
if (isNotFoundLikeProbeXhr(xhr)) {
global.$
.ajax({
url: apiUrl + v020V021Q,
method: 'GET',
headers: headers,
dataType: 'json'
})
.done(function (data) {
if (looksLikeMemosListPayload(data)) finish(FLAVOR_V020_V021)
else finish('unknown')
})
.fail(function () {
finish('unknown')
})
return
}
finish('unknown')
})
}
function keepLegacyVisibleMemos(list) {
const items = Array.isArray(list) ? list : []
return items.filter(function (memo) {
if (!memo) return false
const visibility = typeof memo.visibility === 'string' ? memo.visibility.toUpperCase() : ''
if (!visibility) return true
return visibility === 'PUBLIC' || visibility === 'PROTECTED'
})
}
function extractTagsFromGenericMemo(memo) {
if (!memo) return []
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 []
}
function collectTags(info, memos) {
const items = Array.isArray(memos) ? memos : []
const out = items.flatMap(function (memo) {
if (!memo) return []
if (getFlavor(info) === 'v023' && global.MemosApiV023 && typeof global.MemosApiV023.extractTagsFromMemo === 'function') {
return global.MemosApiV023.extractTagsFromMemo(memo)
}
return extractTagsFromGenericMemo(memo)
})
return [...new Set(out.filter(Boolean))]
}
function buildUploadVisibility(editorContent, hideTag, showTag, memoLock) {
const content = typeof editorContent === 'string' ? editorContent : ''
const nowTag = content.match(/(#[^\s#]+)/)
let visibility = memoLock || ''
if (nowTag) {
if (nowTag[1] === showTag) visibility = 'PUBLIC'
else if (nowTag[1] === hideTag) visibility = 'PRIVATE'
}
return visibility
}
function buildModernFilter(parts) {
const p = parts || {}
const exprs = []
if (typeof p.contentSearch === 'string' && p.contentSearch.length > 0) {
exprs.push('content.contains(' + JSON.stringify(String(p.contentSearch)) + ')')
}
return exprs.join(' && ')
}
function normalizeUploadedItem(entity, fallbackFilename) {
if (!entity) return null
const inferredId = (function () {
const value = entity.id != null ? entity.id : entity.ID != null ? entity.ID : entity.Id
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value)
if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
return Math.floor(Number(value))
}
return null
})()
const name = entity.name || (inferredId != null ? 'resources/' + String(inferredId) : '')
if (!name && inferredId == null) return null
return {
id: inferredId != null ? inferredId : entity.id,
name: name,
filename: entity.filename || fallbackFilename || name,
createTime: entity.createTime || entity.createdTs || entity.createdAt,
type: entity.type
}
}
function unwrapLegacyMemoEntity(data) {
if (!data) return data
if (data.memo) return data.memo
if (data.data && data.data.memo) return data.data.memo
return data
}
function normalizeLegacyResourceIdList(list) {
const items = Array.isArray(list) ? list : []
return items
.map(function (item) {
if (!item) return null
if (typeof item.id === 'number' && Number.isFinite(item.id)) return Math.floor(item.id)
if (typeof item.id === 'string' && item.id.trim() !== '' && !Number.isNaN(Number(item.id))) {
return Math.floor(Number(item.id))
}
const name = typeof item.name === 'string' ? item.name : ''
const tail = name ? name.split('/').pop() : ''
if (tail && !Number.isNaN(Number(tail))) return Math.floor(Number(tail))
return null
})
.filter(function (value) {
return value != null && Number.isFinite(value)
})
}
function resolve(info) {
const flavor = getFlavor(info)
function listTags(success, fail) {
if (flavor === FLAVOR_V020_V021 && global.MemosApiV020V021) {
global.MemosApiV020V021.getTagSuggestion(info, success, fail)
return
}
if (flavor === 'v023' && global.MemosApiV023) {
const filterExpr = global.MemosApiV023.buildFilter({
rowStatus: 'NORMAL',
creator: 'users/' + info.userid
})
global.MemosApiV023.listMemos(
info,
{ pageSize: 1000, filterExpr: filterExpr },
function (data) {
if (success) success(collectTags(info, extractMemos(data)))
},
fail
)
return
}
if (global.MemosApiModern) {
global.MemosApiModern.fetchMemosWithFallback(
info,
'?pageSize=1000',
function (data) {
if (success) success(collectTags(info, extractMemos(data)))
},
fail
)
}
}
function searchMemos(pattern, success, fail) {
const text = String(pattern || '')
const patternLiteral = JSON.stringify(text)
const legacyFilter = '?filter=' + encodeURIComponent('visibility in ["PUBLIC","PROTECTED"] && content.contains(' + patternLiteral + ')')
if (flavor === 'modern' && global.MemosApiV023) {
const filterExpr = buildModernFilter({ contentSearch: text })
global.MemosApiV023.listMemos(info, { pageSize: 1000, filterExpr: filterExpr }, function (data) {
if (success) success(extractMemos(data))
}, fail)
return
}
if (flavor === 'v023' && global.MemosApiV023) {
const filterExpr = global.MemosApiV023.buildFilter({
visibilities: ['PUBLIC', 'PROTECTED'],
contentSearch: text
})
global.MemosApiV023.listMemos(info, { pageSize: 1000, filterExpr: filterExpr }, function (data) {
if (success) success(keepLegacyVisibleMemos(extractMemos(data)))
}, fail)
return
}
if (flavor === FLAVOR_V020_V021 && global.MemosApiV020V021) {
global.MemosApiV020V021.listMemos(info, { limit: 1000, rowStatus: 'NORMAL', contentSearch: text }, function (data) {
if (success) success(keepLegacyVisibleMemos(extractMemos(data)))
}, fail)
return
}
if (global.MemosApiModern) {
global.MemosApiModern.fetchMemosWithFallback(info, legacyFilter, function (data) {
if (success) success(keepLegacyVisibleMemos(extractMemos(data)))
}, fail)
}
}
function listRandomMemos(success, fail) {
if (flavor === 'modern' && global.MemosApiV023) {
const filterExpr = global.MemosApiV023.buildFilter({})
global.MemosApiV023.listMemos(info, { pageSize: 1000, filterExpr: filterExpr }, function (data) {
if (success) success(extractMemos(data))
}, fail)
return
}
if (flavor === 'v023' && global.MemosApiV023) {
const filterExpr = global.MemosApiV023.buildFilter({ visibilities: ['PUBLIC', 'PROTECTED'] })
global.MemosApiV023.listMemos(info, { pageSize: 1000, filterExpr: filterExpr }, function (data) {
if (success) success(keepLegacyVisibleMemos(extractMemos(data)))
}, fail)
return
}
if (flavor === FLAVOR_V020_V021 && global.MemosApiV020V021) {
global.MemosApiV020V021.listMemos(info, { limit: 1000, rowStatus: 'NORMAL' }, function (data) {
if (success) success(keepLegacyVisibleMemos(extractMemos(data)))
}, fail)
return
}
if (global.MemosApiModern) {
const legacyFilter = '?filter=' + encodeURIComponent('visibility in ["PUBLIC","PROTECTED"]')
global.MemosApiModern.fetchMemosWithFallback(info, legacyFilter, function (data) {
if (success) success(keepLegacyVisibleMemos(extractMemos(data)))
}, fail)
}
}
function deleteResource(item, success, fail) {
const name = item && item.name ? item.name : ''
const rid = item && item.id != null ? item.id : ''
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
})()
if (flavor === FLAVOR_V020_V021 && global.MemosApiV020V021 && typeof global.MemosApiV020V021.deleteResource === 'function' && inferredId != null) {
global.MemosApiV020V021.deleteResource(info, inferredId, success, fail)
return
}
requestJson({
url: info.apiUrl + 'api/v1/' + name,
type: 'DELETE',
headers: { Authorization: 'Bearer ' + info.apiTokens }
}, success, fail)
}
function uploadFile(file, options, success, fail) {
const oldName = String(file && file.name ? file.name : 'upload').split('.')
const fileExt = String(file && file.name ? file.name : '').split('.').pop()
const now = global.dayjs().format('YYYYMMDDHHmmss')
const nextName = oldName[0] + '_' + now + (fileExt ? '.' + fileExt : '')
if (flavor === FLAVOR_V020_V021 && global.MemosApiV020V021) {
global.MemosApiV020V021.uploadResourceBlob(
info,
file,
{ filename: nextName, type: file.type },
function (entity) {
if (success) success(normalizeUploadedItem(entity, nextName))
},
fail
)
return
}
const reader = new FileReader()
reader.onload = function (e) {
const base64String = e && e.target && e.target.result ? String(e.target.result).split(',')[1] : ''
const payload = {
content: base64String,
visibility: buildUploadVisibility(options && options.editorContent, options && options.hideTag, options && options.showTag, options && options.memoLock),
filename: nextName,
type: file.type
}
global.MemosApiModern.uploadAttachmentOrResource(
info,
payload,
function (resp) {
const entity = (resp && resp.resource) || resp
if (success) success(normalizeUploadedItem(entity, nextName))
},
fail
)
}
reader.onerror = fail
reader.readAsDataURL(file)
}
function archiveMemo(memo, success, fail) {
const memoId = memo && memo.id != null ? memo.id : ''
const memoName = memo && memo.name ? memo.name : ''
if (flavor === FLAVOR_V020_V021 && global.MemosApiV020V021 && memoId !== '') {
global.MemosApiV020V021.patchMemo(info, memoId, { rowStatus: 'ARCHIVED' }, success, fail)
return
}
requestJson({
url: info.apiUrl + 'api/v1/' + memoName,
type: 'PATCH',
data: JSON.stringify({ state: 'ARCHIVED' }),
contentType: 'application/json',
dataType: 'json',
headers: { Authorization: 'Bearer ' + info.apiTokens }
}, success, fail)
}
function getMemo(memoRef, success, fail) {
const url = flavor === FLAVOR_V020_V021
? info.apiUrl + 'api/v1/memo/' + memoRef
: info.apiUrl + 'api/v1/' + memoRef
requestJson({
url: url,
type: 'GET',
contentType: 'application/json',
dataType: 'json',
headers: { Authorization: 'Bearer ' + info.apiTokens }
}, function (data) {
if (success) success(flavor === FLAVOR_V020_V021 ? unwrapLegacyMemoEntity(data) : data)
}, fail)
}
function createMemo(params, success, fail) {
const payload = params || {}
if (flavor === FLAVOR_V020_V021 && global.MemosApiV020V021) {
global.MemosApiV020V021.createMemo(
info,
{
content: payload.content,
visibility: payload.visibility,
resourceIdList: normalizeLegacyResourceIdList(payload.resourceIdList)
},
success,
fail
)
return
}
requestJson({
url: info.apiUrl + 'api/v1/memos',
type: 'POST',
data: JSON.stringify({
content: payload.content,
visibility: payload.visibility
}),
contentType: 'application/json',
dataType: 'json',
headers: { Authorization: 'Bearer ' + info.apiTokens }
}, function (data) {
const createdName = data && data.name ? data.name : data && data.memo && data.memo.name ? data.memo.name : ''
const resources = Array.isArray(payload.resourceIdList) ? payload.resourceIdList : []
if (!createdName) {
if (success) success(data)
return
}
if (resources.length === 0) {
getMemo(createdName, success, fail)
return
}
global.MemosApiModern.patchMemoWithAttachmentsOrResources(
info,
createdName,
resources,
function () {
getMemo(createdName, success, fail)
},
function () {
getMemo(createdName, success, fail)
}
)
}, fail)
}
return {
flavor: flavor,
needsAuthenticatedImagePreview: function () {
return flavor === FLAVOR_V020_V021
},
listTags: listTags,
searchMemos: searchMemos,
listRandomMemos: listRandomMemos,
deleteResource: deleteResource,
uploadFile: uploadFile,
archiveMemo: archiveMemo,
getMemo: getMemo,
createMemo: createMemo
}
}
global.MemosApiAdapter = {
FLAVOR_V020_V021: FLAVOR_V020_V021,
KNOWN_FLAVORS: KNOWN_FLAVORS.slice(),
getFlavor: getFlavor,
normalizeDetectedFlavor: normalizeDetectedFlavor,
probeFlavor: probeFlavor,
resolve: resolve
}
})(window)
+512
View File
@@ -0,0 +1,512 @@
(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)
}
if (typeof user.username === 'string' && user.username.trim() !== '') {
return user.username.trim()
}
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) {
if (!Number.isNaN(Number(last))) return Number(last)
if (last.trim() !== '') return last.trim()
}
}
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' },
// 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' }
]
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/' + encodeURIComponent(String(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.MemosApiModern = {
extractUserIdFromAuthResponse: extractUserIdFromAuthResponse,
extractMemosListFromResponse: extractMemosListFromResponse,
isNotFoundLikeXhr: isNotFoundLikeXhr,
authWithFallback: authWithFallback,
fetchMemosWithFallback: fetchMemosWithFallback,
uploadAttachmentOrResource: uploadAttachmentOrResource,
patchMemoWithAttachmentsOrResources: patchMemoWithAttachmentsOrResources
}
})(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.MemosApiV020V021 = {
listMemos: listMemos,
createMemo: createMemo,
patchMemo: patchMemo,
getTagList: getTagList,
getTagSuggestion: getTagSuggestion,
uploadResourceBlob: uploadResourceBlob,
deleteResource: deleteResource
}
})(window)
+119
View File
@@ -0,0 +1,119 @@
(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)
})
}
global.MemosApiV023 = {
buildFilter: buildFilter,
listMemos: listMemos,
extractTagsFromMemo: extractTagsFromMemo
}
})(window)