MediaWiki:Gadget-SettingsManager.js
/**
* [[:commons:MediaWiki:Gadget-SettingsManager.js]]
* Managing user preferences of scripts
* Managing gadgets and gadget preferences
*
* Use it for good, not for evil.
*
* @author Rillke, 2012
* @license GPL v.3
* <nowiki>
*/
// List the global variables for jsHint-Validation.
// Scheme: globalVariable:allowOverwriting[, globalVariable:allowOverwriting][, globalVariable:allowOverwriting]
/*global jQuery:false, mediaWiki:false*/
// Set jsHint-options.
/*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, curly:false, browser:true*/
( function ( $, mw ) {
"use strict";
/**
* Refresh edit token
*
* @example
* refreshToken( function() { doGoodStuff.retry(); } );
*
* @param cb {Function} Callback function. The first argument supplied is whether the operation succeeded.
* @context {closure} private function
* @return {Object} a jQuery deferred-object-queue
*/
var refreshToken = function(cb) {
var mwa = new mw.Api(),
apiDef = mwa.get( {
action: 'query',
meta: 'tokens',
type: 'csrf'
} );
apiDef.done(function(result) {
if (!result.query || !result.query.tokens) return cb( false, 'wrong-response' );
mw.user.tokens.set( 'csrfToken', result.query.tokens.csrftoken );
cb( true );
});
apiDef.fail(function(code, result) {
if (!result.query || !result.query.tokens) return cb( false, code );
});
return apiDef;
};
var firstItem = function(o) {
for (var i in o) {
if (o.hasOwnProperty( i )) {
return o[i];
}
}
};
var valByString = function(identifier) {
var arr = identifier.split( '.' ),
lenArr = arr.length,
i,
elemArr,
objCurrent = window;
for (i = 0; i < lenArr; i++) {
elemArr = arr[i];
objCurrent = objCurrent[elemArr];
}
return objCurrent;
};
var mwPrefPrefix = 'userjs-sm-';
var sm = {
version: '0.1.0.1',
errorPrefix: "SettingsWizard encountered a problem. We regret the inconvenience. ",
/**
* Constructor-method. Returns an option-object you should perform the actions on.
*
* @example
* var opt = mw.libs.settingsManager.option( { optionName: 'CatALotOptions', value: { watchCopy: false, watchRemove: false } } );
* // Save the options we've set before and make the script triggering events on the document, you can listen to
* opt.save( $(document), 'CatALotSaveProgress' );
* // or, use the deferred-object returned:
* opt.save().done(function(msg, status, jsFile) {
* alert( msg );
* }).status(function(msg, status, jsFile) {
* console.log( 'settings progress>' + msg );
* });
*
* @param specsIn {Object} specifications passed in. List of defaults (cf. specs) follows.
* @context {mw.libs.settingsManager}
* @return {Object} option-object you can use for performing actions on.
*/
option: function(specsIn) {
// List of defaults:
var specs = {
// The global name the option will be saved under
// This can be also something like "mw.settingsOfToolX"
optionName: '',
// The position where to save them. By default options are
// saved at the user's common.js or <skin>.js (e.g. vector.js)
// specify other locations like "settingsOfToolX" --> "User:Example/prefs/settingsOfToolX.js"
saveAt: false,
// By default the option is not enclosed in a comment-block
// Comment-blocks are recommended for larger configurations
// specifies the signature that will be used for the enclosing comments
// blockSettingsOfToolX --> //blockSettingsOfToolX///////////////////////
// (ignored when saveAt is set)
encloseSignature: false,
// Specify additional block comments added below the signature
// Should be something that explains what the following JSON does or is good for
// Recommended line-length for consistent alignment: 48 chars
encloseBlock: false,
// If no own location for saving the option is used (options that must be
// available while loading the script should not be saved to a separate file while
// complex options should), if the RegExp will have a match on either the common.js
// or the <skin>.js, and this option is not saved yet to another .js, the option
// will be saved to this js-file. In case the option is not saved yet and there is
// no RegExp supplied or it did not match, the option will be saved to the larger
// JavaScript
triggerSaveAt: false,
// Should the new content be insered in front of the match by triggerSaveAt
// If none of the following options is specified, the new content
// will be appended to the script
insertBeforeTrigger: false,
insertAfterTrigger: false,
replaceTrigger: false,
// Finally the option's value. Objects are possible. They will be automatically
// transformed into a JSON-string
value: undefined,
// Edit summary to use while saving the JavaScript
editSummary: ""
};
if (!specsIn) throw new Error(sm.errorPrefix + "Data to save or retrieve was not supplied by the script using SettingsWizard.");
if (!specsIn.optionName && !specsIn.saveAt) throw new Error(sm.errorPrefix + "The options\'s name was not supplied by the script using SettingsWizard.");
$.extend( true, specs, specsIn );
// Prepare variables we need later
var nsUser = mw.config.get('wgFormattedNamespaces')[2],
skin = mw.config.get('skin'),
user = mw.config.get('wgUserName'),
skinJS = [nsUser, ':', user, '/', skin, '.js'].join(''),
commonJS = [nsUser, ':', user, '/','common', '.js'].join('');
// Event-handler system
var $el, evt, jsFiles, process, $progress = new $.Deferred(), customJS;
var triggerEvt = function(any) {
return (evt && $el && $el instanceof jQuery && $el.triggerHandler( evt, Array.prototype.slice.call( arguments, 0 ) ));
};
process = {
updateVars: function() {
// Reset variables that could be polluted
jsFiles = [];
$progress = new $.Deferred();
customJS = [nsUser, ':', user, '/prefs/', specs.saveAt, '.js'].join('');
},
start: function() {
this.updateVars();
// Subscribe to any event: We want to know everything :-)
$progress.then( triggerEvt, triggerEvt, triggerEvt );
// Always async
setTimeout( $.proxy( this.getScripts, this ), 1 );
return $progress;
},
getScripts: function() {
var i, len;
$progress.notify( "Preparing", 1 );
// First, we need something to work on/ edit token, etc. - request the JavaScript(s)
if (specs.saveAt) {
jsFiles.push( sm.script( customJS ) );
} else {
jsFiles.push( sm.script( skinJS ) );
jsFiles.push( sm.script( commonJS ) );
}
len = jsFiles.length;
for (i = 0; i < len; i++) {
var jsFile = jsFiles[i];
jsFile.fetchText( process.gotJS, process.gotJSErr );
$progress.notify( "Requesting " + jsFile.getSource(), Math.round( (i+1)*(9/len) ) + 1, jsFile );
}
return $progress;
},
gotJS: function(jsFile, r){
jsFile.gotContent = true;
var i, len = jsFiles.length, pendings = 0;
for (i = 0; i < len; i++) {
if (!jsFiles[i].gotContent) {
pendings++;
}
}
$progress.notify( "Got " + jsFile.getSource() + '. File length: ' + jsFile.get().length + ' characters.' , Math.round( (len - pendings)*(9/len) ) + 10, jsFile );
if (pendings) return;
process.process();
},
gotJSErr: function(jsFile) {
$progress.reject( "Failed. Could not retrieve " + jsFile.getSource(), -1, jsFile );
},
getStartBlock: function(sig) {
// String concat is sloooow
return '//' + sig + new Array(48 - 2 - sig.length + 1).join('/');
},
getEndBlock: function(sig) {
return new Array(48 - 2 - 3 - sig.length + 1).join('/') + sig + 'End' + '//';
},
getBlockRegExp: function(sig) {
var escSig = process.escapeRE(sig);
return new RegExp('\\n?\\n?\\/\\/' + escSig + '(?:.|\\n)*' + escSig + 'End\\/\\/', 'g');
},
escapeRE: function(string) {
string = mw.RegExp.escape(string);
var specials = ['t', 'n', 'v', '0', 'f'];
$.each(specials, function(i, s) {
var rx = new RegExp('\\'+s, 'g');
string = string.replace(rx, '\\'+s);
});
return string;
},
getVariableRegExp: function(varName) {
var escVar = process.escapeRE(varName);
return {
varRE: new RegExp('\\s*(?:var\\s+|window\\.)?' + escVar + '\\s*=.+', 'g'),
// Throw a warning if the last char of the line is a "+" , "{", "(" or ","
varWarnRE: new RegExp('\\s*(?:var\\s+|window\\.)?' + escVar + '\\s*=.+(?:\\n?\\s*[\\,\\+\\{\\(])\\s*\\n')
};
},
process: function() {
var JSONVal = JSON.stringify( specs.value ),
sig = specs.encloseSignature,
tsa = specs.triggerSaveAt,
opn = specs.optionName,
jsFile, i, len = jsFiles.length,
plainJSON = !opn && !!jsFile,
oldText, newText, hadMatch;
if (opn) {
// No semicolon for valid JSON!
JSONVal = 'window.' + opn + ' = ' + JSONVal + ';';
}
if (!plainJSON) JSONVal = ((specs.encloseBlock && ('\n' + specs.encloseBlock)) || '') + JSONVal;
if (sig && !plainJSON) JSONVal = process.getStartBlock( sig ) + JSONVal + '\n' + process.getEndBlock( sig );
JSONVal = '\n\n' + JSONVal;
// Fine, we've constructed everything we'll need. Now look up where to insert.
// Looking for signature
if (sig) {
var reBl = process.getBlockRegExp( sig );
for (i = 0; i < len; i++) {
jsFile = jsFiles[i];
oldText = jsFile.get();
newText = oldText.replace( reBl, JSONVal );
if (reBl.test( oldText )) {
$progress.notify( "Replacing text enclosed by signature " + jsFile.getSource(), 25, jsFile );
process.save( jsFile.set( newText ) );
hadMatch = true;
}
}
}
if (hadMatch) return;
// Looking for variable-name
if (opn) {
var vre = process.getVariableRegExp( opn ),
warnFile;
for (i = 0; i < len; i++) {
jsFile = jsFiles[i];
oldText = jsFile.get();
if (vre.varWarnRE.test(oldText)) {
// WARNING!!!
$progress.notify( "Unable to remove config from " + jsFile.getSource(), -2, jsFile );
warnFile = jsFile;
} else {
newText = oldText.replace( vre.varRE, JSONVal );
if (vre.varRE.test( oldText )) {
$progress.notify( "Replacing variable " + jsFile.getSource(), 25, jsFile );
process.save( jsFile.set( newText ) );
hadMatch = true;
}
}
// Only append in case of warning if it was not added to another file
if (warnFile && !hadMatch) {
$progress.notify( "Appending variable after warning to " + jsFile.getSource(), 25, jsFile );
process.save( warnFile.set( oldText + JSONVal ) );
hadMatch = true;
}
}
}
if (hadMatch) return;
// If it's just JSON, replace the whole thingy
if (!opn && specs.saveAt) {
jsFile = jsFiles[0];
$progress.notify( "Replacing whole content of " + jsFile.getSource(), 25, jsFile );
process.save( jsFile.set( JSONVal ) );
hadMatch = true;
}
if (hadMatch) return;
// Looking whether supplied RegExp can find something
if (tsa) {
var searchMatch,
triggerLen = 0;
for (i = 0; i < len; i++) {
jsFile = jsFiles[i];
oldText = jsFile.get();
searchMatch = oldText.search( tsa );
if (-1 !== searchMatch) {
if (specs.insertBeforeTrigger) {
$progress.notify( "Inserting before pattern in " + jsFile.getSource(), 25, jsFile );
jsFile.set( oldText.slice( 0, searchMatch ) + JSONVal + oldText.slice( searchMatch ) );
} else if (specs.insertAfterTrigger) {
triggerLen = oldText.match( tsa )[0].length;
$progress.notify( "Inserting after pattern in " + jsFile.getSource(), 25, jsFile );
jsFile.set( oldText.slice( 0, searchMatch + triggerLen ) + JSONVal + oldText.slice( searchMatch + triggerLen ) );
} else if (specs.replaceTrigger) {
$progress.notify( "Replacing pattern with new content in " + jsFile.getSource(), 25, jsFile );
jsFile.set( oldText.replace( tsa, JSONVal ) );
} else {
$progress.notify( "Found pattern, appending to " + jsFile.getSource(), 25, jsFile );
jsFile.set( oldText + '\n//<nowiki>' + JSONVal + '\n//<\/nowiki>' );
}
process.save( jsFile );
hadMatch = true;
break;
}
}
}
if (hadMatch) return;
// Finally compare file size
var biggest = { size: 0, jsFile: null };
for (i = 0; i < len; i++) {
jsFile = jsFiles[i];
oldText = jsFile.get();
var oldTextLen = oldText.length;
if (oldTextLen >= biggest.size) biggest = {
size: oldTextLen,
jsFile: jsFile
};
}
$progress.notify( "Appending to bigger file: " + biggest.jsFile.getSource(), 25, biggest.jsFile );
biggest.jsFile.set( biggest.jsFile.get() + '\n//<nowiki>' + JSONVal + '\n//<\/nowiki>' );
process.save( biggest.jsFile );
},
save: function(jsFile) {
jsFile.saving = true;
$progress.notify( "Saving " + jsFile.getSource(), 30, jsFile );
jsFile.save( process.saved, process.savedErr, "[[MediaWiki:Gadget-SettingsManager.js|SettingsManager]]: " + specs.editSummary );
},
saved: function(jsFile) {
var i, len = jsFiles.length, jsf, waitingFor = [];
jsFile.saving = false;
for (i = 0; i < len; i++) {
jsf = jsFiles[i];
if (jsf.saving) {
waitingFor.push(jsf.getSource());
}
}
$progress.notify( "Saved " + jsFile.getSource() + ". Waiting for " + (waitingFor.join(', ') || '-'), Math.round( (len - waitingFor.length)*(20/len) ) + 50, jsFile );
if (waitingFor.length) return;
$progress.resolve( "Success!", 100, jsFile );
},
savedErr: function(jsFile, code, errObj) {
$progress.reject( "Error saving " + jsFile.getSource() + ". Code is " + code + ".\n", -1, errObj );
}
};
return {
getSpecs: function() {
return specs;
},
setSpecs: function(specsIn) {
specs = specsIn;
return this;
},
// Warning: If you specified a different save-position ("saveAt")
// and also an optionName, the script has to be fetched and evaluated
// We recommend omitting setting "optionName" when using "saveAt"
fetchValue: function(cb, errCb) {
process.updateVars();
if (specs.saveAt) {
var s = sm.script( customJS );
if (specs.optionName) {
s.fetchText(function() {
s.doEval();
cb( valByString( specs.optionName ) );
}, errCb);
} else {
s.fetchJSON(function(scriptObj, JSON) {
cb( JSON );
}, errCb);
}
return this;
}
cb( valByString( specs.optionName ) );
return this;
},
getValue: function() {
return specs.value;
},
setValue: function(val) {
specs.value = val;
return this;
},
save: function($elem, event) {
// We won't check whether the value is undefined. This is your task.
$el = $elem;
evt = event;
return process.start();
},
getProgress: function() {
return $progress;
}
};
},
/**
* Constructor-method. Returns a script-object you should perform the actions on.
*
* @example
* var commonJS = mw.libs.settingsManager.script( 'User:Example/common.js' );
* commonJS.set( '// empty!' ).setSummary( 'Removing Content' ).save( function() { console.log( 'Successfully removed content from ' + commonJS.getSource() ) } )
*
* @param source {String} The name of the JavaScript file with namespace.
* @context {mw.libs.settingsManager}
* @return {Object} script-object you can use for performing actions on.
*/
script: function(source) {
var content,
page,
summary = "Changing configuration using [[:commons:MediaWiki:Gadget-SettingsManager.js]]",
minor = 1,
exists,
fetch,
save;
fetch = function() {
var mwa = new mw.Api();
return mwa.get( {
prop: 'info|revisions',
titles: source,
rvprop: 'timestamp|content',
intoken: 'edit'
} );
};
save = function() {
var mwa = new mw.Api(),
edit = {
action: 'edit',
title: source,
text: 'object' === typeof content ? JSON.stringify(content) : content,
summary: summary,
watchlist: 'nochange',
recreate: 1
};
if (minor) edit.minor = 1;
if (exists) {
edit.basetimestamp = page.revisions[0].timestamp;
} else {
edit.starttimestamp = page.starttimestamp;
}
return mwa.postWithEditToken( edit );
};
return {
get: function() {
return content;
},
getSource: function() {
return source;
},
doEval: function() {
/*jshint evil:true */
return eval(content);
},
parseJSON: function() {
return ('string' === typeof content && content !== '') ? JSON.parse( content ) : '';
},
// Supplied callback called with a string as second argument
fetchText: function(cb, errCb) {
var pgs, pg, scriptObj = this;
fetch().done( function(result) {
pgs = result.query.pages;
page = firstItem( pgs );
exists = !!(page.revisions && page.revisions[0]);
content = (exists && page.revisions[0]['*']) || '';
cb( scriptObj, content );
} ).fail( function( status, errObj ) {
errCb( scriptObj, status, errObj );
} );
return this;
},
// Supplied callback called with parsed JSON-data as second argument
fetchJSON: function(cb, errCb) {
this.fetchText( function(scriptObj, content) {
cb( scriptObj, scriptObj.parseJSON() );
}, function(scriptObj, status, errObj) {
errCb( scriptObj, status, errObj );
} );
return this;
},
set: function(newContent) {
content = newContent;
return this;
},
setMinor: function(newMinor) {
minor = !!newMinor;
},
setSummary: function(newSummary) {
summary = newSummary;
},
save: function(cb, errCb, newSummary, newContent, newMinor) {
var scriptObj = this;
if (newContent !== undefined) content = newContent;
if (newSummary !== undefined) summary = newSummary;
if (newMinor !== undefined) minor = !!newMinor;
save().done( function(result) {
cb( scriptObj, result );
} ).fail( function(status, errObj) {
errCb( scriptObj, status, errObj );
} );
return this;
}
};
},
/**
* Switch a user preference using Ajax!
*
* @example
* mw.libs.settingsManager.switchPref( 'myOption', 'new value' );
*
* @param prefName {String} The name of the preference.
* @param prefVal {String} The new value the preference should set to.
* @param cb {Function} Callback in case of success.
* @param cb {Function} Callback in case of an error.
* @context {mw.libs.settingsManager}
* @return {Object} a jQuery deferred-object-queue. Don't use it for error-handling - Done by this method.
*/
switchPref: function(prefName, prefVal, cb, errCb) {
var mwa = new mw.Api(),
args = arguments,
prefString = (typeof prefVal === 'object') ? JSON.stringify(prefVal) : prefVal,
apiDef = mwa.post( {
action: 'options',
token: mw.user.tokens.get('csrfToken'),
optionname: prefName,
optionvalue: prefString || 0
} );
// If we changed a preference successfully, update user.options reflecting the change
apiDef.done( function() {
mw.user.options.set( prefName, prefString );
} );
if (cb) apiDef.done( cb );
// Catch badtoken and some other common errors
apiDef.fail( function(code, result) {
switch (code) {
case 'badtoken':
refreshToken(function (gotANewToken) {
if (gotANewToken) return sm.switchPref.apply( sm, Array.prototype.slice.call( args, 0 ) );
} );
// Stop the propagation of
return false;
case 'http':
case 'ok-but-empty':
setTimeout( function() {
return sm.switchPref.apply( sm, Array.prototype.slice.call(args, 0) );
}, 2500 );
return false;
default:
return (errCb && errCb(code, result) && false);
}
} );
return apiDef;
},
/**
* Switch a gadget preference using Ajax!
*
* @example
* mw.libs.settingsManager.switchGadgetPref( 'myOption', 'new value' ).done(function() { console.log("DONE!") });
*
* @param prefName {String} The name of the preference.
* @param prefVal {String} The new value the preference should set to.
* @context {mw.libs.settingsManager}
* @return {Object} $.Deferred; a jQuery deferred-object-queue.
*/
switchGadgetPref: function(prefName, prefVal) {
var $def = $.Deferred();
sm.switchPref( mwPrefPrefix + prefName, prefVal, $.proxy( $def.resolve, $def ), $.proxy( $def.reject, $def ) );
return $def;
},
/**
* Fetch a Gadget preference from various sources!
*
* @example
* mw.libs.settingsManager.fetchGadgetSetting( 'mySetting', ['storage', 'option'] ).done(function(prefName, settingValue) { console.log("DONE!") });
*
* @param prefName {String} The name of the preference.
* @param prefSources {Array} One or more of the following values 'storage', 'cookie', 'option', 'window'. Default (if not passed):
* All in the order listed there. Note that they are processed in the order you pass them in and as soon as one is found,
* Script will return.
* IMPORTANT: The Array is changed while processing. So make a copy if you need it again before passing it.
*
* @context {mw.libs.settingsManager}
* @return {Object} $.Deferred; a jQuery deferred-object-queue.
*/
fetchGadgetSetting: function(prefName, prefSources) {
var $def = $.Deferred(), requires = [], options = {
'storage': {
requires: ['jquery.jStorage'],
fetch: function() {
var v = $.jStorage.get( prefName );
return (null === v || undefined === v) ? undefined : v;
}
},
'cookie': {
requires: ['mediawiki.cookie'],
fetch: function() {
var v = mw.cookie.get( prefName );
try {
v = JSON.parse( v );
} catch(invalidJSON) {}
return (null === v || undefined === v) ? undefined : v;
}
},
'option': {
requires: ['mediawiki.user', 'user.options'],
fetch: function() {
var v = mw.user.options.get( mwPrefPrefix + prefName );
try {
v = JSON.parse( v );
} catch(invalidJSON) {}
return (null === v || undefined === v) ? undefined : v;
}
},
'window': {
requires: [],
fetch: function() {
return window[prefName];
}
}
};
if (!prefSources) prefSources = [];
if (!prefSources.length) prefSources = ['storage', 'cookie', 'option', 'window'];
var _fetch = function(s) {
var so = options[s];
if (so) {
mw.loader.using( so.requires, function() {
var v = so.fetch();
if (undefined === v) {
_fetched();
} else {
$def.resolve( prefName, v );
}
} );
} else {
// Security guard: Don't load settings from unprotected pages
if (!/^(?:User\:|MediaWiki\:).+\.js$/.test( s )) _fetched();
sm.script( s ).fetchJSON( function(me, jsonData) {
if (jsonData) {
$def.resolve( prefName, jsonData );
} else {
_fetched();
}
}, $.proxy( $def.reject, $def ) );
}
},
_fetched = function() {
prefSources.shift();
if (prefSources.length) {
_fetch( prefSources[0] );
} else {
$def.resolve( prefName /* no pref found */ );
}
};
$.each( prefSources, function(i, s) {
var so = options[s];
if (so) requires = requires.concat( options[s].requires );
} );
mw.loader.load( requires );
// ensure async
setTimeout( function() {
_fetch(prefSources[0]);
}, 10 );
return $def;
},
/**
* Constructor-method. Returns an option-object you should perform the actions on.
*
* @example
* var slideshowGadget = mw.libs.settingsManager.gadget( 'Slideshow' );
* if (slideshowGadget.isEnabled()) { slideshowGadget.disable( myCallback ) }
*
* // Enable a gadget and load it:
* mw.libs.settingsManager.gadget( 'Slideshow' ).load().enable();
*
* @param gadgetName {Object} The name of the gadget. (Not the script file; without Gadget- prefix or other decoration)
* @context {mw.libs.settingsManager}
* @return {Object} gadget-object you can use for performing actions on.
*/
gadget: function(gadgetName) {
var optGadget = 'gadget-' + gadgetName,
rlGadget = 'ext.gadget.' + gadgetName;
return {
getName: function() {
return gadgetName;
},
isDefault: function() {
var opt = mw.user.options.get( optGadget );
return ('number' === typeof opt || '' === opt);
},
isEnabled: function() {
var opt = mw.user.options.get( optGadget );
return !!opt;
},
getState: function() {
return mw.loader.getState( rlGadget );
},
isLoaded: function() {
return ('ready' === this.getState());
},
load: function(cb, errCb) {
// Always async
if (this.isLoaded && cb) return setTimeout( function() {
cb( gadgetName, true );
}, 1 );
mw.loader.using( rlGadget,
cb ? function() {
cb( gadgetName );
} : undefined,
errCb ? function() {
errCb( gadgetName );
} : undefined
);
return this;
},
enable: function(cb, errCb) {
// Type wouldn't matter due to URL-encoding but we also want to update
// the user.options object
sm.switchPref( optGadget, this.isDefault() ? 1 : '1', cb, errCb );
return this;
},
disable: function(cb, errCb) {
sm.switchPref( optGadget, '', cb, errCb );
return this;
}
};
}
};
mw.libs.settingsManager = sm;
// TODO add to gadget-def
// mw.loader.load([ 'mediawiki.user', 'user.options']);
}( jQuery, mediaWiki ));
Content Disclaimer
Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.
- The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
- There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
- It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
- Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
- Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.